From c9c818d131f1e26ebff0602dcec494d239731962 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 16:57:45 +0000 Subject: [PATCH 1/4] fix(cursor): settle clean Connect terminal without HTTP EOF --- src/adapters/cursor/live-transport.ts | 22 ++++++++++++++++++++- structure/04_transports-and-sidecars.md | 8 ++++++++ tests/cursor-eof-terminal.test.ts | 26 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 83658837bf..2f3b59e67b 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1000,7 +1000,27 @@ class LiveCursorTransport implements CursorTransport { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt, } : { framesReceived: this.framesReceived, elapsedMs: Date.now() - this.turnStartedAt }); - if (endError) failAndClear(endError); + if (endError) { + failAndClear(endError); + return; + } + // Connect's clean END_STREAM envelope is the protocol terminal. Cursor's RunSSE body can + // remain open after this frame (or close through an AbortError), so waiting for HTTP EOF + // strands an otherwise completed turn until the outer bridge stall watchdog fires. + // + // Earlier frames in this serialized frameWork chain have already run. Preserve their real + // turnEnded terminal when present; otherwise finalize the clean protocol end once so open + // tool calls still fail closed and a text-only turn receives its normal done event. + if ( + !this.expectedClose + && !state.terminated + && !this.emittedTerminal + && (state.openToolCalls.size > 0 || this.sawAssistantText) + ) { + for (const event of finalizeTurnEvents(state)) push(event); + } + releaseBacklogLease(); + settler.settleFinish(); return; } await this.handleServerMessage(fromBinary(AgentServerMessageSchema, frame.payload), state, push); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 75410d38b3..b16cd8c07f 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -101,6 +101,14 @@ alone never opt a gateway in. and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. +[Decision Log] +- 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. +- 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. +- 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. +- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. +- 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. +- 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. + A replayed compaction item carries an `encrypted_content` blob only its minting backend can decode, and the client replays it on every later turn. The proxy's own `ocx1:` envelopes are transparent base64, so they always lower to plain user messages. A native blob is relayed only when there is no diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index a2a59b2262..fb10887cba 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -79,6 +79,10 @@ function emptyFrame(): Uint8Array { return encodeConnectFrame(toBinary(AgentServerMessageSchema, create(AgentServerMessageSchema, {}))); } +function cleanConnectEndFrame(): Uint8Array { + return encodeConnectFrame(new TextEncoder().encode("{}"), { endStream: true }); +} + function runRequest(tools?: CursorRunRequest["tools"]): CursorRunRequest { return { modelId: "composer-2", @@ -153,6 +157,28 @@ describe("Cursor clean-EOF terminal gate", () => { }); }); + test("clean Connect END_STREAM finishes before a held-open HTTP body (#2300)", async () => { + let fallback: ReturnType | undefined; + const startedAt = Date.now(); + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(turnEndedFrame())); + stream.write(Buffer.from(cleanConnectEndFrame())); + // Model Cursor's observed shape: the protocol has ended, but the HTTP body has not. The + // fallback keeps the pre-fix test bounded; correct code returns well before it fires. + fallback = setTimeout(() => { + try { stream.end(); } catch { /* transport already closed */ } + }, 500); + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest()); + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + }); + if (fallback) clearTimeout(fallback); + expect(Date.now() - startedAt).toBeLessThan(450); + }); + test("EOF with no open tool call keeps its existing graceful finish", async () => { await withH2Server(respondWith([emptyFrame()]), async baseUrl => { const { failure } = await drain(baseUrl, runRequest()); From 56bff341a724233c114144df3c0b3cde3af49ef4 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 18:05:23 +0000 Subject: [PATCH 2/4] test(cursor): harden clean terminal teardown --- src/adapters/cursor/live-transport.ts | 17 +++++++++++++++++ tests/cursor-eof-terminal.test.ts | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 2f3b59e67b..e00e992a6a 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -753,6 +753,22 @@ class LiveCursorTransport implements CursorTransport { } } + /** + * A clean Connect END_STREAM owns the turn terminal even when Cursor keeps the + * HTTP body open or tears it down with an abort/reset immediately afterward. + * Stop client-side liveness work and classify that later transport close as + * expected without actively sending an RST_STREAM back to Cursor. + */ + private markProtocolComplete(): void { + this.expectedClose = true; + this.clearPendingFinalize(); + if (this.heartbeat) { + clearInterval(this.heartbeat); + this.heartbeat = undefined; + } + this.clearFirstFrameTimer(); + } + private startShellCleanup(): Promise { return this.shellCleanup ??= terminateBackgroundShellsForSession(this.shellOwnerId); } @@ -1019,6 +1035,7 @@ class LiveCursorTransport implements CursorTransport { ) { for (const event of finalizeTurnEvents(state)) push(event); } + this.markProtocolComplete(); releaseBacklogLease(); settler.settleFinish(); return; diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index fb10887cba..e5b30c1b52 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -179,6 +179,25 @@ describe("Cursor clean-EOF terminal gate", () => { expect(Date.now() - startedAt).toBeLessThan(450); }); + test("clean Connect END_STREAM wins over an immediate abort-shaped body teardown (#2300)", async () => { + await withH2Server(stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(Buffer.from(turnEndedFrame())); + stream.write(Buffer.from(cleanConnectEndFrame())); + setImmediate(() => { + const abort = new Error("The operation was aborted"); + abort.name = "AbortError"; + stream.destroy(abort); + }); + }, async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest()); + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + expect(messages.some(message => message.type === "error")).toBe(false); + }); + }); + test("EOF with no open tool call keeps its existing graceful finish", async () => { await withH2Server(respondWith([emptyFrame()]), async baseUrl => { const { failure } = await drain(baseUrl, runRequest()); From 76166608f39067ffc91978e076bc2470c007eb92 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 19:17:45 +0000 Subject: [PATCH 3/4] fix(cursor): preserve drained terminal on clean end --- src/adapters/cursor/live-transport.ts | 16 +++++++-- structure/04_transports-and-sidecars.md | 2 +- tests/cursor-eof-terminal.test.ts | 45 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index e00e992a6a..9a86b2fbb6 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1026,14 +1026,24 @@ class LiveCursorTransport implements CursorTransport { // // Earlier frames in this serialized frameWork chain have already run. Preserve their real // turnEnded terminal when present; otherwise finalize the clean protocol end once so open - // tool calls still fail closed and a text-only turn receives its normal done event. + // tool calls still fail closed, a text-only turn receives its normal done event, and a + // drained client-tool turn does not lose the pending terminal when protocol cleanup clears + // its grace timer. + const hasPendingClientToolFinalization = this.pendingFinalize !== undefined; if ( !this.expectedClose && !state.terminated && !this.emittedTerminal - && (state.openToolCalls.size > 0 || this.sawAssistantText) + && ( + state.openToolCalls.size > 0 + || this.sawAssistantText + || hasPendingClientToolFinalization + ) ) { - for (const event of finalizeTurnEvents(state)) push(event); + const terminal = hasPendingClientToolFinalization + ? finalizeAfterDrain(state) + : finalizeTurnEvents(state); + for (const event of terminal) push(event); } this.markProtocolComplete(); releaseBacklogLease(); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index b16cd8c07f..1e14b19215 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -105,7 +105,7 @@ to GUI static serving. - 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. - 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. - 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. -- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. +- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. - 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. - 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index e5b30c1b52..ba21a8ada8 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -3,6 +3,7 @@ import { create, toBinary } from "@bufbuild/protobuf"; import { describe, expect, test } from "bun:test"; import { AgentServerMessageSchema, + ExecServerMessageSchema, InteractionUpdateSchema, McpArgsSchema, McpToolCallSchema, @@ -63,6 +64,29 @@ function toolCallStartedFrame(callId: string, toolName: string): Uint8Array { return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); } +function clientToolArgsFrame(callId: string, toolName: string, argText: string): Uint8Array { + const message = create(AgentServerMessageSchema, { + message: { + case: "execServerMessage", + value: create(ExecServerMessageSchema, { + id: 1, + execId: `exec-${callId}`, + message: { + case: "mcpArgs", + value: create(McpArgsSchema, { + name: toolName, + toolName, + toolCallId: callId, + providerIdentifier: PROVIDER, + args: { text: new TextEncoder().encode(JSON.stringify(argText)) }, + }), + }, + }), + }, + }); + return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); +} + function turnEndedFrame(): Uint8Array { const message = create(AgentServerMessageSchema, { message: { @@ -100,6 +124,12 @@ const APPLY_PATCH_TOOL = [{ freeform: true, }] as unknown as CursorRunRequest["tools"]; +const ECHO_TOOL = [{ + name: "echo_a", + description: "echo text", + parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, +}] as unknown as CursorRunRequest["tools"]; + async function drain(baseUrl: string, request: CursorRunRequest): Promise<{ messages: CursorServerMessage[]; failure?: Error; @@ -198,6 +228,21 @@ describe("Cursor clean-EOF terminal gate", () => { }); }); + test("clean Connect END_STREAM preserves a drained client-tool terminal before its grace timer", async () => { + await withH2Server(respondWith([ + toolCallStartedFrame("call_client_1", "echo_a"), + clientToolArgsFrame("call_client_1", "echo_a", "A"), + cleanConnectEndFrame(), + ]), async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest(ECHO_TOOL)); + + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "tool_call_end")).toHaveLength(1); + expect(messages.filter(message => message.type === "done")).toHaveLength(1); + expect(messages.some(message => message.type === "error")).toBe(false); + }); + }); + test("EOF with no open tool call keeps its existing graceful finish", async () => { await withH2Server(respondWith([emptyFrame()]), async baseUrl => { const { failure } = await drain(baseUrl, runRequest()); From fcc3f5c05ec5fa3ed855f65b7a1e0f5d3dbc2194 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 19:29:22 +0000 Subject: [PATCH 4/4] fix(cursor): keep mixed tool terminals fail-closed --- src/adapters/cursor/live-transport.ts | 2 +- structure/04_transports-and-sidecars.md | 2 +- tests/cursor-eof-terminal.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 9a86b2fbb6..61f2e6e0a8 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1040,7 +1040,7 @@ class LiveCursorTransport implements CursorTransport { || hasPendingClientToolFinalization ) ) { - const terminal = hasPendingClientToolFinalization + const terminal = hasPendingClientToolFinalization && state.openToolCalls.size === 0 ? finalizeAfterDrain(state) : finalizeTurnEvents(state); for (const event of terminal) push(event); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1e14b19215..fd1bee75bc 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -105,7 +105,7 @@ to GUI static serving. - 목적과 의도: Complete Cursor turns at the protocol terminal instead of waiting for a separate HTTP-body EOF that may never arrive. - 기존 구현 및 제약 조건: Cursor can send turnEnded followed by a clean Connect END_STREAM envelope while RunSSE remains open or later closes through an abort-shaped transport error. The adapter logged the clean envelope but did not settle its terminal owner, so a completed-looking turn could remain open until the Responses stall watchdog. - 검토한 주요 대안: Shorten the global stall timeout; treat every later abort as success; settle only when the HTTP stream emits end; make the clean Connect envelope authoritative. -- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. +- 선택한 방식: Process preceding frames in order, preserve an already-emitted terminal, run any already-armed drained client-tool finalizer before protocol cleanup clears its grace timer only while the call set is still drained, otherwise finalize once through the existing fail-closed tool-call logic, and settle the transport successfully on a clean Connect END_STREAM. - 다른 대안 대신 이 방식을 선택한 이유: The protocol envelope is upstream's explicit terminal signal. Timeout changes only hide the race, and globally swallowing aborts would mask genuine mid-turn cancellation. - 장점, 단점 및 영향: Completed Cursor responses no longer wait for the 300-second watchdog when the HTTP body stays open; incomplete tool calls still emit their existing truncation error, and error-bearing Connect terminals remain failures. diff --git a/tests/cursor-eof-terminal.test.ts b/tests/cursor-eof-terminal.test.ts index ba21a8ada8..18d4da50fa 100644 --- a/tests/cursor-eof-terminal.test.ts +++ b/tests/cursor-eof-terminal.test.ts @@ -130,6 +130,11 @@ const ECHO_TOOL = [{ parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, }] as unknown as CursorRunRequest["tools"]; +const ECHO_AND_APPLY_PATCH_TOOLS = [ + ...(ECHO_TOOL ?? []), + ...(APPLY_PATCH_TOOL ?? []), +] as CursorRunRequest["tools"]; + async function drain(baseUrl: string, request: CursorRunRequest): Promise<{ messages: CursorServerMessage[]; failure?: Error; @@ -243,6 +248,24 @@ describe("Cursor clean-EOF terminal gate", () => { }); }); + test("clean Connect END_STREAM keeps a later open sibling fail-closed after a client-tool drain", async () => { + await withH2Server(respondWith([ + toolCallStartedFrame("call_client_2", "echo_a"), + clientToolArgsFrame("call_client_2", "echo_a", "A"), + toolCallStartedFrame("call_open_2", "apply_patch"), + cleanConnectEndFrame(), + ]), async baseUrl => { + const { messages, failure } = await drain(baseUrl, runRequest(ECHO_AND_APPLY_PATCH_TOOLS)); + + expect(failure).toBeUndefined(); + expect(messages.filter(message => message.type === "tool_call_end")).toHaveLength(1); + const terminal = messages.at(-1); + expect(terminal?.type).toBe("error"); + expect((terminal as { message?: string }).message).toContain("call_open_2"); + expect(messages.some(message => message.type === "done")).toBe(false); + }); + }); + test("EOF with no open tool call keeps its existing graceful finish", async () => { await withH2Server(respondWith([emptyFrame()]), async baseUrl => { const { failure } = await drain(baseUrl, runRequest());