diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 015eac1cfc..6f66004ea8 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -121,6 +121,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda headers: incoming.headers, translatorBudget: incoming.translatorBudget, requestDeclaresFullAccess: cursorRequestDeclaresFullAccess(activeRequest), + sessionId: activeRequest.conversationId, }, activeRequest, incoming.abortSignal, diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index b05acd29d7..0d32b94b5b 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -438,9 +438,12 @@ class LiveCursorTransport implements CursorTransport { private firstFrameAt?: number; private firstFrameLogged = false; /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */ - private readonly sessionId = crypto.randomUUID(); + private readonly sessionId: string; + /** Per-transport owner for native-exec / background shells. Must not share conversationId. */ + private readonly shellOwnerId = crypto.randomUUID(); constructor(private readonly input: CursorTransportFactoryInput) { + this.sessionId = input.sessionId?.trim() || crypto.randomUUID(); this.translatorBudget = input.translatorBudget; this.token = resolveCursorToken(input.provider, input.headers); // Grace window before a drained client-tool turn is finalized. Small enough not to look like a @@ -452,7 +455,7 @@ class LiveCursorTransport implements CursorTransport { this.desktopDeps = desktopDepsFromConfig(input.provider.desktopExecutor); this.execContext = { ...this.desktopDeps, - sessionId: this.sessionId, + sessionId: this.shellOwnerId, unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(input.provider, input.requestDeclaresFullAccess === true), }; const servers = resolveMcpServers(input.provider); @@ -487,7 +490,7 @@ class LiveCursorTransport implements CursorTransport { ...this.desktopDeps, ...mcpDepsFromManager(this.mcpManager!), mcpToolDefs, - sessionId: this.sessionId, + sessionId: this.shellOwnerId, unsafeAllowNativeLocalExec: effectiveCursorNativeExecAllow(this.input.provider, this.input.requestDeclaresFullAccess === true), }; } catch (err) { @@ -700,7 +703,7 @@ class LiveCursorTransport implements CursorTransport { } private startShellCleanup(): Promise { - return this.shellCleanup ??= terminateBackgroundShellsForSession(this.sessionId); + return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId); } async close(): Promise { diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index b1d577fcb7..a869634a30 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -68,6 +68,14 @@ export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192; /** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */ export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024; +/** + * Action text for external-model tool-result continuations. Native models keep + * resumeAction; external wire models continue as userMessageAction so the + * results already stored in history blobs are visible without a ResumeAction. + */ +export const CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT = + "Continue: the requested tool results are provided in the conversation history above."; + /** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */ function runtimeTimeZone(): string { try { @@ -731,18 +739,24 @@ function buildPreparedCursorRunRequest( const text = lastRole === "user" || lastRole === "developer" ? appendCursorGenericToolUseHint(request.tools, rawText) : rawText; - // Tool-result-only turns resume the remembered Cursor conversation with results in history. const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult"; - const actionCase = !lastRawIsToolResult && text.trim().length > 0 + // Native models resume the remembered Cursor conversation. External wire + // models continue as userMessageAction so history-blob tool results stay + // visible without a ResumeAction. + const externalToolContinuation = lastRawIsToolResult && isCursorExternalWireModel(request.modelId); + const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0)) ? "userMessageAction" : "resumeAction"; + const actionText = externalToolContinuation + ? CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT + : text; const action = create(ConversationActionSchema, { action: actionCase === "userMessageAction" ? { case: "userMessageAction", value: create(UserMessageActionSchema, { userMessage: create(UserMessageSchema, { - text, + text: actionText, messageId: crypto.randomUUID(), }), requestContext: buildRequestContext(), @@ -842,7 +856,7 @@ function buildPreparedCursorRunRequest( // tools the payload dropped — the defect that blocked PR #376. const modelVisibleParts = [ ...rootPromptMessagesState.serialized, - ...(actionCase === "userMessageAction" ? [text] : []), + ...(actionCase === "userMessageAction" ? [actionText] : []), ...mcpToolDefs.map(modelVisibleToolText), ]; return { diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index 4f7a796e02..ce18fbd5be 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -33,6 +33,11 @@ export interface CursorTransportFactoryInput { * native local exec authorization because the text is caller-controlled. */ requestDeclaresFullAccess?: boolean; + /** + * Stable Cursor Connect `x-session-id` across transport rebuilds for the same + * client thread. Distinct from the per-transport native-exec/shell owner. + */ + sessionId?: string; } export type CursorTransportFactory = (input: CursorTransportFactoryInput) => CursorTransport; diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index 1627d8e607..83f4b77263 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -9,6 +9,7 @@ import { } from "../src/adapters/cursor/thread-continuity"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types"; +import type { CursorTransportFactoryInput } from "../src/adapters/cursor/transport"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createCursorAdapter = (...args: Parameters) => @@ -162,6 +163,47 @@ describe("Cursor adapter live transport", () => { expect(ids).toHaveLength(2); expect(ids[0]).not.toBe(ids[1]); }); + test("passes conversationId as Connect sessionId and isolates helper turns", async () => { + const captured: CursorTransportFactoryInput[] = []; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { + createTransport(input) { + captured.push(input); + return { + async *run() { + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }; + }, + }); + + const parent: OcxParsedRequest = { + modelId: "cursor/auto", + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + stream: false, + options: {}, + _clientThreadId: "parent-thread-session-id", + }; + await adapter.runTurn?.(parent, { headers: new Headers() }, () => {}); + expect(captured).toHaveLength(1); + expect(captured[0]?.sessionId).toBeTruthy(); + expect(captured[0]?.sessionId).toBe(parent._cursorConversationId); + + const helper: OcxParsedRequest = { + modelId: "cursor/auto", + context: { messages: [{ role: "user", content: "summarize", timestamp: 1 }] }, + stream: false, + options: {}, + _clientThreadId: "parent-thread-session-id", + _cursorConversationId: parent._cursorConversationId, + _cursorIsolateConversation: true, + }; + await adapter.runTurn?.(helper, { headers: new Headers() }, () => {}); + expect(captured).toHaveLength(2); + expect(captured[1]?.sessionId).toBeTruthy(); + expect(captured[1]?.sessionId).not.toBe(captured[0]?.sessionId); + expect(captured[1]?.sessionId).toBe(helper._cursorConversationId); + }); test("parseStream reports that the fetch path is disabled", async () => { const adapter = createCursorAdapter(provider); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 8af5a7c250..e91111f782 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -23,6 +23,7 @@ import { } from "../src/lib/app-owned-memory"; import { CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, + CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT, CURSOR_ROUTING_LEVEL_PARAMETER_ID, encodeCursorRunRequest, @@ -246,7 +247,7 @@ describe("Cursor blob handshake", () => { const rootBytes = (run?.conversationState?.rootPromptMessagesJson ?? []) .reduce((sum, id) => sum + blobData(id).byteLength, 0); - expect(run?.action?.action.case).toBe("resumeAction"); + expect(run?.action?.action.case).toBe("userMessageAction"); expect(rootBytes).toBeLessThanOrEqual(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT); expect(JSON.stringify(roots)).toContain("[Tool Result]"); expect(JSON.stringify(roots)).toContain("truncated for Cursor external replay budget"); @@ -594,7 +595,7 @@ describe("Cursor blob handshake", () => { expect(historicalUser?.content).toEqual([{ type: "text", text: "read a file" }]); const toolResultRoot = roots.find(root => JSON.stringify(root).includes("[Tool Result]")); expect(toolResultRoot?.role).toBe("assistant"); - expect(run?.action?.action.case).toBe("resumeAction"); + expect(run?.action?.action.case).toBe("userMessageAction"); expect(JSON.stringify(roots)).toContain("contents"); expect(JSON.stringify(roots)).not.toContain("hidden reasoning"); }); @@ -621,6 +622,36 @@ describe("Cursor blob handshake", () => { expect(run?.action?.action.case).toBe("resumeAction"); }); + + test("drives external-model tool-result continuations as userMessageAction", () => { + // External wire models encode tool-result hops as userMessageAction; native + // models keep resumeAction. Tool results stay in the history blobs. + const bytes = encodeCursorRunRequest({ + modelId: "claude-fable-5", + conversationId: "c-ext-cont", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/claude-fable-5", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + + expect(run?.action?.action.case).toBe("userMessageAction"); + const value = run?.action?.action.case === "userMessageAction" ? run.action.action.value : undefined; + expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); + // Tool results are still replayed via history blobs. + const roots = decodeRootMessages(bytes) as Array<{ role?: string }>; + expect(JSON.stringify(roots)).toContain("contents"); + }); }); describe("Cursor AgentRunRequest.mcp_tools channel", () => { diff --git a/tests/cursor-hardening.test.ts b/tests/cursor-hardening.test.ts index 525cba635b..438ac2bc04 100644 --- a/tests/cursor-hardening.test.ts +++ b/tests/cursor-hardening.test.ts @@ -25,11 +25,11 @@ import { clearModelCache, getProviderDiscoveryStatus } from "../src/codex/model- import { handleManagementAPI } from "../src/server/management-api"; async function withDiscoveryServer( - handler: (stream: http2.ServerHttp2Stream) => void, + handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void, run: (baseUrl: string) => Promise, ): Promise { const server = http2.createServer(); - server.on("stream", handler); + server.on("stream", (stream, headers) => handler(stream, headers)); await new Promise((resolve, reject) => { const onError = (error: Error) => reject(error); server.once("error", onError); @@ -443,6 +443,34 @@ describe("Cursor live transport unexpected EOF", () => { expect(messages.at(-1)).toMatchObject({ type: "done" }); }); }); + test("sends the injected session id as Connect x-session-id", async () => { + let seenSessionId: string | undefined; + await withDiscoveryServer((stream, headers) => { + const raw = headers["x-session-id"]; + seenSessionId = Array.isArray(raw) ? raw[0] : raw; + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(); + }, async baseUrl => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl, apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + firstFrameTimeoutMs: 2_000, + sessionId: "cursor_from_gjc_session", + }); + try { + for await (const _ of transport.run({ + modelId: "composer-2", + conversationId: "cursor_header_test", + system: [], + messages: [{ role: "user", content: "hello" }], + })) { /* drain */ } + } catch { /* fixture closes immediately */ } + finally { + await transport.close?.(); + } + }); + expect(seenSessionId).toBe("cursor_from_gjc_session"); + }); test("synthesizes done after createPlanRequestQuery text on clean Connect EOF", async () => { const planFrame = encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, { diff --git a/tests/cursor-live-transport.test.ts b/tests/cursor-live-transport.test.ts index 68610f7e77..20538c1899 100644 --- a/tests/cursor-live-transport.test.ts +++ b/tests/cursor-live-transport.test.ts @@ -55,7 +55,7 @@ describe("Cursor live transport", () => { translatorBudget: createTestTranslatorBudget(), headers: new Headers(), }); - const sessionId = (transport as unknown as { sessionId: string }).sessionId; + const sessionId = (transport as unknown as { shellOwnerId: string }).shellOwnerId; const fake = spawnTransportOwnedShell(sessionId); let closed = false; const closing = Promise.resolve(transport.close?.()).then(() => { closed = true; }); @@ -73,8 +73,8 @@ describe("Cursor live transport", () => { translatorBudget: createTestTranslatorBudget(), headers: new Headers(), }); - const internals = transport as unknown as { sessionId: string; cancelCursorRun(): void }; - const fake = spawnTransportOwnedShell(internals.sessionId); + const internals = transport as unknown as { shellOwnerId: string; cancelCursorRun(): void }; + const fake = spawnTransportOwnedShell(internals.shellOwnerId); internals.cancelCursorRun(); const closing = Promise.resolve(transport.close?.()); await Promise.resolve(); @@ -92,19 +92,49 @@ describe("Cursor live transport", () => { }); const internals = transport as unknown as { sessionId: string; + shellOwnerId: string; execContext: { sessionId?: string }; mcpManager?: { listToolHandles(): Promise; dispose(): Promise }; prepareMcp(): Promise; }; - expect(internals.execContext.sessionId).toBe(internals.sessionId); + expect(internals.execContext.sessionId).toBe(internals.shellOwnerId); + expect(internals.execContext.sessionId).not.toBe(internals.sessionId); internals.mcpManager = { listToolHandles: async () => [], dispose: async () => {}, }; await internals.prepareMcp(); - expect(internals.execContext.sessionId).toBe(internals.sessionId); + expect(internals.execContext.sessionId).toBe(internals.shellOwnerId); await transport.close?.(); }); + test("honors an injected session id for Cursor Connect x-session-id", () => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + headers: new Headers(), + sessionId: "cursor_from_gjc_session", + }); + const internals = transport as unknown as { + sessionId: string; + shellOwnerId: string; + execContext: { sessionId?: string }; + }; + expect(internals.sessionId).toBe("cursor_from_gjc_session"); + expect(internals.execContext.sessionId).toBe(internals.shellOwnerId); + expect(internals.execContext.sessionId).not.toBe("cursor_from_gjc_session"); + }); + test("keeps a blank injected session id from becoming the Connect x-session-id", () => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + headers: new Headers(), + sessionId: " ", + }); + const internals = transport as unknown as { sessionId: string }; + expect(internals.sessionId.length).toBeGreaterThan(0); + expect(internals.sessionId.trim()).toBe(internals.sessionId); + expect(internals.sessionId).not.toBe(" "); + }); test("fails before network when no Cursor credential is configured", () => { const prev = process.env.OPENCODEX_CURSOR_TEST_TOKEN;