diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 98c8779ca7..0484bf373f 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -335,13 +335,15 @@ function normalizeJsonText(text: string, toolName: string | undefined, state: Cu * streamed onward), and/or as a structured protobuf map on `toolCallCompleted`. We emit the args * exactly once, at completion, so they can always be schema-normalized regardless of which form * arrived. The completed map wins when present (canonical); otherwise the buffered streamed text is - * used. Returns an empty string when there are no args (the bridge serializes that as `{}`). + * used. Preserve malformed buffered text so the downstream client rejects the call instead of + * silently treating it as a no-arg call. Returns an empty string only when there were no args (the + * bridge serializes that as `{}`). */ function resolveCompletedArgs(buffered: string, args: McpArgs | undefined, state: CursorProtobufEventState): string { if (hasMcpArgBytes(args)) return decodeMcpArgsNormalized(args, state); const name = mcpWireNameFromArgs(args); if (isCompleteJson(buffered)) return normalizeJsonText(buffered, name, state); - return ""; + return buffered; } const PATCH_BEGIN = "*** Begin Patch"; diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 779d0df0b0..6430107011 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -309,6 +309,30 @@ describe("Cursor protobuf tool-call events", () => { expect(delta && delta.type === "tool_call_delta" ? JSON.parse(delta.arguments) : null).toEqual({ path: "a.txt" }); }); + test("preserves incomplete streamed args when completion has no argument map", () => { + const state = createCursorProtobufEventState(); + const toolCall = mcpToolCall("mcp__fs__read_file", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "partialToolCall", + value: create(PartialToolCallUpdateSchema, { + callId: "call_1", + modelCallId: "model_1", + toolCall, + argsTextDelta: "{\"path\":", + }), + }), state)).toEqual([]); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { callId: "call_1", modelCallId: "model_1", toolCall }), + }), state)).toEqual([ + { type: "tool_call_start", id: "call_1", name: "mcp__fs__read_file" }, + { type: "tool_call_delta", arguments: "{\"path\":" }, + { type: "tool_call_end", id: "call_1" }, + ]); + }); + test("commits an advertised no-arg tool call instead of dropping it", () => { // A completed client tool call with no args and no streamed text must still reach Codex when the // tool is advertised (e.g. a no-arg list/status tool). The bridge serializes empty args as "{}".