Skip to content
Merged
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
21 changes: 18 additions & 3 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,20 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
let completedNormally = false;
let lastTransport: { captured?: Uint8Array } | undefined;
let emittedClientTool = false;
// Ordering proof for tool-suspended checkpoints: true only when the newest captured
// checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
// serialized its suspended-on-tool-call state. Only that snapshot can safely resume
// with the covered-prefix + trailing-toolResult path (devlog 260826 050).
let capturedAfterClientTool = false;

const commitCapturedCheckpoint = (activeRequest: ReturnType<typeof createCursorRequest>): void => {
const toolSuspendedCommit =
emittedClientTool
&& capturedAfterClientTool
&& isCursorExternalWireModel(activeRequest.modelId);
if (
replayUnsafe
|| emittedClientTool
|| (emittedClientTool && !toolSuspendedCommit)
|| activeRequest.contextUsageStoreCheckpoints === false
|| !lastTransport?.captured
|| lastTransport.captured.byteLength === 0
Expand All @@ -173,7 +182,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
cursor: {
...(_parsed._providerContinuation?.cursor ?? {}),
conversationId: activeRequest.conversationId,
checkpointUsable: true,
// A tool-suspended checkpoint is only usable by the immediate trailing-toolResult
// continuation; the request-builder guard keys on checkpointUsable=false for that.
checkpointUsable: !toolSuspendedCommit,
checkpointRef,
Comment on lines +185 to 188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist the suspended-only restriction with the checkpoint

The checkpointUsable: false restriction exists only in the returned continuation metadata, while the committed snapshot is also added to the generic prefix index without that restriction. For a stable client thread that sends full history without previous_response_id, lookupPrefixSnapshot can therefore recover this tool-suspended snapshot with no cursorState; if the next request ends in a user message rather than the immediate tool result, the guard at request-builder.ts:427 is bypassed and the request resumes from pending-tool state without appending the uncovered transcript. Store the suspended kind on the snapshot and enforce it for both ref and prefix lookup, or exclude such snapshots from generic prefix recovery.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

},
};
Expand All @@ -183,6 +194,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
checkpointRefHash: cursorCheckpointRefHash(checkpointRef),
checkpointBytes: lastTransport.captured.byteLength,
wireModel: activeRequest.modelId,
...(toolSuspendedCommit ? { toolSuspended: true } : {}),
});
};

