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
22 changes: 17 additions & 5 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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" });
}
}
Expand Down
80 changes: 74 additions & 6 deletions src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -153,13 +154,17 @@ export interface CursorProtobufEventState {
*/
contextCarryForwardTokens?: number;
recordContextTokens?: (tokens: number) => void;
openToolCalls: Map<string, { name: string; args: string }>;
openToolCalls: Map<string, { name: string; args: string; awaitingNativeArgs?: boolean }>;
completedToolCalls: Set<string>;
/** Set once a terminal `done`/truncation has been emitted, so post-terminal frames stay inert. */
terminated?: boolean;
clientToolNames?: Set<string>;
/** Responses/Codex names of request-declared freeform tools advertised to Cursor. */
freeformToolNames?: ReadonlySet<string>;
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<string, unknown>;
/** Cursor wire-name → original Responses/Codex tool name for this request. */
Expand Down Expand Up @@ -195,7 +200,9 @@ function structuredEditCallIsOurs(

export function createCursorProtobufEventState(options: {
clientToolNames?: Iterable<string>;
freeformToolNames?: Iterable<string>;
parallelToolCalls?: boolean;
maxClientToolCalls?: number;
toolSchemas?: Map<string, unknown>;
cursorToolNameMap?: Map<string, string>;
syntheticStructuredEditToolNames?: Iterable<string>;
Expand All @@ -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 } : {}),
Expand Down Expand Up @@ -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;
Comment thread
luvs01 marked this conversation as resolved.
}

const PATCH_BEGIN = "*** Begin Patch";
Expand Down Expand Up @@ -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" }];
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, unknown>).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);
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const schema = toolSchemaForWireName(state, open.name);
if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) {
if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name);
Expand Down Expand Up @@ -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)
&& (
Comment thread
luvs01 marked this conversation as resolved.
(name !== undefined && openBeforeStart.args.length === 0)
|| (state.freeformToolNames?.has(openBeforeStart.name) === true
&& !cursorFreeformWrapperValid(openBeforeStart.args)
Comment thread
luvs01 marked this conversation as resolved.
)
)
) {
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;
Expand All @@ -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));
Expand Down Expand Up @@ -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.` }];
}
Expand Down
14 changes: 12 additions & 2 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return false;
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading