diff --git a/devlog/_plan/260828_cursor_ndjson_backlog_train/050_merge_train.md b/devlog/_plan/260828_cursor_ndjson_backlog_train/050_merge_train.md new file mode 100644 index 0000000000..6393efa5b5 --- /dev/null +++ b/devlog/_plan/260828_cursor_ndjson_backlog_train/050_merge_train.md @@ -0,0 +1,50 @@ +# 050 — cursor PR merge train (wp map for the merge-round loop) + +User instruction (2026-08-28): merge the cursor rounds one at a time; the +instruction is the maintainer approval for these session-authored PRs. + +## Rounds (dependency-first) + +| R | PR | head | gate | +|---|---|---|---| +| R1 | #2774 backlog coalesce | codex/runturn-backlog-coalesce 286a1e5a5 | checks 25 SUCCESS + 1 SKIPPED — green; sol-medium pre-merge review | +| R2 | #2795 midstream echo | codex/cursor-midstream-echo | retarget to dev post-R1; CI re-run green | +| R3 | #2769 failed_precondition | codex/claude-classified-error-status 16cb875b8 | checks green; review | +| R4 | #2801 umbrella core | codex/cursor-umbrella-core 54965ef03 | CI FAIL: test 1/4 update-stop-first launcher-recovery timeout (46.8s, waitForProxy false) — UNRELATED to catalog diff (no update/launcher files touched); same infra-flaky class dev itself shows (dev run 33134096643 fails a different macos test). Gate: causal fix or evidence-backed unrelated-flake disposition + fresh green run; never rerun-until-green without a cause | +| R5 | #2802 umbrella wire | codex/cursor-umbrella-wire | retarget to dev post-R4; CI green | + +## Per-round procedure + +1. Exact head SHA + full check rollup via gh. +2. sol-medium reviewer: independent diff review, VERDICT line. +3. Blockers folded or rebutted with rationale; repairs get focused tests. +4. gh pr merge --squash --delete-branch; record merge SHA. +5. Child retarget (gh pr edit --base dev) + verify checks restart. +6. Post-merge: origin/dev log + no new cursor-test failures. + +## Round log + +- R1 (#2774): reviewer PASS (Tesla, sol-tier; coalescing phase-safe, consumers + checked). MERGED squash 5511a424c via --admin (user merge instruction = + maintainer approval; branch policy requires review). Head branch deleted. + SIDE EFFECT: base deletion auto-closed stacked #2795, which GitHub cannot + reopen (base ref gone). Recovery: cherry-picked 58ee805/a652f0d/e167311 + onto origin/dev (990a83f5e; 17 tests + tsc green on rebased head), + force-pushed the branch, opened successor PR #2803 vs dev. + LESSON for R4/R5: retarget the child to dev BEFORE merging the parent with + --delete-branch, or merge parent without branch deletion. +- R2 (#2803, successor of #2795): CI 23 ok / 0 fail (CodeRabbit status + marker non-required); prior audits stand (cherry-pick clean). MERGED + squash via --admin, branch deleted. +- R3 (#2769): reviewer PASS (Avicenna; precedence + claude derivation + + 72 focused tests + clean merge simulation). MERGED squash via --admin, + branch deleted. +- R4 (#2801): CI failure root-caused by investigator (Zeno): update-stop-first + 45s readiness deadline exhausted on loaded runners (46-47s failures on 4+ + unrelated PRs; catalog diff has no launcher imports, isolated shard). + Causal fix 22c073e03 raised the deadline to 90s (derived budget + pinned + arithmetic keep it honest). Fresh CI fully green (0F, macos SUCCESS). + MERGED squash 7232a60a7. #2802 retargeted to dev BEFORE branch deletion + (R1 lesson applied) — but the parent squash still made the old chain + CONFLICTING; wire branch cherry-picked onto dev (874f59734, 116 tests + + tsc green) and force-pushed; #2802 stayed OPEN base=dev. diff --git a/devlog/_plan/260828_cursor_umbrella_catalog/030_closure.md b/devlog/_plan/260828_cursor_umbrella_catalog/030_closure.md index d4e114e848..f22f44c0dc 100644 --- a/devlog/_plan/260828_cursor_umbrella_catalog/030_closure.md +++ b/devlog/_plan/260828_cursor_umbrella_catalog/030_closure.md @@ -10,3 +10,40 @@ 3. Back-compat proof: legacy-id wire table test green (every 69 id routes to the same wire id as before, or documented intentional change). 4. Stack finalization: PR A -> dev, PR B stacked; retarget checks. + +## Closure results (2026-08-28) + +### Cleanliness comparison + +| Measure | Before (opencodex) | After | senpi | omo-ai@beta | +|---|---|---|---|---| +| Picker rows (cursor, non-router) | 65 | 47 seed rows / 31 umbrella identities | ~raw roster + grouped-with-fast-splits | none (no cursor map at all) | +| Variant duplicate rows | 22 (13 thinking + 7 fast + 2 x 1m) | 1 (claude-4-sonnet-1m real wire id) + composer-2.5-fast (no effort base) | thinking split retained for Claude; fast groups separate | n/a | +| Capability truth surfaces | 2 (effort-map tables + discovery seed annotations) | 1 (catalog.ts CURSOR_CAPABILITIES) | 3 (static TS table + 336-row generated alias JSON + display-name regexes) | 0 | +| Thinking handling | 13 separate picker rows | dimension; merged into base for ALL families | Claude-only thinkingMode split | delegated | +| Fast handling | 7 separate rows | dimension; aliases only | separate catalog groups; parameter fast always "false" | delegated | +| 1M/Max-Mode | single synthetic kimi-k3-1m row | window metadata generalized (claude/gemini/kimi/gpt-5.6 1M) + evidence-gated ultra->maxMode (static kimi-k3 + live maxModeModels union) | window/maxWindow fields; maxMode from name regex + family pattern (window-size inference we rejected as unsupported) | delegated | +| Back-compat | n/a | every legacy slug byte-identical (oracle + pinned-session tests) | variant-id fallback silently degrades to representative id | n/a | + +LOC: effort-map.ts (229) still present as the test oracle only — zero src/ +consumers remain (request-builder/discovery now import catalog.ts; discovery +keeps two legacy helpers for the transition). catalog.ts is 541 lines +INCLUDING the full capability table that previously lived across two files +plus prose. Deletion of effort-map.ts is queued for the post-merge cleanup +once the oracle freezes to literal fixtures. + +### Picker proof + +cursorUmbrellaRows(): 31 identities. Excerpt: kimi-k3 {efforts:[low,high,max], +window:1000000, maxModeVerified:true} — the old kimi-k3-1m row is gone and its +capability rides the base. Seed: 51 rows (4 router + 47). + +### Stack + +| PR | base | head | state | +|---|---|---|---| +| #2801 core | dev | codex/cursor-umbrella-core 54965ef03 | open | +| #2802 wire | codex/cursor-umbrella-core | codex/cursor-umbrella-wire 075c5705a | open, retarget to dev after #2801 | + +Verification totals: 1137 tests / 56 cursor+catalog files pass on the wire +head; tsc 0; privacy scan pass. CI is the wide gate per user instruction. diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index 313015d6fd..9c7fa8b2bb 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -275,10 +275,21 @@ export interface ParsedCursorVariantId { } function stripLevelSuffix(id: string): { stem: string; level?: string } { + // Prefer the parse whose stem is a KNOWN capability, and among known stems + // the most specific (longest) one: "gpt-5.5-extra-high" must parse as + // gpt-5.5-extra + high (its real single-rung wire id), not gpt-5.5 + + // extra-high (A-gate blocker 2 family). + let fallback: { stem: string; level?: string } | undefined; + let best: { stem: string; level?: string } | undefined; for (const token of LEVEL_TOKENS) { - if (id.endsWith(`-${token}`)) return { stem: id.slice(0, -(token.length + 1)), level: token }; + if (!id.endsWith(`-${token}`)) continue; + const candidate = { stem: id.slice(0, -(token.length + 1)), level: token }; + fallback ??= candidate; + if (CURSOR_CAPABILITIES[candidate.stem] && (best === undefined || candidate.stem.length > best.stem.length)) { + best = candidate; + } } - return { stem: id }; + return best ?? fallback ?? { stem: id }; } /** @@ -287,12 +298,22 @@ function stripLevelSuffix(id: string): { stem: string; level?: string } { * `gpt-5.1-codex-max` and `gpt-5.5-extra` — whose tails collide with effort * tokens — never mis-parse (A-gate round-1 blocker 2). */ +/** + * Real wire ids that merely END in "-1m" — they are distinct catalog rows the + * wire serves verbatim, never the synthetic ultra marker (A-gate blocker 2: + * a real legacy wire identity must not parse as `-1m`). + */ +const REAL_1M_WIRE_IDS: ReadonlySet = new Set(["claude-4-sonnet-1m"]); + export function parseCursorVariantId(rawId: string): ParsedCursorVariantId { const id = rawId.trim(); // 1. Exact base identity. if (CURSOR_CAPABILITIES[id]) { return { baseId: id, kind: defaultKindFor(id), ultra: false, known: true }; } + if (REAL_1M_WIRE_IDS.has(id)) { + return { baseId: id, kind: "regular", ultra: false, known: false }; + } // 2. cursor- wire prefix (regular grok wire forms). if (id.startsWith("cursor-")) { const inner = parseCursorVariantId(id.slice("cursor-".length)); @@ -444,10 +465,32 @@ export function resolveCursorSelection( ? `${capability.wirePrefix}${canonicalId}` : canonicalId; const ultraRequested = parsed.ultra || reasoning?.toLowerCase() === "ultra"; - const maxModeArmed = capability.maxModeVerified === true || liveMaxModeIds?.has(parsed.baseId) === true; + const evidence = liveMaxModeIds ?? liveCursorMaxModeBases; + const maxModeArmed = capability.maxModeVerified === true || evidence.has(parsed.baseId); return { wireId, canonicalId, maxMode: ultraRequested && maxModeArmed, known: true }; } +/** + * Live Max-Mode evidence (GetUsableModels maxModeModels). Provider discovery + * records the BASES the live roster flags; the resolver unions this with the + * static `maxModeVerified` gate so ultra generalizes automatically as evidence + * arrives — never from window size (devlog 260828 blocker-4 fold). + */ +let liveCursorMaxModeBases: ReadonlySet = new Set(); + +export function recordLiveCursorMaxModeModels(liveIds: readonly string[]): void { + const bases = new Set(); + for (const id of liveIds) { + const parsed = parseCursorVariantId(id); + if (parsed.known) bases.add(parsed.baseId); + } + liveCursorMaxModeBases = bases; +} + +export function liveCursorMaxModeBasesForTests(): ReadonlySet { + return liveCursorMaxModeBases; +} + export interface CursorUmbrellaRow { readonly id: string; readonly efforts: readonly string[]; @@ -456,6 +499,27 @@ export interface CursorUmbrellaRow { readonly maxModeVerified: boolean; } +/** + * Grok Fast keeps the parameterized wire shape (base id + effort/fast + * parameters) rather than a flattened -fast id — current Cursor clients send + * it that way and the flat form is rejected. Returns undefined for every + * other id. + */ +export function cursorGrokFastSelection( + pickedId: string, + reasoning: string | undefined, +): { wireBaseId: string; effort: string } | undefined { + const parsed = parseCursorVariantId(pickedId); + if (!parsed.known || parsed.kind !== "fast") return undefined; + const capability = CURSOR_CAPABILITIES[parsed.baseId]; + if (capability?.wirePrefix !== "cursor-") return undefined; + const spec = capability.variants.fast; + if (!spec) return undefined; + const effort = cursorVariantEffort(spec, parsed.level ?? reasoning); + if (effort === undefined) return undefined; + return { wireBaseId: parsed.baseId, effort }; +} + /** * The umbrella picker rows: one per base whose default variant is selectable. * Thinking merges into the base row; fast/thinking-fast/legacy slugs stay diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 15f529fdb0..1b0709fd82 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -5,6 +5,7 @@ import { cursorWireModelIdWithEffort, CURSOR_THINKING_MODEL_IDS, } from "./effort-map"; +import { parseCursorVariantId } from "./catalog"; export interface CursorModelInfo { id: string; @@ -76,9 +77,16 @@ function stripCursorWirePrefix(id: string): string { * ordinary `{base}-{effort}` form, or Cursor's current `{base-without-fast}-{effort}-fast` form. */ export function isCursorModelAvailableForAccount(modelId: string, liveIds: readonly string[]): boolean { + // Umbrella matching (devlog 260828_cursor_umbrella_catalog): a live suffix + // id counts toward its BASE — any variant dimension (thinking/fast/effort) + // proves the account can reach the umbrella. Unknown ids fall back to the + // legacy exact/suffix comparison so non-cataloged rows keep matching. + const parsedTarget = parseCursorVariantId(modelId); return liveIds.some(raw => { const id = stripCursorWirePrefix(raw); if (id === modelId) return true; + const parsedLive = parseCursorVariantId(id); + if (parsedLive.known && parsedTarget.known && parsedLive.baseId === parsedTarget.baseId) return true; for (const effort of CANONICAL_EFFORT_SUFFIXES) { if ( id === `${modelId}-${effort}` || @@ -244,15 +252,13 @@ export function filterCursorConfiguredModelsByLiveDiscovery = new Set([ - "claude-opus-5", -]); +export const CURSOR_KNOWN_UNCALLABLE_MODEL_IDS: ReadonlySet = new Set([]); export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([ // Context windows and the model lineup mirror Cursor's public models/pricing docs plus the jawcode @@ -265,26 +271,26 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM // gemini/grok/kimi-k2.7/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker). ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })), - { id: "claude-sonnet-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + // Umbrella seed (devlog 260828_cursor_umbrella_catalog): one row per BASE + // model. Thinking merges into the base (the resolver routes the thinking + // variant); fast / thinking-fast / -1m stay routable as aliases but add no + // rows. Windows follow CURSOR_CAPABILITIES where the base is cataloged. + { id: "claude-sonnet-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, { id: "claude-4-sonnet", contextWindow: CONTEXT_200K }, { id: "claude-4-sonnet-1m", contextWindow: CONTEXT_1M }, { id: "claude-4.5-haiku", contextWindow: CONTEXT_200K }, { id: "claude-4.5-sonnet", contextWindow: CONTEXT_200K }, { id: "claude-4.5-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - { id: "claude-4.6-opus", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - { id: "claude-4.6-sonnet", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - { id: "claude-opus-4-7", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - // Opus Fast families: live GetUsableModels (260822) lists ONLY effort-suffixed wire ids - // ({base-without-fast}-{effort}-fast; the bare id returns not_found), so every entry - // carries a tier picker. Live-verified: claude-opus-4-8-high-fast completed a turn. - // Tiers per the 260822 dump (devlog 260822_senpi_cursor_transfer/300). - { 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 }, - // 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 }, + { id: "claude-4.6-opus", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, + { id: "claude-4.6-sonnet", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, + { id: "claude-opus-4-7", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, + { id: "claude-opus-4-8", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, + // claude-opus-5: regular variant is quarantined (not_found on every Run) but + // the umbrella row routes the THINKING variant, which is live — so the base + // row returns to the seed under the umbrella (resolver never sends the + // quarantined regular wire id for the bare slug). + { id: "claude-opus-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, + { id: "claude-fable-5", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, { id: "composer-1", contextWindow: CONTEXT_200K }, { id: "composer-2.5", contextWindow: CONTEXT_200K }, @@ -301,17 +307,6 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, { id: "gemini-3.7-flash", contextWindow: CONTEXT_GEMINI, supportsReasoningEffort: true }, - // Explicit-thinking variants (260825 live roster). Exposed as first-class ids the same way the - // Opus Fast families were in 831810c13: `isCursorModelAvailableForAccount` matches a base id - // against `{base}`, `{base}-{effort}` and the family's wire form, and none of those ever - // matched a `-thinking` id, so every one of these was invisible in the routed catalog. - // Suffix ORDER differs per family; `cursorWireModelIdWithEffort` owns that mapping. - ...CURSOR_THINKING_MODEL_IDS.map(id => ({ - id, - contextWindow: CONTEXT_200K, - supportsReasoningEffort: cursorModelHasEffortTiers(id), - })), - { id: "gpt-5-codex", contextWindow: CONTEXT_272K }, { id: "gpt-5-fast", contextWindow: CONTEXT_272K }, { id: "gpt-5-mini", contextWindow: CONTEXT_272K }, @@ -344,17 +339,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "kimi-k2.7-code", contextWindow: CONTEXT_262K }, // 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 }, + // kimi-k3 folds the old synthetic kimi-k3-1m row into the umbrella: the base + // is maxModeVerified (user-verified 1M on the Ultra plan, devlog 260826/025), + // so the ultra effort rung arms Max Mode on the wire and the separate picker + // row is gone. cursor/kimi-k3-1m stays routable as an alias. + { id: "kimi-k3", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true }, - { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true }, // 260813 preemptive: grok-4.6 seeded ahead of Cursor's lineup update (mirrors grok-4.5). { id: "grok-4.6", contextWindow: 500_000, supportsReasoningEffort: true }, - { id: "grok-4.6-fast", contextWindow: 500_000, supportsReasoningEffort: true }, ]); export function cursorModelIds(models: readonly CursorModelInfo[] = CURSOR_STATIC_MODELS): string[] { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index e99791134a..e73d5e98ea 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -12,7 +12,7 @@ import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequ import { cursorCheckpointModelAffinityId, cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; import { cursorUltraBaseModelId } from "./discovery"; import { decodeCursorCallId } from "./call-id"; -import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map"; +import { cursorGrokFastSelection, resolveCursorSelection } from "./catalog"; import { cursorMcpToolEncodedSize, cursorMcpToolsEncodedSize, @@ -192,25 +192,32 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): { routingLevel?: CursorRoutingLevel; maxMode?: boolean; } { - // 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 } : {}; + // Router ids (auto / auto-) keep their dedicated wire selection. + const selection = cursorWireModelSelection(modelId); + if (selection.routingLevel !== undefined || selection.modelId === "default") return selection; + // Umbrella catalog resolution (devlog 260828_cursor_umbrella_catalog): one + // resolver owns effort composition, variant dimensions, the synthetic -1m + // marker (ultra -> Max Mode, evidence-gated), and the cursor- wire prefix. const id = selection.modelId; - const suffix = cursorEffortSuffix(id, reasoning); - if ((id === "grok-4.5-fast" || id === "grok-4.6-fast") && suffix) { + // Grok Fast stays parameterized: current Cursor clients send the base id + // plus effort/fast parameters instead of the flattened -fast id. + const grokFast = cursorGrokFastSelection(id, reasoning); + if (grokFast) { return { ...selection, - ...maxMode, - modelId: id.slice(0, -"-fast".length), + modelId: grokFast.wireBaseId, requestedModelParameters: [ - { id: "effort", value: suffix }, + { id: "effort", value: grokFast.effort }, { id: "fast", value: "true" }, ], }; } - return { ...selection, ...maxMode, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id }; + const resolved = resolveCursorSelection(id, reasoning); + return { + ...selection, + ...(resolved.maxMode ? { maxMode: true } : {}), + modelId: resolved.wireId, + }; } function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 1c8d62ae76..5ef9dffa2b 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -46,6 +46,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -1309,6 +1310,9 @@ async function fetchProviderModelsWithAuth( }); if (liveResult.ok) { const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); + // Live Max-Mode evidence feeds the umbrella resolver's ultra gate + // (devlog 260828_cursor_umbrella_catalog; union with static evidence). + recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); const result = available.length > 0 ? available : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index 57cfb34901..4d298d3ca8 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -57,7 +57,8 @@ describe("Cursor discovery metadata", () => { expect(ids).toContain("glm-5.2"); expect(ids).toContain("kimi-k2.7-code"); expect(ids).toContain("kimi-k3"); - expect(ids).toContain("claude-opus-4-7-fast"); + // Umbrella merge (devlog 260828): fast duplicate rows folded into bases. + expect(ids).not.toContain("claude-opus-4-7-fast"); // 260709 refresh: stale ids dropped from the static seed (cursor.com docs); gpt-5.5-extra // stays — it survives the live GetUsableModels filter (004_live_snapshot.md). expect(ids).not.toContain("grok-4.20"); @@ -65,7 +66,7 @@ describe("Cursor discovery metadata", () => { expect(ids).not.toContain("kimi-k2.5"); expect(ids).toContain("gpt-5.5-extra"); expect(ids).toContain("grok-4.6"); - expect(ids).toContain("grok-4.6-fast"); + expect(ids).not.toContain("grok-4.6-fast"); expect(ids).not.toContain("composer-2"); // `auto` mirrors the jawcode SOT `default` entry (200k), not the generic fallback window. for (const id of CURSOR_ROUTER_MODEL_IDS) { @@ -90,11 +91,14 @@ describe("Cursor discovery metadata", () => { expect(isCursorModelAvailableForAccount("grok-4.5-fast", ["cursor-grok-4.5-high-fast"])).toBe(true); // Older snapshots used `{base}-fast-{effort}`; keep discovery compatibility. expect(isCursorModelAvailableForAccount("grok-4.5-fast", ["cursor-grok-4.5-fast-medium"])).toBe(true); - expect(isCursorModelAvailableForAccount("grok-4.5-fast", ["cursor-grok-4.5-high"])).toBe(false); - expect(isCursorModelAvailableForAccount("grok-4.5", ["cursor-grok-4.5-high-fast"])).toBe(false); + // Umbrella matching (devlog 260828): any variant wire id proves the BASE, + // and variant availability rides the base — a live regular grok id now + // admits the fast alias too (fast is a dimension, not a separate row). + expect(isCursorModelAvailableForAccount("grok-4.5-fast", ["cursor-grok-4.5-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("grok-4.5", ["cursor-grok-4.5-high-fast"])).toBe(true); expect(isCursorModelAvailableForAccount("grok-4.6", ["cursor-grok-4.6-xhigh"])).toBe(true); expect(isCursorModelAvailableForAccount("grok-4.6-fast", ["cursor-grok-4.6-xhigh-fast"])).toBe(true); - expect(isCursorModelAvailableForAccount("grok-4.6", ["cursor-grok-4.6-xhigh-fast"])).toBe(false); + expect(isCursorModelAvailableForAccount("grok-4.6", ["cursor-grok-4.6-xhigh-fast"])).toBe(true); expect(isCursorModelAvailableForAccount("gpt-5.4", ["cursor-gpt-5.4-high"])).toBe(true); // Prefixed sibling rejection: cursor- prefix must not bypass sibling-model checks. expect(isCursorModelAvailableForAccount("gpt-5.5", ["cursor-gpt-5.5-extra-high"])).toBe(false); diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index fdc9b04b46..ae7567f260 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -47,28 +47,32 @@ function selectionFor(modelId: string, reasoning?: string) { return { modelId: request.modelId, parameters: request.requestedModelParameters }; } +// Umbrella-merge note (devlog 260828_cursor_umbrella_catalog): bare claude +// base ids now route their THINKING variant — the wire ids below carry the +// family's thinking marker. Effort semantics (literal-first, rank clamp, +// #545 none->lowest) are unchanged; only the variant dimension moved. describe("Cursor per-model reasoning-effort suffix", () => { test("literal requested efforts pass through when the model supports that tier", () => { - expect(modelIdFor("cursor/claude-4.6-opus", "high")).toBe("claude-4.6-opus-high"); - expect(modelIdFor("cursor/claude-4.6-opus", "max")).toBe("claude-4.6-opus-max"); - expect(modelIdFor("cursor/claude-4.6-opus", "xhigh")).toBe("claude-4.6-opus-max"); + expect(modelIdFor("cursor/claude-4.6-opus", "high")).toBe("claude-4.6-opus-high-thinking"); + expect(modelIdFor("cursor/claude-4.6-opus", "max")).toBe("claude-4.6-opus-max-thinking"); + expect(modelIdFor("cursor/claude-4.6-opus", "xhigh")).toBe("claude-4.6-opus-max-thinking"); expect(cursorEffortSuffix("claude-4.6-opus", "high")).toBe("high"); }); test("models with both max and xhigh preserve the exact named tier", () => { - expect(modelIdFor("cursor/claude-opus-4-8", "low")).toBe("claude-opus-4-8-low"); - expect(modelIdFor("cursor/claude-opus-4-8", "medium")).toBe("claude-opus-4-8-medium"); - expect(modelIdFor("cursor/claude-opus-4-8", "high")).toBe("claude-opus-4-8-high"); - expect(modelIdFor("cursor/claude-opus-4-8", "max")).toBe("claude-opus-4-8-max"); - expect(modelIdFor("cursor/claude-opus-4-8", "xhigh")).toBe("claude-opus-4-8-xhigh"); - expect(modelIdFor("cursor/claude-opus-4-8", "ultra")).toBe("claude-opus-4-8-max"); + expect(modelIdFor("cursor/claude-opus-4-8", "low")).toBe("claude-opus-4-8-thinking-low"); + expect(modelIdFor("cursor/claude-opus-4-8", "medium")).toBe("claude-opus-4-8-thinking-medium"); + expect(modelIdFor("cursor/claude-opus-4-8", "high")).toBe("claude-opus-4-8-thinking-high"); + expect(modelIdFor("cursor/claude-opus-4-8", "max")).toBe("claude-opus-4-8-thinking-max"); + expect(modelIdFor("cursor/claude-opus-4-8", "xhigh")).toBe("claude-opus-4-8-thinking-xhigh"); + expect(modelIdFor("cursor/claude-opus-4-8", "ultra")).toBe("claude-opus-4-8-thinking-max"); }); test("efforts outside the model tier set clamp by Codex rank", () => { - expect(modelIdFor("cursor/claude-4.6-opus", "low")).toBe("claude-4.6-opus-high"); // tiers[0] - expect(modelIdFor("cursor/claude-4.6-opus", "medium")).toBe("claude-4.6-opus-high"); - expect(modelIdFor("cursor/claude-4.6-opus", "none")).toBe("claude-4.6-opus-high"); - expect(modelIdFor("cursor/claude-4.6-opus")).toBe("claude-4.6-opus-max"); + expect(modelIdFor("cursor/claude-4.6-opus", "low")).toBe("claude-4.6-opus-high-thinking"); // tiers[0] + expect(modelIdFor("cursor/claude-4.6-opus", "medium")).toBe("claude-4.6-opus-high-thinking"); + expect(modelIdFor("cursor/claude-4.6-opus", "none")).toBe("claude-4.6-opus-high-thinking"); + expect(modelIdFor("cursor/claude-4.6-opus")).toBe("claude-4.6-opus-max-thinking"); }); // #545 made Claude Desktop's `thinking:{type:"disabled"}` survive translation as the "none" @@ -79,14 +83,14 @@ describe("Cursor per-model reasoning-effort suffix", () => { // reading of "do not think". Dropping the instruction sent these to the model's TOP tier, // which is the opposite of what the caller asked for. test("an explicit 'none' picks the lowest tier, not the top one (#545)", () => { - expect(modelIdFor("cursor/claude-opus-4-8", "none")).toBe("claude-opus-4-8-low"); - expect(modelIdFor("cursor/claude-opus-4-8")).toBe("claude-opus-4-8-max"); + expect(modelIdFor("cursor/claude-opus-4-8", "none")).toBe("claude-opus-4-8-thinking-low"); + expect(modelIdFor("cursor/claude-opus-4-8")).toBe("claude-opus-4-8-thinking-max"); }); test("single-tier models always use their one tier", () => { expect(modelIdFor("cursor/gpt-5.5-extra", "low")).toBe("gpt-5.5-extra-high"); - expect(modelIdFor("cursor/claude-4.6-sonnet", "high")).toBe("claude-4.6-sonnet-medium"); - expect(modelIdFor("cursor/claude-4.5-opus", "low")).toBe("claude-4.5-opus-high"); + expect(modelIdFor("cursor/claude-4.6-sonnet", "high")).toBe("claude-4.6-sonnet-medium-thinking"); + expect(modelIdFor("cursor/claude-4.5-opus", "low")).toBe("claude-4.5-opus-high-thinking"); }); test("non-reasoning models and already-qualified ids are left bare", () => { @@ -97,9 +101,9 @@ describe("Cursor per-model reasoning-effort suffix", () => { }); test("claude-sonnet-5 and glm-5.2 map to live effort suffixes", () => { - expect(modelIdFor("cursor/claude-sonnet-5", "low")).toBe("claude-sonnet-5-low"); - expect(modelIdFor("cursor/claude-sonnet-5", "high")).toBe("claude-sonnet-5-high"); - expect(modelIdFor("cursor/claude-sonnet-5", "max")).toBe("claude-sonnet-5-max"); + expect(modelIdFor("cursor/claude-sonnet-5", "low")).toBe("claude-sonnet-5-thinking-low"); + expect(modelIdFor("cursor/claude-sonnet-5", "high")).toBe("claude-sonnet-5-thinking-high"); + expect(modelIdFor("cursor/claude-sonnet-5", "max")).toBe("claude-sonnet-5-thinking-max"); expect(modelIdFor("cursor/glm-5.2", "low")).toBe("glm-5.2-high"); expect(modelIdFor("cursor/glm-5.2", "medium")).toBe("glm-5.2-high"); expect(modelIdFor("cursor/glm-5.2", "high")).toBe("glm-5.2-high"); @@ -258,14 +262,16 @@ describe("#2569 Cursor explicit-thinking variants", () => { expect(cursorModelEffortLadder("claude-4.5-sonnet-thinking")).toBeUndefined(); }); - test("every thinking variant is catalogued and survives live-discovery filtering", () => { + test("thinking variants folded into umbrella rows but still match live-discovery filtering", () => { + // Umbrella merge (devlog 260828): the 13 separate -thinking picker rows are + // gone — the BASE row carries the thinking default. Live thinking wire ids + // must therefore prove the BASE's availability, and legacy thinking slugs + // keep matching too (alias retention). const ids = new Set(CURSOR_STATIC_MODELS.map(model => model.id)); - for (const id of CURSOR_THINKING_MODEL_IDS) expect(ids.has(id)).toBe(true); - - // A base id is kept only when it matches a live wire id; before this change none of the - // -thinking forms matched, so every variant was invisible in the routed catalog. + for (const id of CURSOR_THINKING_MODEL_IDS) expect(ids.has(id)).toBe(false); + expect(isCursorModelAvailableForAccount("claude-opus-5", ["claude-opus-5-thinking-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-4.6-opus", ["claude-4.6-opus-max-thinking"])).toBe(true); expect(isCursorModelAvailableForAccount("claude-opus-5-thinking", ["claude-opus-5-thinking-high"])).toBe(true); - expect(isCursorModelAvailableForAccount("claude-4.6-opus-thinking", ["claude-4.6-opus-max-thinking"])).toBe(true); expect(isCursorModelAvailableForAccount("claude-4.5-sonnet-thinking", ["claude-4.5-sonnet-thinking"])).toBe(true); }); @@ -274,4 +280,3 @@ describe("#2569 Cursor explicit-thinking variants", () => { expect(cursorWireModelIdWithEffort("claude-opus-5-fast", "high")).toBe("claude-opus-5-high-fast"); }); }); - diff --git a/tests/cursor-static-catalog.test.ts b/tests/cursor-static-catalog.test.ts index 1775989bf5..87f73320b9 100644 --- a/tests/cursor-static-catalog.test.ts +++ b/tests/cursor-static-catalog.test.ts @@ -122,12 +122,16 @@ describe("Cursor static Codex catalog", () => { }); describe("Opus Fast catalog families (devlog 300, live-verified 260822)", () => { - test("all three -fast families are present with tier pickers", async () => { + // Umbrella merge (devlog 260828): the -fast rows folded into their bases. + // The alias path still resolves them with the exact live-verified ladders. + test("fast rows folded into bases; aliases keep resolving with tier semantics", async () => { const { CURSOR_STATIC_MODELS } = await import("../src/adapters/cursor/discovery"); + const { resolveCursorSelection } = await import("../src/adapters/cursor/catalog"); for (const id of ["claude-opus-4-7-fast", "claude-opus-4-8-fast", "claude-opus-5-fast"]) { - const entry = CURSOR_STATIC_MODELS.find(model => model.id === id); - expect(entry, `${id} missing from static catalog`).toBeDefined(); - expect(entry?.supportsReasoningEffort, `${id} must carry a tier picker — the bare wire id is not_found`).toBe(true); + expect(CURSOR_STATIC_MODELS.some(model => model.id === id)).toBe(false); + // Alias never sends a bare -fast id (not_found on the wire). + expect(resolveCursorSelection(id, undefined).wireId.endsWith("-fast")).toBe(true); + expect(resolveCursorSelection(id, undefined).wireId).not.toBe(id); } }); diff --git a/tests/cursor-ultra-mode.test.ts b/tests/cursor-ultra-mode.test.ts index a6cbf5bb3a..b45c70a91e 100644 --- a/tests/cursor-ultra-mode.test.ts +++ b/tests/cursor-ultra-mode.test.ts @@ -26,11 +26,15 @@ function parsedFor(modelId: string, reasoning?: string): 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"); + // Umbrella merge (devlog 260828_cursor_umbrella_catalog): the synthetic + // kimi-k3-1m picker row folded into the kimi-k3 base row (1M context, + // maxModeVerified). The alias stays routable; the separate row is gone. + test("kimi-k3 base row carries the 1M context; the synthetic -1m row is folded in", () => { + const row = CURSOR_STATIC_MODELS.find(model => model.id === "kimi-k3"); expect(row).toBeDefined(); expect(row?.contextWindow).toBe(1_000_000); expect(row?.supportsReasoningEffort).toBe(true); + expect(CURSOR_STATIC_MODELS.some(model => model.id === "kimi-k3-1m")).toBe(false); }); test("ultra marker resolves to its wire base and never leaks", () => { @@ -86,9 +90,10 @@ describe("cursor ultra (-1m / Max Mode) toggle (devlog 260826 070)", () => { 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", () => { + test("ultra id set stays narrow and every entry rides its base's static row", () => { for (const id of CURSOR_ULTRA_1M_MODEL_IDS) { - expect(CURSOR_STATIC_MODELS.some(model => model.id === id)).toBe(true); + const base = id.slice(0, -"-1m".length); + expect(CURSOR_STATIC_MODELS.some(model => model.id === base)).toBe(true); } }); }); diff --git a/tests/cursor-umbrella-rows.test.ts b/tests/cursor-umbrella-rows.test.ts new file mode 100644 index 0000000000..c560c376ce --- /dev/null +++ b/tests/cursor-umbrella-rows.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { + cursorUmbrellaRows, + recordLiveCursorMaxModeModels, + resolveCursorSelection, +} from "../src/adapters/cursor/catalog"; +import { CURSOR_STATIC_MODELS, cursorModelReasoningEfforts } from "../src/adapters/cursor/discovery"; +import { createCursorRequest } from "../src/adapters/cursor/request-builder"; +import type { OcxParsedRequest } from "../src/types"; + +function parsedFor(modelId: string, reasoning?: string): OcxParsedRequest { + return { + modelId, + context: { systemPrompt: [], messages: [{ role: "user", content: "hi" }] }, + options: reasoning ? { reasoning } : {}, + } as OcxParsedRequest; +} + +describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", () => { + test("the seed collapsed: no thinking, no fast-duplicate, no -1m rows remain", () => { + const ids = CURSOR_STATIC_MODELS.map(model => model.id); + expect(ids.filter(id => id.includes("-thinking"))).toEqual([]); + expect(ids).not.toContain("kimi-k3-1m"); + expect(ids).not.toContain("claude-opus-4-7-fast"); + expect(ids).not.toContain("claude-opus-4-8-fast"); + expect(ids).not.toContain("claude-opus-5-fast"); + expect(ids).not.toContain("grok-4.5-fast"); + expect(ids).not.toContain("grok-4.6-fast"); + // composer-2.5-fast has no umbrella base with effort dimensions; it stays. + expect(ids).toContain("composer-2.5-fast"); + }); + + test("the quarantined opus-5 base returns to the seed under its thinking umbrella", () => { + expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5")).toBe(true); + }); + + test("row count shrank from the 69-row legacy seed", () => { + // 4 router + 47 base rows. Legacy carried 69 (13 thinking + 5 fast + // duplicates + kimi-k3-1m folded away; quarantined opus-5 base returned). + expect(CURSOR_STATIC_MODELS.length).toBe(51); + }); + + test("umbrella rows and seed efforts agree for every cataloged base", () => { + const efforts = cursorModelReasoningEfforts(); + for (const row of cursorUmbrellaRows()) { + const seeded = efforts[row.id]; + if (seeded === undefined) continue; // bases not in the static seed (yet) + if (seeded.length === 0) continue; // seed marks it non-effort + expect({ id: row.id, efforts: seeded }).toEqual({ id: row.id, efforts: [...row.efforts] }); + } + }); + + describe("pinned-session survival: removed picker slugs still route byte-identically", () => { + const removedSlugs: Array<[string, string | undefined, string]> = [ + ["cursor/claude-opus-5-thinking", "high", "claude-opus-5-thinking-high"], + ["cursor/claude-opus-4-8-thinking-fast", "max", "claude-opus-4-8-thinking-max-fast"], + ["cursor/claude-opus-4-7-fast", "high", "claude-opus-4-7-high-fast"], + ["cursor/claude-4.6-sonnet-thinking", "high", "claude-4.6-sonnet-medium-thinking"], + ["cursor/claude-4-sonnet-thinking", undefined, "claude-4-sonnet-thinking"], + ["cursor/kimi-k3-1m", "max", "kimi-k3-max"], + ]; + for (const [slug, effort, wire] of removedSlugs) { + test(`${slug} still resolves to ${wire}`, () => { + const request = createCursorRequest(parsedFor(slug, effort)); + expect(request.modelId).toBe(wire); + }); + } + + test("removed grok fast slug keeps its parameterized wire shape", () => { + const request = createCursorRequest(parsedFor("cursor/grok-4.6-fast", "high")); + expect(request.modelId).toBe("grok-4.6"); + expect(request.requestedModelParameters).toEqual([ + { id: "effort", value: "high" }, + { id: "fast", value: "true" }, + ]); + }); + + test("kimi-k3-1m alias still arms Max Mode", () => { + const request = createCursorRequest(parsedFor("cursor/kimi-k3-1m", "max")); + expect(request.maxMode).toBe(true); + }); + }); + + describe("live Max-Mode evidence generalizes ultra", () => { + test("recorded live maxModeModels arm ultra for their bases and reset cleanly", () => { + recordLiveCursorMaxModeModels(["claude-opus-4-8-high-fast"]); + try { + expect(resolveCursorSelection("claude-opus-4-8", "ultra").maxMode).toBe(true); + expect(resolveCursorSelection("claude-opus-4-7", "ultra").maxMode).toBe(false); + } finally { + recordLiveCursorMaxModeModels([]); + } + expect(resolveCursorSelection("claude-opus-4-8", "ultra").maxMode).toBe(false); + // Static evidence survives the reset. + expect(resolveCursorSelection("kimi-k3", "ultra").maxMode).toBe(true); + }); + }); +}); diff --git a/tests/cursor-uncallable-quarantine.test.ts b/tests/cursor-uncallable-quarantine.test.ts index 99bc3957a0..64eb0b0b58 100644 --- a/tests/cursor-uncallable-quarantine.test.ts +++ b/tests/cursor-uncallable-quarantine.test.ts @@ -6,28 +6,29 @@ import { } 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); + // Umbrella merge (devlog 260828): the base row RETURNED to the seed because + // its umbrella defaults to the healthy THINKING variant; the quarantined + // regular wire id is still never sent for the bare slug (resolver-level). + test("static seed carries claude-opus-5 under its thinking umbrella", () => { + expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5")).toBe(true); }); - test("siblings from other wire families survive", () => { - expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5-fast")).toBe(true); + test("fast siblings folded into the umbrella (no separate seed rows)", () => { + expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5-fast")).toBe(false); }); - 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("the quarantined REGULAR variant is never sent for the bare slug (resolver-level quarantine)", async () => { + const { resolveCursorSelection, CURSOR_CAPABILITIES } = await import("../src/adapters/cursor/catalog"); + expect(CURSOR_CAPABILITIES["claude-opus-5"]!.variants.regular?.quarantined).toBe(true); + // The bare slug routes the healthy thinking family, never claude-opus-5-. + expect(resolveCursorSelection("claude-opus-5", "high").wireId).toBe("claude-opus-5-thinking-high"); }); - test("quarantine applies with an empty live list too (stale/static degradation path)", () => { + test("row-level quarantine set is empty (mechanism retained for whole-base cases)", () => { + expect([...CURSOR_KNOWN_UNCALLABLE_MODEL_IDS]).toEqual([]); + // Live thinking wire ids prove the base under the umbrella matcher. 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"]); + const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, ["claude-opus-5-thinking-high"]); + expect(filtered.some(model => model.id === "claude-opus-5")).toBe(true); }); });