Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 78 additions & 1 deletion tests/cursor-tool-finalize-race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
McpArgsSchema,
McpToolCallSchema,
ToolCallSchema,
ToolCallCompletedUpdateSchema,
ToolCallStartedUpdateSchema,
InteractionUpdateSchema,
} from "../src/adapters/cursor/gen/agent_pb";
Expand Down Expand Up @@ -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<typeof startedFrame>): Promise<void>;
feed(
frame: ReturnType<typeof startedFrame> | ReturnType<typeof completedFrame> | ReturnType<typeof completedByCallIdFrame>,
): Promise<void>;
events: CursorServerMessage[];
closeCodes: number[];
cancelled(): boolean;
Expand Down Expand Up @@ -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]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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]);
});
});
Loading