diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 718323b6a2..15f529fdb0 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -154,6 +154,25 @@ export function cursorCodexToWireModelId(modelId: string): string { return cursorWireModelSelection(modelId).modelId; } +/** + * Synthetic ultra/big-context picker marker (devlog 260826 070). A `cursor/-1m` row is a + * picker-only variant: the wire request keeps `` (plus effort suffix) and turns on Cursor + * Max Mode instead. Only ids listed here are treated as synthetic — a real upstream wire id that + * happens to end in `-1m` never collides because it will not be in this set. + */ +export const CURSOR_ULTRA_1M_MODEL_IDS: ReadonlySet = new Set([ + "kimi-k3-1m", +]); + +const CURSOR_ULTRA_1M_SUFFIX = "-1m"; + +/** Resolve a synthetic ultra marker id to its wire base, or undefined for ordinary ids. */ +export function cursorUltraBaseModelId(modelId: string): string | undefined { + const normalized = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : modelId; + if (!CURSOR_ULTRA_1M_MODEL_IDS.has(normalized)) return undefined; + return normalized.slice(0, -CURSOR_ULTRA_1M_SUFFIX.length); +} + /** * Cursor-native wire models keep server-side conversation state reliably. * External models (gpt/claude/gemini/grok families and similar) are more brittle on resumeAction. @@ -215,7 +234,11 @@ export function filterCursorConfiguredModelsByLiveDiscovery !CURSOR_KNOWN_UNCALLABLE_MODEL_IDS.has(model.id) - && (isCursorRouterModelId(model.id) || isCursorModelAvailableForAccount(model.id, liveIds)), + && ( + isCursorRouterModelId(model.id) + // Synthetic ultra rows ride their base model's account availability. + || isCursorModelAvailableForAccount(cursorUltraBaseModelId(model.id) ?? model.id, liveIds) + ), ); } @@ -322,6 +345,10 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM // kimi-k3: cursor.com/docs/models/kimi-k3; account-verified via GetUsableModels (2026-07-28) — // ships only as effort-suffixed kimi-k3-{low,high,max}, so the tier picker is exposed. { id: "kimi-k3", contextWindow: CONTEXT_262K, supportsReasoningEffort: true }, + // kimi-k3-1m: synthetic ultra/Max-Mode picker variant (CURSOR_ULTRA_1M_MODEL_IDS) — wire sends + // kimi-k3- with maxMode=true; 1M context user-verified live on the Ultra plan + // (devlog 260826_cursor_responses_gap/025). inferCursorContextWindow maps "1m" ids to 1M. + { id: "kimi-k3-1m", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true }, { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true }, diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 6bc0270f74..29525b4190 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -58,6 +58,9 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // GetUsableModels (2026-07-28) lists kimi-k3 only as effort-suffixed kimi-k3-{low,high,max}; // the bare id returns not_found. Tiers mirror the native Kimi provider's K3 ladder. "kimi-k3": ["low", "high", "max"], + // Synthetic ultra picker variant (devlog 260826 070): same tier ladder as kimi-k3; the -1m + // marker is stripped before wire-id composition, so these tiers never form a wire suffix. + "kimi-k3-1m": ["low", "high", "max"], // Cursor renamed the Grok 4.5 slugs to cursor-grok-4.5-{low,medium,high} and // cursor-grok-4.5-{low,medium,high}-fast. The bare Fast id returns not_found. "grok-4.5": ["low", "medium", "high"], diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index 32bafe1517..4e4c07a236 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -43,7 +43,7 @@ export interface CursorUsableModelsOptions { } export type CursorUsableModelsResult = - | { ok: true; models: string[] } + | { ok: true; models: string[]; maxModeModels?: string[] } | { ok: false; error: "auth" | "http" | "policy" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string }; /** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */ @@ -120,6 +120,7 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { // make stale configured ids such as `composer-2` look activated. const ids: string[] = []; const seenIds = new Set(); + const maxModeIds: string[] = []; for (const model of response.models ?? []) { const rawId = (model as { modelId?: string }).modelId; if (typeof rawId !== "string") continue; @@ -127,9 +128,13 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { if (!isValidModelDiscoveryModelId(id) || seenIds.has(id)) continue; seenIds.add(id); ids.push(id); + // Preserve Max-Mode capability for ultra/big-context auto-detection (devlog 260826 070). + if ((model as { maxMode?: boolean }).maxMode === true) maxModeIds.push(id); if (ids.length >= CURSOR_MAX_DISCOVERED_MODELS) break; } - return ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" }; + return ids.length > 0 + ? { ok: true, models: ids, ...(maxModeIds.length > 0 ? { maxModeModels: maxModeIds } : {}) } + : { ok: false, error: "empty" }; } catch { return { ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" }; } diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index c274cd9d74..5b4e07b426 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -967,12 +967,15 @@ function buildPreparedCursorRunRequest( displayName: request.modelId, displayNameShort: request.modelId, aliases: [], + ...(request.maxMode === true ? { maxMode: true } : {}), }), } : {}), - ...(requestedModelParameters.length > 0 ? { + ...(requestedModelParameters.length > 0 || request.maxMode === true ? { requestedModel: create(RequestedModelSchema, { modelId: request.modelId, - maxMode: false, + // Max Mode must be raised on BOTH RequestedModel and ModelDetails; missing either + // can invalid_argument upstream (devlog 260826 070). + maxMode: request.maxMode === true, parameters: requestedModelParameters.map(parameter => create(RequestedModel_ModelParameterbytesSchema, parameter)), }), diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 542386b8c5..e99791134a 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -10,6 +10,7 @@ import type { import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types"; import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequest } from "./types"; import { cursorCheckpointModelAffinityId, cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; +import { cursorUltraBaseModelId } from "./discovery"; import { decodeCursorCallId } from "./call-id"; import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map"; import { @@ -189,13 +190,19 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): { modelId: string; requestedModelParameters?: readonly CursorRequestedModelParameter[]; routingLevel?: CursorRoutingLevel; + maxMode?: boolean; } { - const selection = cursorWireModelSelection(modelId); + // Synthetic ultra (-1m) picker rows resolve to their wire base with Max Mode on + // (devlog 260826 070); the marker never reaches the wire. + const ultraBase = cursorUltraBaseModelId(modelId); + const selection = cursorWireModelSelection(ultraBase ?? modelId); + const maxMode = ultraBase !== undefined ? { maxMode: true } : {}; const id = selection.modelId; const suffix = cursorEffortSuffix(id, reasoning); if ((id === "grok-4.5-fast" || id === "grok-4.6-fast") && suffix) { return { ...selection, + ...maxMode, modelId: id.slice(0, -"-fast".length), requestedModelParameters: [ { id: "effort", value: suffix }, @@ -203,7 +210,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): { ], }; } - return { ...selection, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id }; + return { ...selection, ...maxMode, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id }; } function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined { @@ -446,6 +453,7 @@ export function createCursorRequest( modelId: model.modelId, ...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}), ...(model.routingLevel ? { routingLevel: model.routingLevel } : {}), + ...(model.maxMode ? { maxMode: true } : {}), conversationId: resolveCursorConversationId(parsed, model.modelId, options), system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])], messages, diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index 94112b4697..1c642e9b78 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -15,6 +15,12 @@ export interface CursorRunRequest { requestedModelParameters?: readonly CursorRequestedModelParameter[]; /** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */ routingLevel?: CursorRoutingLevel; + /** + * Cursor Max Mode (ultra/big-context). Set from a synthetic `-1m` picker variant; the wire + * keeps the original model id and raises RequestedModel.maxMode + ModelDetails.maxMode + * (both fields — missing either can invalid_argument upstream). Devlog 260826 070. + */ + maxMode?: boolean; /** * 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 diff --git a/tests/cursor-ultra-mode.test.ts b/tests/cursor-ultra-mode.test.ts new file mode 100644 index 0000000000..a6cbf5bb3a --- /dev/null +++ b/tests/cursor-ultra-mode.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { + CURSOR_STATIC_MODELS, + CURSOR_ULTRA_1M_MODEL_IDS, + cursorUltraBaseModelId, + filterCursorConfiguredModelsByLiveDiscovery, +} from "../src/adapters/cursor/discovery"; +import { createCursorRequest } from "../src/adapters/cursor/request-builder"; +import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { fromBinary } from "@bufbuild/protobuf"; +import { AgentClientMessageSchema, type AgentRunRequest } from "../src/adapters/cursor/gen/agent_pb"; +import type { OcxParsedRequest } from "../src/types/request"; + +function decodeRunRequest(bytes: Uint8Array): AgentRunRequest { + const msg = fromBinary(AgentClientMessageSchema, bytes); + if (msg.message.case !== "runRequest") throw new Error("expected runRequest"); + return msg.message.value; +} + +function parsedFor(modelId: string, reasoning?: string): OcxParsedRequest { + return { + modelId, + context: { systemPrompt: [], messages: [{ role: "user", content: "hi" }] }, + options: reasoning ? { reasoning } : {}, + } as OcxParsedRequest; +} + +describe("cursor ultra (-1m / Max Mode) toggle (devlog 260826 070)", () => { + test("static catalog exposes the kimi-k3-1m picker row with 1M context", () => { + const row = CURSOR_STATIC_MODELS.find(model => model.id === "kimi-k3-1m"); + expect(row).toBeDefined(); + expect(row?.contextWindow).toBe(1_000_000); + expect(row?.supportsReasoningEffort).toBe(true); + }); + + test("ultra marker resolves to its wire base and never leaks", () => { + expect(cursorUltraBaseModelId("cursor/kimi-k3-1m")).toBe("kimi-k3"); + expect(cursorUltraBaseModelId("kimi-k3-1m")).toBe("kimi-k3"); + expect(cursorUltraBaseModelId("kimi-k3")).toBeUndefined(); + expect(cursorUltraBaseModelId("claude-4-sonnet-1m")).toBeUndefined(); + }); + + test("kimi-k3-1m + max resolves to wire kimi-k3-max with maxMode on the request", () => { + const request = createCursorRequest(parsedFor("cursor/kimi-k3-1m", "max")); + expect(request.modelId).toBe("kimi-k3-max"); + expect(request.maxMode).toBe(true); + }); + + test("plain kimi-k3 stays maxMode-off", () => { + const request = createCursorRequest(parsedFor("cursor/kimi-k3", "max")); + expect(request.modelId).toBe("kimi-k3-max"); + expect(request.maxMode).toBeUndefined(); + }); + + test("wire raises maxMode on BOTH RequestedModel and ModelDetails", () => { + const bytes = encodeCursorRunRequest({ + modelId: "kimi-k3-max", + maxMode: true, + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "hi" }], + }); + const decoded = decodeRunRequest(bytes); + expect(decoded.requestedModel?.maxMode).toBe(true); + expect(decoded.requestedModel?.modelId).toBe("kimi-k3-max"); + expect(decoded.modelDetails?.maxMode).toBe(true); + }); + + test("non-ultra requests keep maxMode=false wire behavior", () => { + const bytes = encodeCursorRunRequest({ + modelId: "kimi-k3-max", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "hi" }], + }); + const decoded = decodeRunRequest(bytes); + expect(decoded.requestedModel).toBeUndefined(); + // ModelDetails.maxMode is proto-optional; absent (undefined) means off. + expect(decoded.modelDetails?.maxMode ?? false).toBe(false); + }); + + test("account filter admits the synthetic row through its base availability", () => { + const configured = [{ id: "kimi-k3-1m" }, { id: "kimi-k3" }]; + const live = ["kimi-k3-high", "kimi-k3-max"]; + const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, live); + expect(filtered.map(model => model.id)).toEqual(["kimi-k3-1m", "kimi-k3"]); + }); + + test("ultra id set stays narrow and every entry has a static row", () => { + for (const id of CURSOR_ULTRA_1M_MODEL_IDS) { + expect(CURSOR_STATIC_MODELS.some(model => model.id === id)).toBe(true); + } + }); +});