Expand All @@ -208,7 +220,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
if (message.type === "done") completedNormally = true;
if (message.type === "tool_call_end") emittedClientTool = true;
const captured = capturedCursorCheckpointBytes(activeTransport);
if (captured) lastTransport = { captured };
if (captured) {
if (captured !== lastTransport?.captured) capturedAfterClientTool = emittedClientTool;
Comment on lines 221 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a checkpoint after the final emitted tool call

With multiple client tool calls, a checkpoint captured after the first tool_call_end sets capturedAfterClientTool permanently true; a later second call does not reset it, so done can commit the earlier snapshot even when no checkpoint arrived after the second call. Cursor explicitly supports sequential and late sibling calls, and resuming that snapshot with results for all emitted calls can omit the later pending call or trigger an invalid continuation. Reset the ordering proof for every newly emitted tool call and require a newer capture after the last one before committing.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

lastTransport = { captured };
}
const events = mapCursorServerMessage(message, {
kv,
writeClient: clientMessage => {
Expand Down
18 changes: 16 additions & 2 deletions src/adapters/cursor/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,23 @@ export function filterCursorConfiguredModelsByLiveDiscovery<T extends { id: stri
liveIds: readonly string[],
): T[] {
return configured.filter(model =>
isCursorRouterModelId(model.id) || isCursorModelAvailableForAccount(model.id, liveIds),
!CURSOR_KNOWN_UNCALLABLE_MODEL_IDS.has(model.id)
&& (isCursorRouterModelId(model.id) || isCursorModelAvailableForAccount(model.id, liveIds)),
);
}

/**
* Models GetUsableModels advertises but whose every Run returns not_found (catalog honesty,
* devlog 260826_cursor_responses_gap 060). Live probes 2026-08-26: cursor/claude-opus-5 failed
* 100% ("Cursor Connect error not_found") while its -fast and -thinking siblings — separate
* wire families — succeed. Quarantined here, in the shared filter, so live, cached, stale, and
* static serving paths all agree. Custom user provider overrides are not routed through this
* canonical seed and stay untouched.
*/
export const CURSOR_KNOWN_UNCALLABLE_MODEL_IDS: ReadonlySet<string> = new Set([
"claude-opus-5",
]);

export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([
// Context windows and the model lineup mirror Cursor's public models/pricing docs plus the jawcode
// SOT (../jawcode/packages/ai/src/models.json, `cursor` provider), which mirrors the real
Expand Down Expand Up @@ -245,7 +258,8 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM
{ id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
{ id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
{ id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
{ id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
// claude-opus-5 (bare) removed from the seed: GetUsableModels lists it but every Run returns
// not_found (quarantined via CURSOR_KNOWN_UNCALLABLE_MODEL_IDS; -fast/-thinking families stay).
{ id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },
{ id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true },

Expand Down
6 changes: 5 additions & 1 deletion src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,7 +990,11 @@ function buildPreparedCursorRunRequest(
// the event-state `clientToolNames` use (live-transport.ts). Advertising the raw `request.tools`
// here would let mcp_tools expose a tool that the event state does not recognize for a generic
// tool-count prompt, so a call to it would be rejected as an unknown Responses tool.
...(mcpToolDefs.length > 0 ? { mcpTools: create(McpToolsSchema, { mcpTools: mcpToolDefs }) } : {}),
// An explicitly empty McpTools wrapper (bare API callers) suppresses Cursor's default
// native catalog; an absent field lets identified Codex sessions keep it (devlog 260826 040).
...(mcpToolDefs.length > 0 || request.suppressDefaultCursorToolCatalog === true
? { mcpTools: create(McpToolsSchema, { mcpTools: mcpToolDefs }) }
: {}),
});

const message = create(AgentClientMessageSchema, {
Expand Down
5 changes: 5 additions & 0 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,11 @@ export function createCursorRequest(
...(parsed._compactionRequest === true || parsed._contextCompactionBoundary === true ? { contextUsageReset: true } : {}),
...(parsed._compactionRequest === true ? { contextUsageStoreCheckpoints: false } : {}),
...(budget.tools.length ? { tools: budget.tools } : {}),
// Bare API caller (no tools, no Codex thread identity): suppress Cursor's default
// native tool catalog instead of paying its ~10-15K token preamble (devlog 260826 040).
...(budget.tools.length === 0 && !cursorClientThreadOwner(parsed)
? { suppressDefaultCursorToolCatalog: true }
: {}),
...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}),
...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}),
};
Expand Down
7 changes: 7 additions & 0 deletions src/adapters/cursor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export interface CursorRunRequest {
requestedModelParameters?: readonly CursorRequestedModelParameter[];
/** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
routingLevel?: CursorRoutingLevel;
/**
* Bare API callers (no caller tools, no Codex thread identity) pay a ~10-15K input-token
* preamble because an absent AgentRunRequest.mcp_tools field makes Cursor inject its default
* native tool catalog. When true, an explicitly empty McpTools wrapper is serialized instead,
* suppressing that default. Codex-identified sessions keep the absent-field behavior.
*/
suppressDefaultCursorToolCatalog?: boolean;
conversationId: string;
system: string[];
messages: CursorRequestMessage[];
Expand Down
12 changes: 12 additions & 0 deletions tests/cursor-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,18 @@ describe("Cursor AgentRunRequest.mcp_tools channel", () => {
expect(mcpToolNames(bytes)).toBeUndefined();
});

test("suppression flag serializes an explicitly empty mcp_tools wrapper", () => {
const bytes = encodeCursorRunRequest({
modelId: "gpt-5.6-luna-high",
conversationId: "c1",
system: ["You are helpful."],
messages: [{ role: "user", content: "hi" }],
tools: [],
suppressDefaultCursorToolCatalog: true,
});
expect(mcpToolNames(bytes)).toEqual([]);
});

test("leaves mcp_tools unset when toolChoice is none", () => {
const bytes = encodeCursorRunRequest({
modelId: "gpt-5.6-luna-high",
Expand Down
47 changes: 47 additions & 0 deletions tests/cursor-default-catalog-suppression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { createCursorRequest } from "../src/adapters/cursor/request-builder";
import type { OcxParsedRequest } from "../src/types/request";

function parsedRequest(overrides: Partial<OcxParsedRequest> = {}): OcxParsedRequest {
return {
modelId: "cursor/grok-4.6",
context: {
systemPrompt: [],
messages: [{ role: "user", content: "hi" }],
tools: undefined,
},
options: {},
...overrides,
} as OcxParsedRequest;
}

const CALLER_TOOL = {
name: "get_weather",
description: "d",
parameters: { type: "object", properties: {} },
} as const;

describe("cursor default-catalog suppression (preamble floor)", () => {
test("bare request with no tools and no thread identity sets the flag", () => {
const request = createCursorRequest(parsedRequest());
expect(request.suppressDefaultCursorToolCatalog).toBe(true);
});

test("_clientThreadId identity keeps the default catalog (flag unset)", () => {
const request = createCursorRequest(parsedRequest({ _clientThreadId: "thread-1" } as Partial<OcxParsedRequest>));
expect(request.suppressDefaultCursorToolCatalog).toBeUndefined();
});

test("_cursorClientThreadId identity keeps the default catalog (flag unset)", () => {
const request = createCursorRequest(parsedRequest({ _cursorClientThreadId: "app:x" } as Partial<OcxParsedRequest>));
expect(request.suppressDefaultCursorToolCatalog).toBeUndefined();
});

test("caller-supplied tools never set the flag", () => {
const request = createCursorRequest(parsedRequest({
context: { systemPrompt: [], messages: [{ role: "user", content: "hi" }], tools: [CALLER_TOOL] },
} as Partial<OcxParsedRequest>));
expect(request.suppressDefaultCursorToolCatalog).toBeUndefined();
expect(request.tools?.length).toBe(1);
});
});
96 changes: 96 additions & 0 deletions tests/cursor-tool-suspended-checkpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { createCursorAdapter as createCursorAdapterProduction } from "../src/adapters/cursor";
import { clearCursorCheckpointsForTests, getCursorCheckpoint } from "../src/adapters/cursor/checkpoint-store";
import { create, toBinary } from "@bufbuild/protobuf";
import { ConversationStateStructureSchema } from "../src/adapters/cursor/gen/agent_pb";
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types";
import type { CursorServerMessage } from "../src/adapters/cursor/types";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const createCursorAdapter = (...args: Parameters<typeof createCursorAdapterProduction>) =>
withTestTranslatorBudget(createCursorAdapterProduction(...args));

const provider: OcxProviderConfig = { adapter: "cursor", baseUrl: "https://api2.cursor.sh" };

const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, {
pendingToolCalls: ["suspended-fixture"],
}));

/** Transport that emits a client tool call, exposing checkpoint bytes only after it. */
function toolSuspendedTransport() {
let capturable: Uint8Array | undefined;
return {
async *run() {
yield { type: "tool_call_start", id: "call_x", name: "get_weather" } satisfies CursorServerMessage;
yield { type: "tool_call_delta", arguments: "{}" } satisfies CursorServerMessage;
capturable = checkpointBytes;
yield { type: "tool_call_end" } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
},
writeClient() {},
capturedConversationCheckpoint() {
return capturable;
},
};
}

function body(modelId: string): OcxParsedRequest {
return {
modelId,
context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] },
stream: false,
options: {},
_cursorConversationId: "cursor_tool_suspend",
_cursorIdentityScope: "acct-suspend",
} as OcxParsedRequest;
}

describe("tool-suspended checkpoint commit (devlog 260826 050)", () => {
test("external model commits a tool-suspended checkpoint with checkpointUsable=false", async () => {
clearCursorCheckpointsForTests();
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: toolSuspendedTransport });
const events: AdapterEvent[] = [];
await adapter.runTurn?.(body("cursor/grok-4.6"), { headers: new Headers() }, event => events.push(event));
const done = events.find(event => event.type === "done");
if (done?.type !== "done") throw new Error("expected done");
expect(done.providerState?.cursor?.checkpointRef).toBeDefined();
expect(done.providerState?.cursor?.checkpointUsable).toBe(false);
expect(getCursorCheckpoint(done.providerState?.cursor?.checkpointRef)).toBeDefined();
clearCursorCheckpointsForTests();
});

test("native composer model still refuses the tool-suspended commit", async () => {
clearCursorCheckpointsForTests();
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: toolSuspendedTransport });
const events: AdapterEvent[] = [];
await adapter.runTurn?.(body("cursor/composer-2.5"), { headers: new Headers() }, event => events.push(event));
const done = events.find(event => event.type === "done");
if (done?.type !== "done") throw new Error("expected done");
expect(done.providerState?.cursor?.checkpointRef).toBeUndefined();
clearCursorCheckpointsForTests();
});

test("checkpoint captured before the tool call is still refused (ordering guard)", async () => {
clearCursorCheckpointsForTests();
const transport = {
async *run() {
yield { type: "tool_call_start", id: "call_y", name: "get_weather" } satisfies CursorServerMessage;
yield { type: "tool_call_delta", arguments: "{}" } satisfies CursorServerMessage;
yield { type: "tool_call_end" } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
},
writeClient() {},
capturedConversationCheckpoint() {
// Bytes available from the very first poll — pre-tool capture.
return checkpointBytes;
},
};
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: () => transport });
const events: AdapterEvent[] = [];
await adapter.runTurn?.(body("cursor/grok-4.6"), { headers: new Headers() }, event => events.push(event));
const done = events.find(event => event.type === "done");
if (done?.type !== "done") throw new Error("expected done");
expect(done.providerState?.cursor?.checkpointRef).toBeUndefined();
clearCursorCheckpointsForTests();
});
});
33 changes: 33 additions & 0 deletions tests/cursor-uncallable-quarantine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import {
CURSOR_KNOWN_UNCALLABLE_MODEL_IDS,
CURSOR_STATIC_MODELS,
filterCursorConfiguredModelsByLiveDiscovery,
} from "../src/adapters/cursor/discovery";

describe("cursor uncallable-model quarantine (devlog 260826 060)", () => {
test("static seed no longer carries bare claude-opus-5", () => {
expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5")).toBe(false);
});

test("siblings from other wire families survive", () => {
expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5-fast")).toBe(true);
});

test("live filter drops quarantined ids even when GetUsableModels lists them", () => {
const configured = [{ id: "claude-opus-5" }, { id: "claude-opus-5-fast" }, { id: "grok-4.6" }];
const live = ["claude-opus-5-high", "claude-opus-5-high-fast", "grok-4.6-high"];
const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, live);
expect(filtered.map(model => model.id)).toEqual(["claude-opus-5-fast", "grok-4.6"]);
});

test("quarantine applies with an empty live list too (stale/static degradation path)", () => {
const configured = [{ id: "claude-opus-5" }, { id: "auto" }];
const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, []);
expect(filtered.some(model => model.id === "claude-opus-5")).toBe(false);
});

test("quarantine set stays narrow", () => {
expect([...CURSOR_KNOWN_UNCALLABLE_MODEL_IDS]).toEqual(["claude-opus-5"]);
});
});
Loading