diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index b0cfeca602..39b85293f1 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -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): void => { + const toolSuspendedCommit = + emittedClientTool + && capturedAfterClientTool + && isCursorExternalWireModel(activeRequest.modelId); if ( replayUnsafe - || emittedClientTool + || (emittedClientTool && !toolSuspendedCommit) || activeRequest.contextUsageStoreCheckpoints === false || !lastTransport?.captured || lastTransport.captured.byteLength === 0 @@ -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, }, }; @@ -183,6 +194,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda checkpointRefHash: cursorCheckpointRefHash(checkpointRef), checkpointBytes: lastTransport.captured.byteLength, wireModel: activeRequest.modelId, + ...(toolSuspendedCommit ? { toolSuspended: true } : {}), }); }; @@ -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; + lastTransport = { captured }; + } const events = mapCursorServerMessage(message, { kv, writeClient: clientMessage => { diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index dd77472723..718323b6a2 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -214,10 +214,23 @@ export function filterCursorConfiguredModelsByLiveDiscovery - 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 = 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 @@ -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 }, diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 76e1cc12da..c274cd9d74 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -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, { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index d9785f14c1..542386b8c5 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -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 } : {}), }; diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index cbc9a9f3f6..94112b4697 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -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[]; diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index cf37282c80..01ff52d0e5 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -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", diff --git a/tests/cursor-default-catalog-suppression.test.ts b/tests/cursor-default-catalog-suppression.test.ts new file mode 100644 index 0000000000..2d6871076d --- /dev/null +++ b/tests/cursor-default-catalog-suppression.test.ts @@ -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 { + 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)); + expect(request.suppressDefaultCursorToolCatalog).toBeUndefined(); + }); + + test("_cursorClientThreadId identity keeps the default catalog (flag unset)", () => { + const request = createCursorRequest(parsedRequest({ _cursorClientThreadId: "app:x" } as Partial)); + 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)); + expect(request.suppressDefaultCursorToolCatalog).toBeUndefined(); + expect(request.tools?.length).toBe(1); + }); +}); diff --git a/tests/cursor-tool-suspended-checkpoint.test.ts b/tests/cursor-tool-suspended-checkpoint.test.ts new file mode 100644 index 0000000000..6461d20a48 --- /dev/null +++ b/tests/cursor-tool-suspended-checkpoint.test.ts @@ -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) => + 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(); + }); +}); diff --git a/tests/cursor-uncallable-quarantine.test.ts b/tests/cursor-uncallable-quarantine.test.ts new file mode 100644 index 0000000000..99bc3957a0 --- /dev/null +++ b/tests/cursor-uncallable-quarantine.test.ts @@ -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"]); + }); +});