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..b4cce2bd64 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,42 @@ 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(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(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. + await h.feed(completedFrame("call_b", "echo_b")); + 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 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(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]); + }); + + 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]); + }); });