From 0046816b3c1f0083ade78115b9d7690602a61f96 Mon Sep 17 00:00:00 2001 From: mose Date: Mon, 17 Aug 2026 21:58:05 +0900 Subject: [PATCH 1/3] fix(cursor): pin Connect x-session-id to the client conversation Reuse the resolved conversationId as Connect x-session-id so transport rebuilds for the same client thread keep one session identity. Fall back to a random UUID only when no session identity exists. Native-exec/background shells use a separate per-transport owner (see follow-up commit). --- src/adapters/cursor.ts | 1 + src/adapters/cursor/live-transport.ts | 3 ++- src/adapters/cursor/transport.ts | 6 ++++++ tests/cursor-live-transport.test.ts | 14 ++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) 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 da46e6f468..f85b904b7f 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -432,9 +432,10 @@ 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; 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 diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index 4f7a796e02..df3def90d7 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -33,6 +33,12 @@ export interface CursorTransportFactoryInput { * native local exec authorization because the text is caller-controlled. */ requestDeclaresFullAccess?: boolean; + /** + * Stable Cursor Connect `x-session-id`. Must survive transport rebuilds for the + * same GJC/OCX client thread; a fresh UUID per turn looks like a new IDE session + * and trips Cursor Connect resource limits. + */ + sessionId?: string; } export type CursorTransportFactory = (input: CursorTransportFactoryInput) => CursorTransport; diff --git a/tests/cursor-live-transport.test.ts b/tests/cursor-live-transport.test.ts index 68610f7e77..b96745dfa8 100644 --- a/tests/cursor-live-transport.test.ts +++ b/tests/cursor-live-transport.test.ts @@ -105,6 +105,20 @@ describe("Cursor live transport", () => { expect(internals.execContext.sessionId).toBe(internals.sessionId); 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; + execContext: { sessionId?: string }; + }; + expect(internals.sessionId).toBe("cursor_from_gjc_session"); + expect(internals.execContext.sessionId).toBe("cursor_from_gjc_session"); + }); test("fails before network when no Cursor credential is configured", () => { const prev = process.env.OPENCODEX_CURSOR_TEST_TOKEN; From 4d5850e4d5e263510f57ea783f36c30f4edb7794 Mon Sep 17 00:00:00 2001 From: mose Date: Mon, 17 Aug 2026 23:01:34 +0900 Subject: [PATCH 2/3] fix(cursor): drive external tool-result continuations as userMessageAction Drive external-model tool-result hops as userMessageAction so history-blob tool results stay visible without ResumeAction. Native models keep resumeAction. Live Connect probes informed this encoding choice; unit tests only lock the action case. --- src/adapters/cursor/protobuf-request.ts | 27 ++++++++++++++++--- tests/cursor-blob.test.ts | 36 +++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 4ede0a482f..d994bce41d 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -65,6 +65,15 @@ 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. External wire models cannot use + * resumeAction (Connect rejects replayed-history resumes past a few thousand tokens with + * resource_exhausted), so the continuation is driven as a userMessageAction; the tool results + * themselves are already in the history blobs. + */ +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 { @@ -577,18 +586,28 @@ 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 + // Tool-result-only turns on NATIVE models resume the remembered Cursor conversation with + // results in history. EXTERNAL wire models must NOT use resumeAction: after the client-tool + // suspend the server holds no live step, and Connect deterministically rejects external + // resume runs whose replayed history exceeds a few thousand tokens with resource_exhausted + // ("resource limit exceeded", surfaced as a 429). Verified live 2026-08-17: identical + // 45k-token histories pass as userMessageAction and fail as resumeAction. Results stay in + // the history blobs either way; the action text only tells the model to continue. + 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(), @@ -688,7 +707,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/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index e8df19da3e..d395cbfe71 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"); @@ -592,7 +593,7 @@ describe("Cursor blob handshake", () => { const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; const historicalUser = roots.find(root => root.role === "user"); expect(historicalUser?.content).toEqual([{ type: "text", text: "read a file" }]); - 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"); }); @@ -619,6 +620,37 @@ describe("Cursor blob handshake", () => { expect(run?.action?.action.case).toBe("resumeAction"); }); + + test("drives external-model tool-result continuations as userMessageAction", () => { + // Connect deterministically rejects external resumeAction runs whose replayed history + // exceeds a few thousand tokens (resource_exhausted). The continuation must be a + // userMessageAction; the 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", () => { From e3c99658625799d73132402e60781fe462ebe2b3 Mon Sep 17 00:00:00 2001 From: mose Date: Tue, 18 Aug 2026 13:06:41 +0900 Subject: [PATCH 3/3] fix(cursor): split shell owner from Connect session id Independent review: do not claim the 429 diagnosis from unit tests, keep native-exec/background shells on a per-transport owner so overlapping turns cannot reap each other, and lock sessionId forwarding in adapter plus Connect header tests. --- src/adapters/cursor/live-transport.ts | 8 +++-- src/adapters/cursor/protobuf-request.ts | 17 ++++------ src/adapters/cursor/transport.ts | 5 ++- tests/cursor-adapter.test.ts | 42 +++++++++++++++++++++++++ tests/cursor-blob.test.ts | 5 ++- tests/cursor-hardening.test.ts | 32 +++++++++++++++++-- tests/cursor-live-transport.test.ts | 28 +++++++++++++---- 7 files changed, 109 insertions(+), 28 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index f85b904b7f..02a55ef3c6 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -433,6 +433,8 @@ class LiveCursorTransport implements CursorTransport { private firstFrameLogged = false; /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */ 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(); @@ -447,7 +449,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); @@ -482,7 +484,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) { @@ -678,7 +680,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 d994bce41d..c2b639eeb3 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -66,10 +66,9 @@ export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192; export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024; /** - * Action text for external-model tool-result continuations. External wire models cannot use - * resumeAction (Connect rejects replayed-history resumes past a few thousand tokens with - * resource_exhausted), so the continuation is driven as a userMessageAction; the tool results - * themselves are already in the history blobs. + * 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."; @@ -587,13 +586,9 @@ function buildPreparedCursorRunRequest( ? appendCursorGenericToolUseHint(request.tools, rawText) : rawText; const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult"; - // Tool-result-only turns on NATIVE models resume the remembered Cursor conversation with - // results in history. EXTERNAL wire models must NOT use resumeAction: after the client-tool - // suspend the server holds no live step, and Connect deterministically rejects external - // resume runs whose replayed history exceeds a few thousand tokens with resource_exhausted - // ("resource limit exceeded", surfaced as a 429). Verified live 2026-08-17: identical - // 45k-token histories pass as userMessageAction and fail as resumeAction. Results stay in - // the history blobs either way; the action text only tells the model to continue. + // 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" diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index df3def90d7..ce18fbd5be 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -34,9 +34,8 @@ export interface CursorTransportFactoryInput { */ requestDeclaresFullAccess?: boolean; /** - * Stable Cursor Connect `x-session-id`. Must survive transport rebuilds for the - * same GJC/OCX client thread; a fresh UUID per turn looks like a new IDE session - * and trips Cursor Connect resource limits. + * 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; } 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 d395cbfe71..5490ded17e 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -622,9 +622,8 @@ describe("Cursor blob handshake", () => { }); test("drives external-model tool-result continuations as userMessageAction", () => { - // Connect deterministically rejects external resumeAction runs whose replayed history - // exceeds a few thousand tokens (resource_exhausted). The continuation must be a - // userMessageAction; the tool results stay in the history blobs. + // 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", 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 b96745dfa8..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,17 +92,19 @@ 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", () => { @@ -114,10 +116,24 @@ describe("Cursor live transport", () => { }); 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("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", () => {