From 23c2955fb9f19f019234c7c58358046691b56dcf Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:02:58 +0900 Subject: [PATCH 1/5] fix(cursor): fail closed on malformed tool arguments --- src/adapters/cursor/protobuf-events.ts | 5 +-- src/bridge.ts | 3 +- tests/cursor-protobuf-events.test.ts | 28 +++++++++++++++++ tests/responses-stream-tool-events.test.ts | 36 +++++++++++++++++++++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 98c8779ca7..b38a6b07f3 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -335,13 +335,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"; diff --git a/src/bridge.ts b/src/bridge.ts index d7ddf0c87e..b167509538 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -92,8 +92,9 @@ 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); return true; diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 779d0df0b0..87b3584936 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -309,6 +309,34 @@ 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("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 "{}". diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index d85ede35e0..62cbeb1b6f 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,38 @@ 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("non-streaming whitespace-only arguments fail with an incomplete call", () => { + const response = buildResponseJSON([ + { 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(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", + }); + }); }); From 74d7802773b41d51b7484c9c66bd2914197ddda3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:42:27 +0900 Subject: [PATCH 2/5] fix(cursor): validate tool arguments before commit --- src/adapters/cursor/live-transport.ts | 6 +++ src/adapters/cursor/protobuf-events.ts | 26 ++++++++++ src/bridge.ts | 2 +- tests/cursor-protobuf-events.test.ts | 56 ++++++++++++++++++++++ tests/responses-stream-tool-events.test.ts | 20 ++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index f71652e36a..4e8414ae27 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, diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index b38a6b07f3..38251b0c7c 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -158,6 +158,8 @@ export interface CursorProtobufEventState { /** 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; /** Tool wire-name → original JSON Schema parameters object, for arg-key normalization. */ @@ -195,6 +197,7 @@ function structuredEditCallIsOurs( export function createCursorProtobufEventState(options: { clientToolNames?: Iterable; + freeformToolNames?: Iterable; parallelToolCalls?: boolean; toolSchemas?: Map; cursorToolNameMap?: Map; @@ -215,6 +218,7 @@ 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) } : {}), @@ -534,6 +538,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); @@ -551,6 +574,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); diff --git a/src/bridge.ts b/src/bridge.ts index b167509538..38549223b3 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -96,7 +96,7 @@ function toolCallArgumentsUsable(args: string): boolean { const trimmed = args.trim(); if (!trimmed) return false; try { - JSON.parse(trimmed); + JSON.parse(args); return true; } catch { return false; diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 87b3584936..f2536895be 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -337,6 +337,62 @@ describe("Cursor protobuf tool-call events", () => { ]); }); + test("rejects incomplete wrappers for request-declared freeform tools", () => { + 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([ + { type: "error", message: expect.stringContaining("invalid freeform arguments") }, + ]); + expect(state.openToolCalls.has("call_freeform")).toBe(false); + expect(state.completedToolCalls.has("call_freeform")).toBe(true); + + 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("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 "{}". diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index 62cbeb1b6f..537019b5fe 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -129,6 +129,26 @@ describe("Responses streaming tool event contract", () => { 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 whitespace-only arguments fail with an incomplete call", () => { const response = buildResponseJSON([ { type: "tool_call_start", id: "call_space", name: "read_file" }, From ed9ea265994a5201f1ead0102be5ad79a285f7c3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:02:26 +0900 Subject: [PATCH 3/5] fix(cursor): preserve delayed freeform arguments --- src/adapters/cursor/protobuf-events.ts | 19 +++- src/bridge.ts | 9 ++ tests/cursor-protobuf-events.test.ts | 111 +++++++++++++++++++++ tests/responses-stream-tool-events.test.ts | 6 +- 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 38251b0c7c..81d9e20bf3 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -686,12 +686,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 call with no buffered or structured args -> wait for native exec. + // - compact callId-only freeform completion -> wait while its required input wrapper is + // absent or incomplete; 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) + || ( + name === undefined + && state.freeformToolNames?.has(openBeforeStart.name) === true + && !cursorFreeformWrapperValid(openBeforeStart.args) + ) + ) + ) { + 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; diff --git a/src/bridge.ts b/src/bridge.ts index 38549223b3..f29764bf39 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1594,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 f2536895be..0e9bf0dfac 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -18,6 +18,7 @@ import { createCursorProtobufEventState, finalizeTurnEvents, mapCursorProtobufServerMessage, + mapSyntheticMcpExecToToolEvents, } from "../src/adapters/cursor/protobuf-events"; const encoder = new TextEncoder(); @@ -393,6 +394,116 @@ describe("Cursor protobuf tool-call events", () => { ]); }); + 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 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("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 "{}". diff --git a/tests/responses-stream-tool-events.test.ts b/tests/responses-stream-tool-events.test.ts index 537019b5fe..4031520c31 100644 --- a/tests/responses-stream-tool-events.test.ts +++ b/tests/responses-stream-tool-events.test.ts @@ -149,11 +149,14 @@ describe("Responses streaming tool event contract", () => { } }); - test("non-streaming whitespace-only arguments fail with an incomplete call", () => { + 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"); @@ -164,5 +167,6 @@ describe("Responses streaming tool event contract", () => { 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); }); }); From 676e2846b6c27bc1fe19b4e9469a47e98bcd083a Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:04:28 +0900 Subject: [PATCH 4/5] fix(cursor): preserve named late native arguments --- src/adapters/cursor/protobuf-events.ts | 16 +++--- tests/cursor-protobuf-events.test.ts | 80 ++++++++++++++++++++------ 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 81d9e20bf3..f65e1d7383 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -466,6 +466,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" }]; @@ -686,9 +687,10 @@ export function mapCursorProtobufServerMessage( const args = mcpArgsFromToolCall(update.value.toolCall); const openBeforeStart = state.openToolCalls.get(update.value.callId); // Empty-arg completion handling: - // - already-open named call with no buffered or structured args -> wait for native exec. - // - compact callId-only freeform completion -> wait while its required input wrapper is - // absent or incomplete; a valid buffered wrapper can commit immediately. + // - 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) @@ -697,9 +699,7 @@ export function mapCursorProtobufServerMessage( openBeforeStart && !hasMcpArgBytes(args) && ( (name !== undefined && openBeforeStart.args.length === 0) - || ( - name === undefined - && state.freeformToolNames?.has(openBeforeStart.name) === true + || (state.freeformToolNames?.has(openBeforeStart.name) === true && !cursorFreeformWrapperValid(openBeforeStart.args) ) ) @@ -763,8 +763,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/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 0e9bf0dfac..1816dfecdb 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -20,6 +20,7 @@ import { mapCursorProtobufServerMessage, mapSyntheticMcpExecToToolEvents, } from "../src/adapters/cursor/protobuf-events"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; const encoder = new TextEncoder(); @@ -338,7 +339,7 @@ describe("Cursor protobuf tool-call events", () => { ]); }); - test("rejects incomplete wrappers for request-declared freeform tools", () => { + test("keeps named incomplete freeform wrappers open for late native arguments", () => { const freeformSchema = { type: "object", properties: { input: { type: "string" } }, @@ -369,12 +370,26 @@ describe("Cursor protobuf tool-call events", () => { expect(mapCursorProtobufServerMessage(interaction({ case: "toolCallCompleted", value: create(ToolCallCompletedUpdateSchema, { callId: "call_freeform", modelCallId: "model_1", toolCall }), - }), state)).toEqual([ - { type: "error", message: expect.stringContaining("invalid freeform arguments") }, + }), 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"], @@ -571,23 +586,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", () => { From 2d84e08c263562a9300d90c16a55e7f4f7945f01 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:55:28 +0900 Subject: [PATCH 5/5] fix(cursor): preserve completion-only native arguments --- src/adapters/cursor/live-transport.ts | 16 ++-- src/adapters/cursor/protobuf-events.ts | 26 +++++- tests/cursor-protobuf-events.test.ts | 105 ++++++++++++++++++++++++ tests/cursor-tool-finalize-race.test.ts | 19 ++++- 4 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 4e8414ae27..afc411e243 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -1097,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. @@ -1114,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 f65e1d7383..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,7 +154,7 @@ 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; @@ -162,6 +163,8 @@ export interface CursorProtobufEventState { 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. */ @@ -199,6 +202,7 @@ export function createCursorProtobufEventState(options: { clientToolNames?: Iterable; freeformToolNames?: Iterable; parallelToolCalls?: boolean; + maxClientToolCalls?: number; toolSchemas?: Map; cursorToolNameMap?: Map; syntheticStructuredEditToolNames?: Iterable; @@ -224,6 +228,11 @@ export function createCursorProtobufEventState(options: { : {}), ...(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 } : {}), @@ -524,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); @@ -704,6 +716,7 @@ export function mapCursorProtobufServerMessage( ) ) ) { + openBeforeStart.awaitingNativeArgs = true; return []; } if (name && !hasMcpArgBytes(args)) { @@ -717,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)); diff --git a/tests/cursor-protobuf-events.test.ts b/tests/cursor-protobuf-events.test.ts index 1816dfecdb..1f8e1bfbec 100644 --- a/tests/cursor-protobuf-events.test.ts +++ b/tests/cursor-protobuf-events.test.ts @@ -471,6 +471,73 @@ describe("Cursor protobuf tool-call events", () => { 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"], @@ -519,6 +586,44 @@ describe("Cursor protobuf tool-call events", () => { 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 "{}". 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"));