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
63 changes: 50 additions & 13 deletions src/integrations/cursor-effort-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size);
* any parse failure yields null so the caller falls back to the static mirror.
*/
import { readFileSync, statSync } from "node:fs";
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
import { join } from "node:path";
import type { CursorInstall } from "./cursor-detect";

Expand Down Expand Up @@ -111,32 +111,69 @@ function splitStrings(list: string): string[] {

export interface CursorEffortTableDeps {
platform: string;
stat(path: string): { mtimeMs: number; size: number } | null;
readText(path: string): string | null;
readBundle(path: string, cached?: { mtimeMs: number; size: number }): { mtimeMs: number; size: number; text: string | null } | null;
}

export function realCursorEffortTableDeps(): CursorEffortTableDeps {
return {
platform: process.platform,
stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } },
readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } },
readBundle: readCursorBundle,
};
}

let cache: { key: string; table: CursorEffortTable | null } | null = null;
function readCursorBundle(path: string, cached?: { mtimeMs: number; size: number }): { mtimeMs: number; size: number; text: string | null } | null {
let fd: number | null = null;
try {
// O_NOFOLLOW binds the validation and read to the same regular file. O_NONBLOCK
// keeps opening a substituted special file from stalling before fstat rejects it.
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const stat = fstatSync(fd);
if (!stat.isFile() || stat.size > BUNDLE_MAX_BYTES) return null;
if (cached?.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return { mtimeMs: stat.mtimeMs, size: stat.size, text: null };
}

const chunks: Buffer[] = [];
let size = 0;
while (size <= BUNDLE_MAX_BYTES) {
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, BUNDLE_MAX_BYTES + 1 - size));
const bytesRead = readSync(fd, chunk, 0, chunk.length, null);
if (bytesRead === 0) break;
chunks.push(chunk.subarray(0, bytesRead));
size += bytesRead;
}
if (size > BUNDLE_MAX_BYTES) return null;
return { mtimeMs: stat.mtimeMs, size, text: Buffer.concat(chunks, size).toString("utf8") };
} catch {
return null;
} finally {
if (fd !== null) closeSync(fd);
}
}

let cache: { key: string; path: string; mtimeMs: number; size: number; table: CursorEffortTable | null } | null = null;

/** Table from the Private Inference install, else null (caller falls back to the static mirror). */
export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null {
if (!install) return null;
const bundlePath = cursorAgentBundlePath(install, deps.platform);
const st = deps.stat(bundlePath);
if (!st || st.size > BUNDLE_MAX_BYTES) return null;
const key = `${bundlePath}|${st.mtimeMs}|${st.size}`;
if (cache?.key === key) return cache.table;
const text = deps.readText(bundlePath);
const parsed = text ? parseCursorEffortTable(text) : null;
const cachedMetadata = cache?.path === bundlePath
? { mtimeMs: cache.mtimeMs, size: cache.size }
: undefined;
const bundle = deps.readBundle(bundlePath, cachedMetadata);
if (!bundle) return null;
const key = `${bundlePath}|${bundle.mtimeMs}|${bundle.size}`;
if (cache?.key === key) {
// The cache key covers bundle identity only; install.version comes from
// product.json and can change or resolve without touching the bundle.
const cached = cache.table;
return cached && cached.version !== install.version
? { ...cached, version: install.version }
: cached;
}
const parsed = bundle.text ? parseCursorEffortTable(bundle.text) : null;
const table = parsed ? { ...parsed, version: install.version, bundlePath } : null;
cache = { key, table };
cache = { key, path: bundlePath, mtimeMs: bundle.mtimeMs, size: bundle.size, table };
return table;
}

Expand Down
8 changes: 8 additions & 0 deletions structure/clients/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ parsing and ownership rules below.
| `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. |
| `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. |

## Cursor installed capability reads

`src/integrations/cursor-effort-table.ts` reads the installed agent bundle through one regular-file
handle, refuses final symlinks where supported, and caps bytes read even if the file grows after
inspection. Failure retains the static-table fallback. Parsed content is cached by path, mtime and
size; the returned table always uses the current install version, including on a cache hit.
`tests/providers/cursor/cursor-effort-table.test.ts` covers cache reuse, version refresh and unsafe files.

## Data Flow

```text
Expand Down
5 changes: 3 additions & 2 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,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
48 changes: 40 additions & 8 deletions tests/providers/cursor/cursor-effort-table.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
cursorAgentBundlePath,
loadCursorEffortTable,
parseCursorEffortTable,
resetCursorEffortTableCacheForTests,
Expand Down Expand Up @@ -65,15 +68,13 @@ describe("Cursor installed-bundle effort table", () => {
test("activates the static fallback for missing installs, missing literals, and malformed regexes", () => {
const missingStat: CursorEffortTableDeps = {
platform: "darwin",
stat: () => null,
readText: () => { throw new Error("readText must not run without a stat"); },
readBundle: () => null,
};
expect(loadCursorEffortTable(INSTALL, missingStat)).toBeNull();

const loadSource = (source: string, mtimeMs: number) => loadCursorEffortTable(INSTALL, {
platform: "darwin",
stat: () => ({ mtimeMs, size: source.length }),
readText: () => source,
readBundle: () => ({ mtimeMs, size: source.length, text: source }),
});
expect(loadSource("function unrelated(){}", 1)).toBeNull();
expect(loadSource(FIXTURE.replace("/^claude-opus-5$/u", "/[/u"), 2)).toBeNull();
Expand Down Expand Up @@ -102,10 +103,12 @@ describe("Cursor installed-bundle effort table", () => {
let reads = 0;
const deps: CursorEffortTableDeps = {
platform: "darwin",
stat: () => ({ mtimeMs, size: FIXTURE.length }),
readText: () => {
readBundle: (_path, cached) => {
if (cached?.mtimeMs === mtimeMs && cached.size === FIXTURE.length) {
return { ...cached, text: null };
}
reads += 1;
return FIXTURE;
return { mtimeMs, size: FIXTURE.length, text: FIXTURE };
},
};
expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16);
Expand All @@ -115,4 +118,33 @@ describe("Cursor installed-bundle effort table", () => {
expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16);
expect(reads).toBe(2);
});

test("refreshes the reported version on a bundle cache hit", () => {
const deps: CursorEffortTableDeps = {
platform: "darwin",
readBundle: () => ({ mtimeMs: 1, size: FIXTURE.length, text: FIXTURE }),
};
expect(loadCursorEffortTable(INSTALL, deps)?.version).toBe("3.18.25");
const upgraded = { ...INSTALL, version: "3.19.0" };
expect(loadCursorEffortTable(upgraded, deps)?.version).toBe("3.19.0");
});

test("rejects symlinks and special files without blocking", () => {
if (process.platform === "win32") return;
const root = `${tmpdir()}/ocx-cursor-bundle-${process.pid}-${Date.now()}`;
const install = { ...INSTALL, path: root };
const bundlePath = cursorAgentBundlePath(install, process.platform);
const target = `${root}/target.js`;
mkdirSync(bundlePath.slice(0, bundlePath.lastIndexOf("/")), { recursive: true });
writeFileSync(target, FIXTURE);
try {
symlinkSync(target, bundlePath);
expect(loadCursorEffortTable(install)).toBeNull();
rmSync(bundlePath);
execFileSync("mkfifo", [bundlePath]);
expect(loadCursorEffortTable(install)).toBeNull();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
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