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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DrainedTextToolCall & { callId: string }>;
/** 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. */
Expand Down Expand Up @@ -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" }];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
28 changes: 27 additions & 1 deletion tests/providers/cursor/cursor-protobuf-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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", () => {
Expand Down
Loading