diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index f71652e36a..afc411e243 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -549,6 +549,11 @@ class LiveCursorTransport implements CursorTransport { .filter(isCursorSyntheticStructuredEditTool) .map(cursorToolWireName), ); + const freeformToolNames = new Set( + (cursorVisibleTools ?? []) + .filter(tool => tool.freeform) + .map(tool => namespacedToolName(tool.namespace, tool.name)), + ); this.execContext = { ...this.execContext, clientToolDefs, @@ -578,6 +583,7 @@ class LiveCursorTransport implements CursorTransport { try { state = createCursorProtobufEventState({ clientToolNames: clientToolDefs.map(tool => tool.toolName || tool.name), + freeformToolNames, parallelToolCalls: request.parallelToolCalls, toolSchemas, cursorToolNameMap, @@ -1091,7 +1097,12 @@ class LiveCursorTransport implements CursorTransport { const update = message.message.case === "interactionUpdate" ? message.message.value.message : undefined; const completesOpenClientTool = update?.case === "toolCallCompleted" && state.openToolCalls.has(update.value.callId); + const awaitedNativeArgsBeforeMapping = update?.case === "toolCallCompleted" + && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true; const mapped = mapCursorProtobufServerMessage(message, state); + const beganAwaitingNativeClientToolArgs = update?.case === "toolCallCompleted" + && !awaitedNativeArgsBeforeMapping + && state.openToolCalls.get(update.value.callId)?.awaitingNativeArgs === true; 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. @@ -1108,13 +1119,14 @@ class LiveCursorTransport implements CursorTransport { return; } // The frame produced no outward Responses event (e.g. toolCallStarted / partialToolCall args - // buffering, toolCallDelta, tokenDelta, or a checkpoint update). Tool-call protocol events are - // deferred to completion for atomic, parallel-safe emission, so a turn that silently assembles - // several tool calls can otherwise exceed the bridge's stall watchdog (upstream_stall_timeout). + // buffering, a completion waiting for native args, toolCallDelta, tokenDelta, or a checkpoint + // update). Tool-call protocol events are deferred to completion for atomic, parallel-safe + // emission, so a turn that silently assembles several tool calls can otherwise exceed the + // bridge's stall watchdog (upstream_stall_timeout). // Emit a liveness heartbeat for these progress frames so the watchdog sees the upstream is alive. // Never after a terminal (done/truncation): a stray post-terminal frame must stay fully inert. - if (!state.terminated && isCursorProgressFrame(message)) { - if (isClientToolFrame(message)) this.noteClientToolActivity(); + if (!state.terminated && (isCursorProgressFrame(message) || beganAwaitingNativeClientToolArgs)) { + if (isClientToolFrame(message) || beganAwaitingNativeClientToolArgs) this.noteClientToolActivity(); push({ type: "heartbeat" }); } } diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 98c8779ca7..cb91cb1166 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -20,6 +20,7 @@ import type { TranslatorBudget } from "../../lib/translator-budget"; const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200; const DEFAULT_CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000; +const DEFAULT_MAX_CLIENT_TOOL_CALLS = 330; export interface CursorContextUsageControls { /** @@ -153,13 +154,17 @@ export interface CursorProtobufEventState { */ contextCarryForwardTokens?: number; recordContextTokens?: (tokens: number) => void; - openToolCalls: Map; + openToolCalls: Map; completedToolCalls: Set; /** Set once a terminal `done`/truncation has been emitted, so post-terminal frames stay inert. */ terminated?: boolean; clientToolNames?: Set; + /** Responses/Codex names of request-declared freeform tools advertised to Cursor. */ + freeformToolNames?: ReadonlySet; parallelToolCalls?: boolean; startedClientToolCalls: number; + /** Hard cap on client tool-call records retained during one upstream turn. */ + maxClientToolCalls: number; /** Tool wire-name → original JSON Schema parameters object, for arg-key normalization. */ toolSchemas?: Map; /** Cursor wire-name → original Responses/Codex tool name for this request. */ @@ -195,7 +200,9 @@ function structuredEditCallIsOurs( export function createCursorProtobufEventState(options: { clientToolNames?: Iterable; + freeformToolNames?: Iterable; parallelToolCalls?: boolean; + maxClientToolCalls?: number; toolSchemas?: Map; cursorToolNameMap?: Map; syntheticStructuredEditToolNames?: Iterable; @@ -215,11 +222,17 @@ export function createCursorProtobufEventState(options: { openToolCalls: new Map(), completedToolCalls: new Set(), ...(options.clientToolNames ? { clientToolNames: new Set(options.clientToolNames) } : {}), + ...(options.freeformToolNames ? { freeformToolNames: new Set(options.freeformToolNames) } : {}), ...(options.syntheticStructuredEditToolNames ? { syntheticStructuredEditToolNames: new Set(options.syntheticStructuredEditToolNames) } : {}), ...(options.parallelToolCalls !== undefined ? { parallelToolCalls: options.parallelToolCalls } : {}), startedClientToolCalls: 0, + maxClientToolCalls: typeof options.maxClientToolCalls === "number" + && Number.isFinite(options.maxClientToolCalls) + && options.maxClientToolCalls > 0 + ? Math.floor(options.maxClientToolCalls) + : DEFAULT_MAX_CLIENT_TOOL_CALLS, ...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}), ...(options.cursorToolNameMap ? { cursorToolNameMap: options.cursorToolNameMap } : {}), ...(options.translatorBudget ? { translatorBudget: options.translatorBudget } : {}), @@ -335,13 +348,14 @@ 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 `{}`). + * preserved verbatim so the bridge can reject malformed or truncated JSON instead of silently + * converting it to `{}`. A genuinely empty buffer remains the no-argument case. */ 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"; @@ -461,6 +475,7 @@ export function mapSyntheticMcpExecToToolEvents( options: { allowEmptyArgs?: boolean; state?: CursorProtobufEventState } = {}, ): CursorServerMessage[] { if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) return []; + if (options.state?.terminated) return []; if (options.allowEmptyArgs !== true && !hasMcpArgBytes(args)) return []; const cursorWireName = mcpWireNameFromArgs(args); if (!cursorWireName) return [{ type: "error", message: "Cursor requested a Responses tool without a tool name" }]; @@ -518,6 +533,9 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW if (state.clientToolNames && !advertisedName) { return [{ type: "error", message: `Cursor requested unknown Responses tool: ${cursorWireName}` }]; } + if (state.startedClientToolCalls >= state.maxClientToolCalls) { + return [{ type: "error", message: `Cursor exceeded client tool-call limit (${state.maxClientToolCalls})` }]; + } // Prefer the advertised catalog name for Responses mapping so shell_command/exec_command aliases // land on the tool Codex actually exposed this turn (#399). const mapKey = advertisedName ?? normalizeCursorWireName(cursorWireName); @@ -533,6 +551,25 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW * recorded in `openToolCalls`. Because each completion emits a whole non-interleaved unit, the bridge * (which tracks a single current tool call) serializes parallel Cursor calls correctly. */ +function cursorFreeformWrapperValid(args: string): boolean { + try { + const parsed = JSON.parse(args) as unknown; + return !!parsed + && typeof parsed === "object" + && !Array.isArray(parsed) + && typeof (parsed as Record).input === "string"; + } catch { + return false; + } +} + +function dropInvalidFreeformCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] { + state.openToolCalls.delete(callId); + state.translatorBudget?.closeCall(callId); + state.completedToolCalls.add(callId); + return [{ type: "error", message: `${toolName} call had invalid freeform arguments; expected {input:string}` }]; +} + function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, toolName: string): CursorServerMessage[] { state.openToolCalls.delete(callId); state.translatorBudget?.closeCall(callId); @@ -550,6 +587,9 @@ function dropStructuredEditCall(state: CursorProtobufEventState, callId: string, function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] { const open = state.openToolCalls.get(callId); if (!open) return []; + if (state.freeformToolNames?.has(open.name) && !cursorFreeformWrapperValid(finalArgs)) { + return dropInvalidFreeformCall(state, callId, open.name); + } const schema = toolSchemaForWireName(state, open.name); if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) { if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name); @@ -659,12 +699,27 @@ export function mapCursorProtobufServerMessage( const args = mcpArgsFromToolCall(update.value.toolCall); const openBeforeStart = state.openToolCalls.get(update.value.callId); // Empty-arg completion handling: - // - already open with empty args -> wait for the native-exec args path (do not commit yet). + // - already-open named ordinary call with no buffered or structured args -> wait for native exec. + // - request-declared freeform completion -> wait while its required input wrapper is absent or + // incomplete, whether the completion repeats the name or is compact callId-only. A valid + // buffered wrapper can commit immediately. + // A compact ordinary no-arg call remains legitimate and commits below. // - never started + not advertised -> Cursor prelude noise, drop it. // - advertised client tool, not yet open -> a legitimate no-arg call: commit it (start+end) // so it is not silently dropped; the bridge serializes empty args as "{}". + if ( + openBeforeStart && !hasMcpArgBytes(args) + && ( + (name !== undefined && openBeforeStart.args.length === 0) + || (state.freeformToolNames?.has(openBeforeStart.name) === true + && !cursorFreeformWrapperValid(openBeforeStart.args) + ) + ) + ) { + openBeforeStart.awaitingNativeArgs = true; + return []; + } if (name && !hasMcpArgBytes(args)) { - if (openBeforeStart && openBeforeStart.args.length === 0) return []; // Only commit a no-arg call when the tool is *explicitly* advertised. Without an advertised // tool list we cannot tell a real no-arg call from a Cursor prelude, so we keep dropping it. const advertised = state.clientToolNames?.has(name) ?? false; @@ -675,6 +730,17 @@ export function mapCursorProtobufServerMessage( if (name) out.push(...recordToolCall(state, update.value.callId, name)); if (out.some(event => event.type === "error")) return out; const open = state.openToolCalls.get(update.value.callId); + // A request-declared freeform call may first appear only in its completion frame. Record it so + // later same-ID native mcpArgs can supply the authoritative wrapper, but do not broaden the + // wait to ordinary advertised no-arg tools: those still commit immediately below. + if ( + !openBeforeStart && open && !hasMcpArgBytes(args) + && state.freeformToolNames?.has(open.name) === true + && !cursorFreeformWrapperValid(open.args) + ) { + open.awaitingNativeArgs = true; + return []; + } if (open) { const finalArgs = resolveCompletedArgs(open.args, args, state); out.push(...commitToolCall(state, update.value.callId, finalArgs)); @@ -721,8 +787,10 @@ export function resolvedTurnUsage(state: CursorProtobufEventState): OcxUsage { export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServerMessage[] { state.terminated = true; if (state.openToolCalls.size > 0) { - const openIds = [...state.openToolCalls.keys()].join(", "); + const openCallIds = [...state.openToolCalls.keys()]; + const openIds = openCallIds.join(", "); // Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit. + for (const callId of openCallIds) state.translatorBudget?.closeCall(callId); state.openToolCalls.clear(); return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }]; } diff --git a/src/bridge.ts b/src/bridge.ts index d7ddf0c87e..f29764bf39 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -92,10 +92,11 @@ function responseError(status: number, type: string, message: string): OcxErrorP * non-stream adapters degrade a bad payload to `{}`. */ function toolCallArgumentsUsable(args: string): boolean { + if (args.length === 0) return true; const trimmed = args.trim(); - if (!trimmed) return true; + if (!trimmed) return false; try { - JSON.parse(trimmed); + JSON.parse(args); return true; } catch { return false; @@ -1593,6 +1594,15 @@ function buildResponseJSONWithBudget( }; for (const e of events) { + if (errorEvent) { + // Match streaming: once the turn fails, later parallel calls must not become executable + // completed output. Still release every retained event in order and preserve terminal usage. + if (e.type === "error" || e.type === "incomplete" || e.type === "done") { + usage = e.usage ?? usage; + } + if (budget) releaseTranslatedEvent(e, budget); + continue; + } switch (e.type) { case "assistant_boundary": flushText("commentary"); diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 779d0df0b0..1f8e1bfbec 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -18,7 +18,9 @@ import { createCursorProtobufEventState, finalizeTurnEvents, mapCursorProtobufServerMessage, + mapSyntheticMcpExecToToolEvents, } from "../src/adapters/cursor/protobuf-events"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; const encoder = new TextEncoder(); @@ -309,6 +311,319 @@ 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({ clientToolNames: ["mcp__fs__read_file"] }); + const toolCall = mcpToolCall("mcp__fs__read_file", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { callId: "call_1", modelCallId: "model_1", toolCall }), + }), state)).toEqual([]); + 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("keeps named incomplete freeform wrappers open for late native arguments", () => { + const freeformSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + }; + const state = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + toolSchemas: new Map([["apply_patch", freeformSchema]]), + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + const toolCall = mcpToolCall("apply_patch", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { callId: "call_freeform", modelCallId: "model_1", toolCall }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "partialToolCall", + value: create(PartialToolCallUpdateSchema, { + callId: "call_freeform", + modelCallId: "model_1", + toolCall, + argsTextDelta: '{"input":"DELETE', + }), + }), state)).toEqual([]); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { callId: "call_freeform", modelCallId: "model_1", toolCall }), + }), state)).toEqual([]); + expect(state.openToolCalls.has("call_freeform")).toBe(true); + expect(state.completedToolCalls.has("call_freeform")).toBe(false); + + const lateArgs = create(McpArgsSchema, { + name: "apply_patch", + toolName: "apply_patch", + toolCallId: "call_freeform", + providerIdentifier: "opencodex-responses", + args: { input: encoder.encode(JSON.stringify("*** Begin Patch\n*** End Patch")) }, + }); + expect(mapSyntheticMcpExecToToolEvents(lateArgs, "fallback", { state })).toEqual([ + { type: "tool_call_start", id: "call_freeform", name: "apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }) }, + { type: "tool_call_end", id: "call_freeform" }, + ]); + expect(state.openToolCalls.has("call_freeform")).toBe(false); + expect(state.completedToolCalls.has("call_freeform")).toBe(true); + + // A complete wrapper remains authoritative and commits without waiting for native exec. + const valid = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + toolSchemas: new Map([["apply_patch", freeformSchema]]), + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + const validToolCall = mcpToolCall("apply_patch", { input: "*** Begin Patch\n*** End Patch" }); + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_valid_freeform", modelCallId: "model_2", toolCall: validToolCall, + }), + }), valid)).toEqual([ + { type: "tool_call_start", id: "call_valid_freeform", name: "apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }) }, + { type: "tool_call_end", id: "call_valid_freeform" }, + ]); + }); + + test("keeps an empty started freeform call open for late native arguments", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + const toolCall = mcpToolCall("apply_patch", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { + callId: "call_empty_freeform", modelCallId: "model_1", toolCall, + }), + }), state)).toEqual([]); + // Cursor may compact a completion to callId only; the later native frame owns the arguments. + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_empty_freeform", modelCallId: "model_1", + }), + }), state)).toEqual([]); + expect(state.openToolCalls.has("call_empty_freeform")).toBe(true); + expect(state.completedToolCalls.has("call_empty_freeform")).toBe(false); + + const lateArgs = create(McpArgsSchema, { + name: "apply_patch", + toolName: "apply_patch", + toolCallId: "call_empty_freeform", + providerIdentifier: "opencodex-responses", + args: { input: encoder.encode(JSON.stringify("*** Begin Patch\n*** End Patch")) }, + }); + expect(mapSyntheticMcpExecToToolEvents(lateArgs, "fallback", { state })).toEqual([ + { type: "tool_call_start", id: "call_empty_freeform", name: "apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }) }, + { type: "tool_call_end", id: "call_empty_freeform" }, + ]); + expect(state.openToolCalls.has("call_empty_freeform")).toBe(false); + expect(state.completedToolCalls.has("call_empty_freeform")).toBe(true); + + const abandoned = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { + callId: "call_abandoned_freeform", modelCallId: "model_2", toolCall, + }), + }), abandoned)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_abandoned_freeform", modelCallId: "model_2", toolCall, + }), + }), abandoned)).toEqual([]); + expect(mapCursorProtobufServerMessage(turnEndedFrame(), abandoned)).toEqual([ + { type: "error", message: expect.stringContaining("call_abandoned_freeform") }, + ]); + expect(abandoned.openToolCalls.size).toBe(0); + }); + + test("keeps a completion-only freeform call open for late native arguments", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + const toolCall = mcpToolCall("apply_patch", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_completion_only_freeform", modelCallId: "model_completion_only", toolCall, + }), + }), state)).toEqual([]); + expect(state.openToolCalls.has("call_completion_only_freeform")).toBe(true); + expect(state.completedToolCalls.has("call_completion_only_freeform")).toBe(false); + + const lateArgs = create(McpArgsSchema, { + name: "apply_patch", + toolName: "apply_patch", + toolCallId: "call_completion_only_freeform", + providerIdentifier: "opencodex-responses", + args: { input: encoder.encode(JSON.stringify("*** Begin Patch\n*** End Patch")) }, + }); + expect(mapSyntheticMcpExecToToolEvents(lateArgs, "fallback", { state })).toEqual([ + { type: "tool_call_start", id: "call_completion_only_freeform", name: "apply_patch" }, + { + type: "tool_call_delta", + arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + }, + { type: "tool_call_end", id: "call_completion_only_freeform" }, + ]); + expect(state.openToolCalls.has("call_completion_only_freeform")).toBe(false); + expect(state.completedToolCalls.has("call_completion_only_freeform")).toBe(true); + }); + + test("bounds retained completion-only client tool calls", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + maxClientToolCalls: 2, + }); + const toolCall = mcpToolCall("apply_patch", {}); + + for (let index = 1; index <= 2; index++) { + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: `call_bounded_${index}`, modelCallId: `model_bounded_${index}`, toolCall, + }), + }), state)).toEqual([]); + } + + const overflow = mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_bounded_3", modelCallId: "model_bounded_3", toolCall, + }), + }), state); + expect(overflow).toEqual([ + { type: "error", message: "Cursor exceeded client tool-call limit (2)" }, + ]); + expect(state.openToolCalls.size).toBe(2); + expect(state.startedClientToolCalls).toBe(2); + }); + + test("keeps a partial freeform wrapper open across compact completion for late native arguments", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + const toolCall = mcpToolCall("apply_patch", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { + callId: "call_partial_freeform", modelCallId: "model_3", toolCall, + }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "partialToolCall", + value: create(PartialToolCallUpdateSchema, { + callId: "call_partial_freeform", + modelCallId: "model_3", + toolCall, + argsTextDelta: '{"input":', + }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_partial_freeform", modelCallId: "model_3", + }), + }), state)).toEqual([]); + expect(state.openToolCalls.has("call_partial_freeform")).toBe(true); + expect(state.completedToolCalls.has("call_partial_freeform")).toBe(false); + + const lateArgs = create(McpArgsSchema, { + name: "apply_patch", + toolName: "apply_patch", + toolCallId: "call_partial_freeform", + providerIdentifier: "opencodex-responses", + args: { input: encoder.encode(JSON.stringify("*** Begin Patch\n*** End Patch")) }, + }); + expect(mapSyntheticMcpExecToToolEvents(lateArgs, "fallback", { state })).toEqual([ + { type: "tool_call_start", id: "call_partial_freeform", name: "apply_patch" }, + { type: "tool_call_delta", arguments: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }) }, + { type: "tool_call_end", id: "call_partial_freeform" }, + ]); + expect(state.openToolCalls.has("call_partial_freeform")).toBe(false); + expect(state.completedToolCalls.has("call_partial_freeform")).toBe(true); + }); + + test("keeps a complete but invalid freeform wrapper open until turn end", () => { + const state = createCursorProtobufEventState({ + clientToolNames: ["apply_patch"], + freeformToolNames: ["apply_patch"], + cursorToolNameMap: new Map([["apply_patch", "apply_patch"]]), + }); + const toolCall = mcpToolCall("apply_patch", {}); + + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { + callId: "call_invalid_freeform", modelCallId: "model_invalid", toolCall, + }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "partialToolCall", + value: create(PartialToolCallUpdateSchema, { + callId: "call_invalid_freeform", + modelCallId: "model_invalid", + toolCall, + argsTextDelta: '{"input":1}', + }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallCompleted", + value: create(ToolCallCompletedUpdateSchema, { + callId: "call_invalid_freeform", modelCallId: "model_invalid", + }), + }), state)).toEqual([]); + expect(state.openToolCalls.has("call_invalid_freeform")).toBe(true); + expect(state.completedToolCalls.has("call_invalid_freeform")).toBe(false); + + expect(mapCursorProtobufServerMessage(turnEndedFrame(), state)).toEqual([ + { type: "error", message: expect.stringContaining("call_invalid_freeform") }, + ]); + expect(state.openToolCalls.size).toBe(0); + }); + 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 "{}". @@ -376,23 +691,50 @@ describe("Cursor protobuf tool-call events", () => { }); test("turnEnded with an open tool call emits truncation error instead of done (fail-closed)", () => { - const state = createCursorProtobufEventState({ clientToolNames: ["mcp__fs__read_file"] }); - // Start a tool call but never complete it. - mapCursorProtobufServerMessage(interaction({ - case: "toolCallStarted", - value: create(ToolCallStartedUpdateSchema, { callId: "call_1", modelCallId: "model_1", toolCall: mcpToolCall("mcp__fs__read_file", {}) }), - }), state); - // Now the turn ends while the tool call is still open. - const turnEnd = create(AgentServerMessageSchema, { - message: { case: "interactionUpdate", value: create(InteractionUpdateSchema, { - message: { case: "turnEnded", value: {} }, - }) }, + const budget = createTranslatorBudget(); + const state = createCursorProtobufEventState({ + clientToolNames: ["mcp__fs__read_file"], + translatorBudget: budget, }); - const events = mapCursorProtobufServerMessage(turnEnd, state); - expect(events).toHaveLength(1); - expect(events[0]!.type).toBe("error"); - expect((events[0] as { message: string }).message).toContain("incomplete tool call"); - expect((events[0] as { message: string }).message).toContain("call_1"); + const toolCall = mcpToolCall("mcp__fs__read_file", {}); + try { + expect(mapCursorProtobufServerMessage(interaction({ + case: "toolCallStarted", + value: create(ToolCallStartedUpdateSchema, { callId: "call_1", modelCallId: "model_1", toolCall }), + }), state)).toEqual([]); + expect(mapCursorProtobufServerMessage(interaction({ + case: "partialToolCall", + value: create(PartialToolCallUpdateSchema, { + callId: "call_1", + modelCallId: "model_1", + toolCall, + argsTextDelta: '{"path":', + }), + }), state)).toEqual([]); + expect(budget.snapshot().activeCalls).toBe(1); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + + const events = mapCursorProtobufServerMessage(turnEndedFrame(), state); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("error"); + expect((events[0] as { message: string }).message).toContain("incomplete tool call"); + expect((events[0] as { message: string }).message).toContain("call_1"); + expect(state.openToolCalls.size).toBe(0); + expect(state.terminated).toBe(true); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, activeCalls: 0 }); + + const lateArgs = create(McpArgsSchema, { + name: "mcp__fs__read_file", + toolName: "mcp__fs__read_file", + toolCallId: "call_1", + providerIdentifier: "opencodex-responses", + args: { path: encoder.encode(JSON.stringify("late.txt")) }, + }); + expect(mapSyntheticMcpExecToToolEvents(lateArgs, "fallback", { state })).toEqual([]); + expect(state.openToolCalls.size).toBe(0); + } finally { + budget.dispose(); + } }); test("turnEnded without open tool calls emits done normally", () => { diff --git a/tests/cursor-tool-finalize-race.test.ts b/tests/cursor-tool-finalize-race.test.ts index b4cce2bd64..867d942561 100644 --- a/tests/cursor-tool-finalize-race.test.ts +++ b/tests/cursor-tool-finalize-race.test.ts @@ -103,7 +103,11 @@ interface Harness { cancelled(): boolean; } -function makeHarness(graceMs: number, clientToolNames: string[]): Harness { +function makeHarness( + graceMs: number, + clientToolNames: string[], + freeformToolNames: string[] = [], +): Harness { const transport = createLiveCursorTransport({ provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, translatorBudget: createTestTranslatorBudget(), @@ -123,7 +127,7 @@ function makeHarness(graceMs: number, clientToolNames: string[]): Harness { closed: false, destroyed: false, }; - const state = createCursorProtobufEventState({ clientToolNames }); + const state = createCursorProtobufEventState({ clientToolNames, freeformToolNames }); const push = (e: CursorServerMessage) => { events.push(e); }; return { feed: (frame) => transport.handleServerMessage(frame, state, push), @@ -168,6 +172,17 @@ describe("client-tool finalize grace selection", () => { }); describe("transport finalize race (hidden parallel sibling)", () => { + test("completion-only freeform wait emits liveness while native arguments are pending", async () => { + const h = makeHarness(20, ["apply_patch"], ["apply_patch"]); + + await h.feed(completedFrame("call_completion_only", "apply_patch")); + await h.feed(completedFrame("call_completion_only", "apply_patch")); + + // Repeated completion frames for the same pending call cannot refresh the watchdog forever. + expect(h.events).toEqual([{ type: "heartbeat" }]); + expect(h.cancelled()).toBe(false); + }); + test("single client tool: grace timer fires once, emits done, cancels with RST_STREAM CANCEL", async () => { const h = makeHarness(20, ["echo_a"]); await h.feed(startedFrame("call_a", "echo_a")); diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index d85ede35e0..4031520c31 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bridgeToResponsesSSE } from "../src/bridge"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; import type { AdapterEvent } from "../src/types"; async function* replay(events: AdapterEvent[]): AsyncGenerator { @@ -111,4 +111,62 @@ describe("Responses streaming tool event contract", () => { .find(item => item?.type === "function_call"); expect(itemDone).toMatchObject({ status: "incomplete" }); }); + + test("whitespace-only assembled arguments fail instead of completing invalid JSON", async () => { + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: "call_space", name: "read_file" }, + { type: "tool_call_delta", arguments: " \t" }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "cursor/composer-2.5")); + + expect(frames.some(frame => frame.event === "response.function_call_arguments.done")).toBe(false); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + const itemDone = frames.filter(frame => frame.event === "response.output_item.done") + .map(frame => frame.data.item as Record) + .find(item => item?.type === "function_call"); + expect(itemDone).toMatchObject({ call_id: "call_space", status: "incomplete" }); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + }); + + test("JSON-invalid Unicode prefixes fail instead of being trimmed into valid arguments", async () => { + for (const [index, argumentsText] of ["\u00A0{}", "\uFEFF{}"].entries()) { + const callId = `call_unicode_${index}`; + const frames = await collectSse(bridgeToResponsesSSE(replay([ + { type: "tool_call_start", id: callId, name: "read_file" }, + { type: "tool_call_delta", arguments: argumentsText }, + { type: "tool_call_end" }, + { type: "done" }, + ]), "cursor/composer-2.5")); + + expect(frames.some(frame => frame.event === "response.function_call_arguments.done")).toBe(false); + expect(frames.some(frame => frame.event === "response.completed")).toBe(false); + const itemDone = frames.filter(frame => frame.event === "response.output_item.done") + .map(frame => frame.data.item as Record) + .find(item => item?.type === "function_call"); + expect(itemDone).toMatchObject({ call_id: callId, arguments: argumentsText, status: "incomplete" }); + expect(frames.some(frame => frame.event === "response.failed")).toBe(true); + } + }); + + test("non-streaming malformed arguments stop before later parallel calls", () => { + const response = buildResponseJSON([ + { type: "tool_call_start", id: "call_space", name: "read_file" }, + { type: "tool_call_delta", arguments: " \t" }, + { type: "tool_call_end" }, + { type: "tool_call_start", id: "call_late", name: "write_file" }, + { type: "tool_call_delta", arguments: "{\"path\":\"safe.txt\"}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], "cursor/composer-2.5"); + + expect(response.status).toBe("failed"); + expect(response.error).toMatchObject({ type: "upstream_error" }); + const output = response.output as Record[]; + expect(output).toHaveLength(1); + expect(output[0]).toMatchObject({ + type: "function_call", call_id: "call_space", arguments: " \t", status: "incomplete", + }); + expect(output.some(item => item.call_id === "call_late")).toBe(false); + }); });