From 755d50b139f7ecda576e0ce5617ac8b29a419418 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 20 Sep 2026 15:17:39 +0900 Subject: [PATCH] fix(cursor): budget buffered textual tool calls --- src/adapters/cursor/protobuf-events.ts | 52 ++++++++++++++----- structure/providers/cursor.md | 5 +- .../cursor/cursor-protobuf-events.test.ts | 28 +++++++++- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 1bc771a0098..c10c3f20ad1 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -189,8 +189,8 @@ export interface CursorProtobufEventState { pendingTextToolCall?: string; /** Constant-space scanner used after an incomplete textual marker exceeds its retained byte cap. */ suppressedTextToolCall?: SuppressedTextToolCallScan; - /** Parsed textual fallback calls held until turn finalization establishes that no real frame won. */ - bufferedTextToolCalls?: DrainedTextToolCall[]; + /** Budgeted textual fallback calls held until turn finalization establishes that no real frame won. */ + bufferedTextToolCalls?: Array; /** True once this turn carries any real client-tool frame, including an incomplete one. */ sawRealClientToolCall?: boolean; /** Monotonic id suffix for tool calls promoted from text markers. */ @@ -1077,7 +1077,10 @@ export function mapSyntheticMcpExecToToolEvents( ): CursorServerMessage[] { if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) return []; if (options.state?.terminated) return []; - if (options.state) options.state.sawRealClientToolCall = true; + if (options.state) { + discardBufferedTextToolCalls(options.state); + options.state.sawRealClientToolCall = true; + } 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" }]; @@ -1148,10 +1151,16 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW } function recordRealToolCall(state: CursorProtobufEventState, callId: string, cursorWireName: string): CursorServerMessage[] { + discardBufferedTextToolCalls(state); state.sawRealClientToolCall = true; return recordToolCall(state, callId, cursorWireName); } +function discardBufferedTextToolCalls(state: CursorProtobufEventState): void { + for (const call of state.bufferedTextToolCalls ?? []) state.translatorBudget?.closeCall(call.callId); + delete state.bufferedTextToolCalls; +} + /** * Emit a completed client tool call as one atomic unit: `tool_call_start` (deferred from open time), * the full normalized arguments delta when present, then `tool_call_end`. The call must already be @@ -1313,10 +1322,21 @@ export function mapCursorProtobufServerMessage( || !advertised || (state.bufferedTextToolCalls?.length ?? 0) >= state.maxClientToolCalls ) continue; - (state.bufferedTextToolCalls ??= []).push({ - name: advertised, - args: normalizeJsonText(call.args, advertised, state), - }); + const args = normalizeJsonText(call.args, advertised, state); + state.textToolCallSeq = (state.textToolCallSeq ?? 0) + 1; + const callId = `textcall_${state.textToolCallSeq}`; + state.translatorBudget?.openCall(callId); + try { + const reservation = state.translatorBudget?.reserveTransient( + Buffer.byteLength(args), + { kind: "tool_args", callId }, + ); + reservation?.commitRetained(); + (state.bufferedTextToolCalls ??= []).push({ name: advertised, args, callId }); + } catch (error) { + state.translatorBudget?.closeCall(callId); + throw error; + } } return out; } @@ -1346,7 +1366,10 @@ export function mapCursorProtobufServerMessage( const out: CursorServerMessage[] = []; if (state.completedToolCalls.has(update.value.callId)) return []; const name = mcpCursorWireName(update.value.toolCall); - if (name) state.sawRealClientToolCall = true; + if (name) { + discardBufferedTextToolCalls(state); + state.sawRealClientToolCall = true; + } const args = mcpArgsFromToolCall(update.value.toolCall); const openBeforeStart = state.openToolCalls.get(update.value.callId); // Empty-arg completion handling: @@ -1454,6 +1477,7 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe const bufferedTextToolCalls = state.bufferedTextToolCalls ?? []; delete state.bufferedTextToolCalls; if (state.openToolCalls.size > 0) { + for (const call of bufferedTextToolCalls) state.translatorBudget?.closeCall(call.callId); const openCallIds = [...state.openToolCalls.keys()]; const openIds = openCallIds.join(", "); // Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit. @@ -1464,11 +1488,15 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe const out: CursorServerMessage[] = []; if (!state.sawRealClientToolCall) { for (const call of bufferedTextToolCalls) { - state.textToolCallSeq = (state.textToolCallSeq ?? 0) + 1; - const callId = `textcall_${state.textToolCallSeq}`; - out.push(...recordToolCall(state, callId, call.name)); - if (state.openToolCalls.has(callId)) out.push(...commitToolCall(state, callId, call.args)); + out.push(...recordToolCall(state, call.callId, call.name)); + const open = state.openToolCalls.get(call.callId); + if (open) { + open.args = call.args; + out.push(...commitToolCall(state, call.callId, call.args)); + } else state.translatorBudget?.closeCall(call.callId); } + } else { + for (const call of bufferedTextToolCalls) state.translatorBudget?.closeCall(call.callId); } // Surface the absolute context size (when Cursor reported a checkpoint) as both totalTokens and // the estimated input side of Codex's visible `input + output` counter. Codex status lines can diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index c8392c48606..17fe0e06a6e 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -153,9 +153,10 @@ markers up to a byte-counted cap, then switches to a constant-space suppressed s the JSON object closes; neither an oversized tail nor a malformed payload returns to prose. Malformed argument diagnostics contain only the failure class and an optional tool name, never the argument content. `src/adapters/cursor/protobuf-events.ts` buffers advertised textual calls -until turn finalization. It flushes them onto the atomic tool-call path only when the turn +until turn finalization, charging each retained argument immediately against the normal per-call +and per-turn translator budgets. It flushes them onto the atomic tool-call path only when the turn contained no real client-tool frame; any real frame, including one left incomplete, wins and -drops the whole textual buffer. A missing advertised-name set is fail-closed. Finalize also +drops the whole textual buffer and releases its charges. A missing advertised-name set is fail-closed. Finalize also clears any held or suppressed prefix. Coverage lives in `tests/providers/cursor/cursor-protobuf-events.test.ts`. diff --git a/tests/providers/cursor/cursor-protobuf-events.test.ts b/tests/providers/cursor/cursor-protobuf-events.test.ts index 4dae165c066..3c3b2e2eaf1 100644 --- a/tests/providers/cursor/cursor-protobuf-events.test.ts +++ b/tests/providers/cursor/cursor-protobuf-events.test.ts @@ -1257,7 +1257,8 @@ describe("textual pseudo tool-call marker quarantine", () => { }); test("a real frame wins over a textual echo in the same turn", () => { - const state = createCursorProtobufEventState({ clientToolNames: ["grep"] }); + const budget = createTranslatorBudget(); + const state = createCursorProtobufEventState({ clientToolNames: ["grep"], translatorBudget: budget }); expect(mapCursorProtobufServerMessage( textDelta('[TOOL_CALL]grep[ARGS]{"pattern":"echo"}'), state, @@ -1272,6 +1273,31 @@ describe("textual pseudo tool-call marker quarantine", () => { { type: "tool_call_start", id: "call_1", name: "grep" }, ]); expect(events.some(event => event.type === "tool_call_delta" && event.arguments.includes("echo"))).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); + + test("complete textual fallbacks are budgeted before they are retained", () => { + const budget = createTranslatorBudget({ maxCallArgumentBytes: 32, maxTurnBytes: 40 }); + const state = createCursorProtobufEventState({ clientToolNames: ["grep"], translatorBudget: budget }); + try { + expect(() => mapCursorProtobufServerMessage( + textDelta(`[TOOL_CALL]grep[ARGS]{"pattern":"${"x".repeat(40)}"}`), + state, + )).toThrow("translator tool_args buffer exceeded 32 bytes"); + expect(state.bufferedTextToolCalls).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + + mapCursorProtobufServerMessage(textDelta('[TOOL_CALL]grep[ARGS]{"pattern":"12345678"}'), state); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + expect(() => mapCursorProtobufServerMessage( + textDelta('[TOOL_CALL]grep[ARGS]{"pattern":"abcdefgh"}'), + state, + )).toThrow("translator tool_args buffer exceeded 40 bytes"); + expect(state.bufferedTextToolCalls).toHaveLength(1); + } finally { + budget.dispose(); + } }); test("a split marker is dropped when an incomplete real frame appears", () => {