From 95c24c48eb379f2967780df1117723a4a617cbd2 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:31:09 +0900 Subject: [PATCH 1/3] fix(cursor): re-arm finalize after completion-only tool frames --- src/adapters/cursor/live-transport.ts | 15 +++++- tests/cursor-tool-finalize-race.test.ts | 71 ++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index eafb2c9b4b..f71652e36a 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1086,12 +1086,25 @@ class LiveCursorTransport implements CursorTransport { } return; } + // A completion may carry only callId. Capture its ownership before mapping removes the open + // call, because the embedded-tool classifier cannot identify that valid compact frame. + const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined; + const completesOpenClientTool = update?.case === "toolCallCompleted" + && state.openToolCalls.has(update.value.callId); const mapped = mapCursorProtobufServerMessage(message, state); if (mapped.length > 0) { // A client tool call announced/committed via interactionUpdate (toolCallStarted/partialToolCall/ // toolCallCompleted) changes the call set, so revoke any finalize armed by an earlier drain. - if (isClientToolFrame(message)) this.noteClientToolActivity(); + // A completion can also commit and drain a late call without a following mcpArgs frame; in + // that case re-arm finalization here so the Responses bridge does not wait forever. + const clientToolFrame = completesOpenClientTool || isClientToolFrame(message); + if (clientToolFrame) this.noteClientToolActivity(); for (const event of mapped) push(event); + if ( + clientToolFrame + && state.openToolCalls.size === 0 + && mapped.some(event => event.type === "tool_call_end") + ) this.scheduleClientToolFinalize(state, push); return; } // The frame produced no outward Responses event (e.g. toolCallStarted / partialToolCall args diff --git a/tests/cursor-tool-finalize-race.test.ts b/tests/cursor-tool-finalize-race.test.ts index d094cc34ed..14f2333126 100644 --- a/tests/cursor-tool-finalize-race.test.ts +++ b/tests/cursor-tool-finalize-race.test.ts @@ -10,6 +10,7 @@ import { McpArgsSchema, McpToolCallSchema, ToolCallSchema, + ToolCallCompletedUpdateSchema, ToolCallStartedUpdateSchema, InteractionUpdateSchema, } from "../src/adapters/cursor/gen/agent_pb"; @@ -57,8 +58,46 @@ function execFrame(id: number, callId: string, toolName: string, argText: string }); } +function completedFrame(callId: string, toolName: string) { + const toolCall = create(ToolCallSchema, { + tool: { + case: "mcpToolCall", + value: create(McpToolCallSchema, { + args: create(McpArgsSchema, { name: toolName, toolName, toolCallId: callId, providerIdentifier: PROVIDER }), + }), + }, + }); + return create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { callId, modelCallId: callId, toolCall }), + }, + }), + }, + }); +} + +function completedByCallIdFrame(callId: string) { + return create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { callId, modelCallId: callId }), + }, + }), + }, + }); +} + interface Harness { - feed(frame: ReturnType): Promise; + feed( + frame: ReturnType | ReturnType | ReturnType, + ): Promise; events: CursorServerMessage[]; closeCodes: number[]; cancelled(): boolean; @@ -162,4 +201,34 @@ describe("transport finalize race (hidden parallel sibling)", () => { const ends = h.events.filter(e => e.type === "tool_call_end").length; expect(ends).toBe(2); }); + + test("completion-only sibling re-arms finalize after draining the call set", async () => { + const h = makeHarness(20, ["echo_a", "echo_b"]); + await h.feed(startedFrame("call_a", "echo_a")); + await h.feed(execFrame(1, "call_a", "echo_a", "A")); + + // A completed sibling can arrive without a preceding started/mcpArgs frame. It revokes the + // pending finalize while mapping the terminal tool event, then must arm a fresh finalize. + await h.feed(completedFrame("call_b", "echo_b")); + expect(h.events.map(e => e.type)).not.toContain("done"); + + await sleep(60); + expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); + expect(h.events.filter(e => e.type === "tool_call_end")).toHaveLength(2); + expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); + }); + + test("call-id-only completion re-arms finalize for an open client tool", async () => { + const h = makeHarness(20, ["echo_a"]); + await h.feed(startedFrame("call_a", "echo_a")); + + // Cursor may omit the embedded ToolCall and identify a previously opened call only by id. + await h.feed(completedByCallIdFrame("call_a")); + expect(h.events.map(e => e.type)).not.toContain("done"); + + await sleep(60); + expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); + expect(h.events.filter(e => e.type === "tool_call_end")).toHaveLength(1); + expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); + }); }); From 78d4ed9c3446bc7a78fed7896e9a2be012ece35f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:51:51 +0900 Subject: [PATCH 2/3] test(cursor): prove finalize grace is re-armed --- tests/cursor-tool-finalize-race.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/cursor-tool-finalize-race.test.ts b/tests/cursor-tool-finalize-race.test.ts index 14f2333126..c77be24267 100644 --- a/tests/cursor-tool-finalize-race.test.ts +++ b/tests/cursor-tool-finalize-race.test.ts @@ -203,16 +203,23 @@ describe("transport finalize race (hidden parallel sibling)", () => { }); test("completion-only sibling re-arms finalize after draining the call set", async () => { - const h = makeHarness(20, ["echo_a", "echo_b"]); + const h = makeHarness(200, ["echo_a", "echo_b"]); await h.feed(startedFrame("call_a", "echo_a")); await h.feed(execFrame(1, "call_a", "echo_a", "A")); + await sleep(60); // A completed sibling can arrive without a preceding started/mcpArgs frame. It revokes the // pending finalize while mapping the terminal tool event, then must arm a fresh finalize. await h.feed(completedFrame("call_b", "echo_b")); expect(h.events.map(e => e.type)).not.toContain("done"); - await sleep(60); + // Cross the original deadline while staying inside the re-armed grace window. If the first + // timer survived, this assertion observes a premature terminal event. + await sleep(160); + expect(h.events.map(e => e.type)).not.toContain("done"); + expect(h.cancelled()).toBe(false); + + await sleep(80); expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); expect(h.events.filter(e => e.type === "tool_call_end")).toHaveLength(2); expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]); From fe41ee87a424f95dd36824efff96577d5b04416b Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:49:31 +0900 Subject: [PATCH 3/3] test(cursor): widen finalize timer margin --- tests/cursor-tool-finalize-race.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/cursor-tool-finalize-race.test.ts b/tests/cursor-tool-finalize-race.test.ts index c77be24267..b4cce2bd64 100644 --- a/tests/cursor-tool-finalize-race.test.ts +++ b/tests/cursor-tool-finalize-race.test.ts @@ -203,10 +203,10 @@ describe("transport finalize race (hidden parallel sibling)", () => { }); test("completion-only sibling re-arms finalize after draining the call set", async () => { - const h = makeHarness(200, ["echo_a", "echo_b"]); + const h = makeHarness(1_000, ["echo_a", "echo_b"]); await h.feed(startedFrame("call_a", "echo_a")); await h.feed(execFrame(1, "call_a", "echo_a", "A")); - await sleep(60); + await sleep(300); // A completed sibling can arrive without a preceding started/mcpArgs frame. It revokes the // pending finalize while mapping the terminal tool event, then must arm a fresh finalize. @@ -214,12 +214,13 @@ describe("transport finalize race (hidden parallel sibling)", () => { expect(h.events.map(e => e.type)).not.toContain("done"); // Cross the original deadline while staying inside the re-armed grace window. If the first - // timer survived, this assertion observes a premature terminal event. - await sleep(160); + // timer survived, this assertion observes a premature terminal event with 150 ms of margin + // on either side of the two deadlines. + await sleep(850); expect(h.events.map(e => e.type)).not.toContain("done"); expect(h.cancelled()).toBe(false); - await sleep(80); + await sleep(250); expect(h.events.map(e => e.type).filter(t => t === "done")).toHaveLength(1); expect(h.events.filter(e => e.type === "tool_call_end")).toHaveLength(2); expect(h.closeCodes).toEqual([NGHTTP2_CANCEL]);