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);
});
});