From 54bafa1cceb4326fa55c7f155b09b838076e94b5 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 11 Jul 2026 21:52:28 +0200 Subject: [PATCH] feat(coding-agent): implemented interactive fallback chain configuration - Implemented model-specific keys and provider wildcards for `retry.fallbackChains` with updated resolution logic. - Added interactive fallback chain management in the model roles UI, including support for reordering and editing. - Improved fallback chain specificity rules and added comprehensive validation with startup warnings. - Fixed mouse interaction alignment and hover state coordinate mapping in the roles view. --- README.md | 2 +- docs/settings.md | 31 +- .../test/auth-storage-codex-selection.test.ts | 38 ++ packages/coding-agent/CHANGELOG.md | 5 + .../src/config/settings-schema.ts | 2 +- .../src/modes/components/model-hub.ts | 487 ++++++++++++++---- .../modes/controllers/selector-controller.ts | 33 +- .../coding-agent/src/session/agent-session.ts | 156 +++++- .../test/agent-session-retry-fallback.test.ts | 242 +++++++++ packages/coding-agent/test/model-hub.test.ts | 212 +++++++- 10 files changed, 1060 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 49a3fb43f..a9d03c0f5 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ Ollama `local` · Ollama Cloud · LM Studio `local` · llama.cpp `local` · vLLM ### Four knobs that make routing useful - **Custom providers** — Declare anything that speaks `openai-completions`, `openai-responses`, `openai-codex-responses`, `azure-openai-responses`, `anthropic-messages`, `google-generative-ai`, or `google-vertex` in `~/.omp/agent/models.yml`. -- **Fallback chains** — Per-role chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown. +- **Fallback chains** — Per-role or per-model chains under `retry.fallbackChains`. When the primary throws 429s or hits a quota wall, the next entry takes the rest of the turn — restored on cooldown. - **Path-scoped models** — Scope `enabledModels` and `disabledProviders` entries to a `path:` prefix to pin a different model set on one repo without touching the global config. Scoped entries cover the path and everything under it. - **Round-robin credentials** — Stack API keys per provider and the runtime rotates with session affinity and per-credential backoff. Useful when one key would burn its quota by lunch. diff --git a/docs/settings.md b/docs/settings.md index 655c41ee7..e385b1c8d 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -385,6 +385,31 @@ retry: maxDelayMs: 300000 modelFallback: true fallbackRevertPolicy: cooldown-expiry + fallbackChains: + # Any role without an explicit chain inherits the "default" chain. + default: + - anthropic/claude-opus-4-5 + - openai/gpt-5.5 + - google/gemini-3-pro + # Per-role chains override the default (roles from `modelRoles`, + # including custom roles). Selectors accept an optional thinking + # suffix, e.g. openai/gpt-5.5:low. + smol: + - openai/gpt-5.5-mini + - anthropic/claude-haiku-4-5 + # Model-selector keys (any key containing "/") attach the chain to the + # model itself: it applies whenever that model is active, no matter + # which role it is assigned to, and survives role reassignment. + google/gemini-3-pro: + - google-vertex/gemini-3-pro + # A `provider/*` KEY covers every model of a provider — current or + # future. A `provider/*` ENTRY keeps the failing model's id and swaps + # the provider: google-antigravity/x -> google/x -> google-vertex/x. + # Ids missing on the target provider are skipped (near-miss ids resolve + # fuzzily); exact model keys override the wildcard for a specific model. + google-antigravity/*: + - google/* + - google-vertex/* ``` | Key | Type | Default | Notes | @@ -394,8 +419,10 @@ retry: | `retry.baseDelayMs` | number | `500` | Initial backoff. | | `retry.maxDelayMs` | number | `300000` | Backoff ceiling (5 min). | | `retry.modelFallback` | boolean | `true` | Fall back to another model when one is unavailable. | -| `retry.fallbackChains` | record | `{}` | Per-model fallback chains. | -| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry`, `never`. | +| `retry.fallbackChains` | record | `{}` | Maps roles, model selectors, or `provider/*` wildcards to ordered fallback selectors. Keys containing `/` are model-oriented and win over roles: `provider/model-id` matches that exact model, `provider/*` matches every model of the provider. A `provider/*` *entry* keeps the failing model's id and swaps the provider. The `default` chain covers every assigned role without its own chain. Unknown models/providers or malformed chains are reported as config warnings at startup. | +| `retry.fallbackRevertPolicy` | enum | `cooldown-expiry` | `cooldown-expiry` returns to the primary model once its suppression window ends; `never` stays on the fallback until switched manually. | + +When the active model keeps failing (429s, quota walls, provider outages) and `retry.modelFallback` is on, the session picks the chain that owns the failing model, by specificity: an exact `provider/model-id` key, then a `provider/*` wildcard, then the current role's chain, then `default`. It skips models whose selectors are still cooling down and switches for the rest of the turn. Subagents get their own per-spawn chains when their agent definition lists multiple model patterns — the first resolvable pattern is primary and the rest become its fallbacks; there is no `agent:` key in `fallbackChains`. ### Tools and approvals diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 3a0cf25a9..316de7262 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -1606,6 +1606,44 @@ describe("AuthStorage codex oauth ranking", () => { expect(apiKey).toBe("api-acct-paid"); }); + test("attempts every exhausted account for a paid-gated model until one passes the plan gate", async () => { + if (!authStorage) throw new Error("test setup failed"); + + // Production shape of the same regression: EVERY seat is usage-blocked + // (the free seat resets soonest, so it leads the blocked ordering) and + // only a later-resetting paid seat can serve gpt-5.6-sol. The blocked + // pass must keep iterating past the plan-ineligible free seat instead + // of giving up after the first blocked candidate. + await authStorage.set("openai-codex", [ + { type: "oauth", ...createCredential("acct-free", "free@example.com") }, + { type: "oauth", ...createCredential("acct-paid", "paid@example.com") }, + ]); + + usageByAccount.set( + "acct-free", + createCodexUsageReport({ + accountId: "acct-free", + primary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 }, + secondary: { usedFraction: 1, resetInMs: 5 * 60 * 1000 }, + metadata: { planType: "free", email: "free@example.com", limitReached: true }, + }), + ); + usageByAccount.set( + "acct-paid", + createCodexUsageReport({ + accountId: "acct-paid", + primary: { usedFraction: 1, resetInMs: 2 * HOUR_MS }, + secondary: { usedFraction: 1, resetInMs: 6 * 24 * 60 * 60 * 1000 }, + metadata: { planType: "plus", email: "paid@example.com", limitReached: true }, + }), + ); + + const apiKey = await authStorage.getApiKey("openai-codex", "session-sol-all-exhausted", { + modelId: "gpt-5.6-sol", + }); + expect(apiKey).toBe("api-acct-paid"); + }); + test("prefers Pro accounts for codex spark models over Plus accounts", async () => { if (!authStorage) throw new Error("test setup failed"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1bd6d9883..3f8816a97 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,16 +5,21 @@ ### Added - Added `invalidate` action to the usage command to clear cached usage reports +- Added model-oriented keys and wildcard entries to `retry.fallbackChains`: a `provider/model-id` key attaches a fallback chain to that exact model, a `provider/*` key covers every current or future model of a provider, and a `provider/*` chain entry keeps the failing model's id while swapping the provider (`google-antigravity/x` → `google/x`) — so fallbacks survive role and model reassignments without config edits. Keys resolve by specificity: exact model, then provider wildcard, then role, then `default`. +- Added fallback-chain editing to the /models Roles view: each role's `retry.fallbackChains` entries render as indented rows beneath it, `f` picks a fallback model to append, Enter on an entry replaces it, `x`/backspace removes it, and `[`/`]` (or shift+↑/↓) reorder the chain. +- Added model-keyed fallback management to the /models Roles view: model and `provider/*` chains render as a separate section below the roles (divider + "+ New fallback…" row for creating one by picking the protected model, then keying it by model or provider), with the same replace/remove/reorder editing as role chains; the model strip gains `fallbacks:` and `fallbacks:/*` chips as shortcuts. ### Changed - Retain completed and abandoned tasks in session history for improved context on resume +- Changed the Model Hub `retry-fallback` strip chip to append the model to the default fallback chain instead of prepending it, matching the chain-building order of the Roles view (already-registered models are a no-op). ### Fixed - Fixed compiled release binaries bundling `fastembed` and baking the build-machine `@anush008/tokenizers` path; native runtime dependencies now stay external for every compiled build path so Mnemopi resolves its on-demand install instead. ([#5195](https://github.com/can1357/oh-my-pi/issues/5195)) - Fixed `/btw` side-channel turns on Codex models such as `gpt-5.6-luna` by preserving the session websocket preference instead of forcing SSE, and made Esc dismiss the active `/btw` panel before interrupting loop/maintenance work. ([#5213](https://github.com/can1357/oh-my-pi/issues/5213)) - Fixed the Model Hub role-assignment strip hiding the selected chip once the row overflowed; the strip now scrolls horizontally, truncating passed chips behind a leading ellipsis so the selection (plus one chip of lookahead) stays visible. +- Fixed mouse hover and clicks in the /models Roles view landing one row above the pointer (the row mapping subtracted the status row twice). ## [16.4.5] - 2026-07-11 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c314ef56a..e07b14045 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1375,7 +1375,7 @@ export const SETTINGS_SCHEMA = { group: "Retry & Fallback", label: "Retry Fallback Chains", description: - 'JSON object mapping model roles to ordered fallback model selectors, e.g. {"default":["openai/gpt-4o-mini"]}.', + 'JSON object mapping model roles, model selectors ("provider/model-id"), or provider wildcards ("provider/*") to ordered fallback selectors, e.g. {"default":["openai/gpt-4o-mini"],"google-antigravity/*":["google/*","google-vertex/*"]}. Model-oriented keys apply whenever that model/provider is active, regardless of role; a "provider/*" entry keeps the failing model\'s id and swaps the provider.', }, }, "retry.fallbackRevertPolicy": { diff --git a/packages/coding-agent/src/modes/components/model-hub.ts b/packages/coding-agent/src/modes/components/model-hub.ts index ef28c2bb7..82fa5cb6b 100644 --- a/packages/coding-agent/src/modes/components/model-hub.ts +++ b/packages/coding-agent/src/modes/components/model-hub.ts @@ -52,7 +52,29 @@ import { renderSegmentTrack } from "./segment-track"; /** `roles` is the full /models hub; `pick` is a one-shot session/embedded picker. */ export type ModelHubMode = "roles" | "pick"; -export type ModelHubAction = "modelRole" | "retryFallback"; +/** + * A row of the Roles view: a role, a model/wildcard chain-key header, one of a + * chain's fallback entries, or the trailing "+ New role…". Fallback rows under + * a chain-key header carry the key in `role` — `retry.fallbackChains` treats + * roles, `provider/model-id`, and `provider/*` keys uniformly. + */ +type RolesRow = + | { kind: "role"; role: string } + | { kind: "chainKey"; role: string } + | { kind: "fallback"; role: string; chainIndex: number; selector: string } + | { kind: "separator" } + | { kind: "newFallback" } + | { kind: "newRole" }; + +/** + * What the model browser is currently picking for: a role's model, a slot in + * a fallback chain (`role` may be a role name, model selector, or `provider/*` + * key), or the primary model a brand-new fallback chain protects. + */ +type AssignTarget = + | { kind: "role"; role: string } + | { kind: "fallback"; role: string; index: number | null } + | { kind: "fallbackKey" }; /** A `--models` scope entry (mirrors the session's scoped model list). */ export interface ScopedModelItem { @@ -61,16 +83,12 @@ export interface ScopedModelItem { } export interface ModelHubCallbacks { - /** Persist a role assignment (or a retry-fallback registration). */ - onAssign: ( - model: Model, - role: string, - thinkingLevel: ConfiguredThinkingLevel | undefined, - selector: string, - action: ModelHubAction, - ) => void; + /** Persist a role assignment. */ + onAssign: (model: Model, role: string, thinkingLevel: ConfiguredThinkingLevel | undefined, selector: string) => void; /** Clear a configured role back to auto-selection. */ onUnassign: (role: string) => void; + /** Persist a `retry.fallbackChains` entry — keyed by a role, `provider/model-id`, or `provider/*`; an empty chain clears the key. */ + onFallbackChainChange?: (role: string, chain: string[]) => void; /** Pick-mode activation: session-only switch or embedded pick. */ onPick?: (model: Model, selector: string) => void; /** Locked provider activation: forward to the /login flow. */ @@ -108,7 +126,7 @@ interface StripChip { /** Pre-styled label body (without selection decoration). */ styled: string; role?: string; - action: "assign" | "unassign" | "fallback" | "thinking"; + action: "assign" | "unassign" | "fallback" | "fallbackModel" | "fallbackProvider" | "thinking"; thinkingLevel?: ConfiguredThinkingLevel; } @@ -192,11 +210,11 @@ export class ModelHubComponent implements Component { */ #focus: "scope" | "list" = "scope"; - #roleIds: string[] = []; + #rolesRows: RolesRow[] = []; #roleIndex = 0; #roleHover: number | null = null; - #assigningRole: string | null = null; + #assigning: AssignTarget | null = null; #strip: StripState | null = null; /** Per-provider fuzzy match counts while a query is active; null when not searching. */ #searchCounts: Map | null = null; @@ -215,7 +233,7 @@ export class ModelHubComponent implements Component { #footerRow = 0; #chipRanges: ChipRange[] = []; #lockedLoginLine: number | null = null; - #rolesRowStart = 2; + #rolesRowStart = 1; constructor( tui: TUI, @@ -373,6 +391,7 @@ export class ModelHubComponent implements Component { } this.#reloadRoles(availableModels); + this.#buildRolesRows(); const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; this.#availableItems = buildBrowserItems(availableModels); @@ -545,7 +564,6 @@ export class ModelHubComponent implements Component { break; } case "roles": - this.#roleIds = this.#visibleRoleIds(); this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRowCount - 1)); break; default: @@ -555,6 +573,58 @@ export class ModelHubComponent implements Component { } } + /** + * The configured `retry.fallbackChains` record with malformed keys/entries + * dropped: non-array chains and non-string selectors never reach the rows + * or chain editors, so an edit through the hub replaces them wholesale. + */ + #fallbackChains(): Record { + try { + const chains = this.#settings.get("retry.fallbackChains"); + if (!chains || typeof chains !== "object" || Array.isArray(chains)) return {}; + const sanitized: Record = {}; + for (const key in chains) { + const chain = (chains as Record)[key]; + if (!Array.isArray(chain)) continue; + sanitized[key] = chain.filter((entry): entry is string => typeof entry === "string"); + } + return sanitized; + } catch { + return {}; + } + } + + /** + * Rebuild the Roles view rows: each visible role followed by its + * fallback-chain entries, then model-oriented chains (`provider/model-id` + * and `provider/*` keys) as headed groups. + */ + #buildRolesRows(): void { + const rows: RolesRow[] = []; + const chains = this.#fallbackChains(); + for (const role of this.#visibleRoleIds()) { + rows.push({ kind: "role", role }); + const chain = chains[role] ?? []; + for (let i = 0; i < chain.length; i++) { + rows.push({ kind: "fallback", role, chainIndex: i, selector: chain[i] }); + } + } + rows.push({ kind: "newRole" }); + rows.push({ kind: "separator" }); + const modelKeys = Object.keys(chains) + .filter(key => key.includes("/")) + .sort(); + for (const key of modelKeys) { + const chain = chains[key] ?? []; + rows.push({ kind: "chainKey", role: key }); + for (let i = 0; i < chain.length; i++) { + rows.push({ kind: "fallback", role: key, chainIndex: i, selector: chain[i] }); + } + } + rows.push({ kind: "newFallback" }); + this.#rolesRows = rows; + } + /** Refresh roles + dependent state after a settings mutation (assign/unassign). */ #refreshAfterMutation(): void { this.#syncFromRegistryState(); @@ -588,7 +658,7 @@ export class ModelHubComponent implements Component { this.#composeEntries(); const entry = this.#activeEntry(); if ( - this.#assigningRole === null && + this.#assigning === null && entry.kind === "provider" && (entry.locked || (counts.get(entry.providerId ?? "") ?? 0) === 0) ) { @@ -739,10 +809,16 @@ export class ModelHubComponent implements Component { this.#callbacks.onPick?.(item.model, item.selector); return; } - if (this.#assigningRole) { - const role = this.#assigningRole; - this.#assigningRole = null; - this.#assignRole(item, role, true); + if (this.#assigning) { + const target = this.#assigning; + this.#assigning = null; + if (target.kind === "role") { + this.#assignRole(item, target.role, true); + } else if (target.kind === "fallbackKey") { + this.#openFallbackKeyStrip(item); + } else { + this.#commitFallback(item, target); + } return; } this.#openRoleStrip(item); @@ -756,7 +832,7 @@ export class ModelHubComponent implements Component { const supported = this.#thinkingOptionsFor(item.model); level = supported.includes(current.thinkingLevel) ? current.thinkingLevel : ThinkingLevel.Inherit; } - this.#callbacks.onAssign(item.model, role, level, item.selector, "modelRole"); + this.#callbacks.onAssign(item.model, role, level, item.selector); this.#refreshAfterMutation(); this.#openThinkingStrip(item, role, returnToRoles); } @@ -793,6 +869,16 @@ export class ModelHubComponent implements Component { action: assignedHere ? "unassign" : "assign", }); } + chips.push({ + label: `fallbacks:${item.model.id}`, + styled: theme.fg("muted", `fallbacks:${item.model.id}`), + action: "fallbackModel", + }); + chips.push({ + label: `fallbacks:${item.model.provider}/*`, + styled: theme.fg("muted", `fallbacks:${item.model.provider}/*`), + action: "fallbackProvider", + }); chips.push({ label: "fallback", styled: theme.fg("muted", "retry-fallback"), action: "fallback" }); this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false }; } @@ -851,18 +937,20 @@ export class ModelHubComponent implements Component { this.#closeStrip(); return; case "fallback": - this.#callbacks.onAssign(strip.item.model, "default", undefined, strip.item.selector, "retryFallback"); + this.#appendFallback(strip.item, "default"); this.#closeStrip(); return; + case "fallbackModel": + this.#closeStrip(); + this.#startAssignFallback(strip.item.selector, null); + return; + case "fallbackProvider": + this.#closeStrip(); + this.#startAssignFallback(`${strip.item.model.provider}/*`, null); + return; case "thinking": if (strip.role && chip.thinkingLevel !== undefined) { - this.#callbacks.onAssign( - strip.item.model, - strip.role, - chip.thinkingLevel, - strip.item.selector, - "modelRole", - ); + this.#callbacks.onAssign(strip.item.model, strip.role, chip.thinkingLevel, strip.item.selector); this.#refreshAfterMutation(); } this.#closeStrip(); @@ -872,7 +960,7 @@ export class ModelHubComponent implements Component { /** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */ #startAssign(role: string): void { - this.#assigningRole = role; + this.#assigning = { kind: "role", role }; this.#focus = "scope"; this.#browser.setShowProvider(true); this.#browser.setItems([...this.#availableItems]); @@ -883,8 +971,104 @@ export class ModelHubComponent implements Component { } } + /** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */ + #startAssignFallback(role: string, index: number | null): void { + this.#assigning = { kind: "fallback", role, index }; + this.#focus = "scope"; + this.#browser.setShowProvider(true); + this.#browser.setItems([...this.#availableItems]); + this.#browser.setQuery(""); + if (index !== null) { + const selector = this.#fallbackChains()[role]?.[index]; + if (selector) this.#browser.selectSelector(selector); + } + } + + /** Browse the catalog for the primary model a brand-new fallback chain protects. */ + #startAssignFallbackKey(): void { + this.#assigning = { kind: "fallbackKey" }; + this.#focus = "scope"; + this.#browser.setShowProvider(true); + this.#browser.setItems([...this.#availableItems]); + this.#browser.setQuery(""); + } + + /** Second step of "+ New fallback…": key the chain by the picked model or its whole provider. */ + #openFallbackKeyStrip(item: ModelBrowserItem): void { + const chips: StripChip[] = [ + { + label: `for ${item.selector}`, + styled: theme.fg("muted", `for ${item.selector}`), + action: "fallbackModel", + }, + { + label: `for ${item.model.provider}/*`, + styled: theme.fg("muted", `for ${item.model.provider}/*`), + action: "fallbackProvider", + }, + ]; + this.#strip = { kind: "role", item, chips, index: 0, returnToRoles: false }; + } + + /** Write the picked model into the target chain slot, dedupe, and land back on its Roles row. */ + #commitFallback(item: ModelBrowserItem, target: { role: string; index: number | null }): void { + const chain = [...(this.#fallbackChains()[target.role] ?? [])]; + const selector = item.selector; + if (target.index !== null && target.index < chain.length) { + chain[target.index] = selector; + for (let i = chain.length - 1; i >= 0; i--) { + if (i !== target.index && chain[i] === selector) chain.splice(i, 1); + } + } else if (!chain.includes(selector)) { + chain.push(selector); + } + this.#setFallbackChain(target.role, chain); + this.#browser.setQuery(""); + if (this.#mode === "roles") { + this.#setActiveEntry("roles"); + this.#focus = "list"; + const rowIndex = this.#rolesRows.findIndex( + row => row.kind === "fallback" && row.role === target.role && row.selector === selector, + ); + if (rowIndex >= 0) this.#roleIndex = rowIndex; + } + } + + /** Persist `role`'s chain through the host callback and rebuild dependent state. */ + #setFallbackChain(role: string, chain: string[]): void { + this.#callbacks.onFallbackChainChange?.(role, chain); + this.#refreshAfterMutation(); + } + + /** Append `item` to `role`'s fallback chain (no-op when already present). */ + #appendFallback(item: ModelBrowserItem, role: string): void { + const chain = [...(this.#fallbackChains()[role] ?? [])]; + if (chain.includes(item.selector)) return; + chain.push(item.selector); + this.#setFallbackChain(role, chain); + } + + /** Remove one chain entry; the cursor stays on the nearest surviving row. */ + #removeFallback(row: { role: string; chainIndex: number }): void { + const chain = [...(this.#fallbackChains()[row.role] ?? [])]; + if (row.chainIndex >= chain.length) return; + chain.splice(row.chainIndex, 1); + this.#setFallbackChain(row.role, chain); + this.#roleIndex = Math.min(this.#roleIndex, Math.max(0, this.#rolesRows.length - 1)); + } + + /** Move a chain entry one slot earlier/later; the cursor follows the moved entry. */ + #moveFallback(row: { role: string; chainIndex: number }, delta: -1 | 1): void { + const chain = [...(this.#fallbackChains()[row.role] ?? [])]; + const target = row.chainIndex + delta; + if (row.chainIndex >= chain.length || target < 0 || target >= chain.length) return; + [chain[row.chainIndex], chain[target]] = [chain[target], chain[row.chainIndex]]; + this.#setFallbackChain(row.role, chain); + this.#roleIndex += delta; + } + #cancelAssign(): void { - this.#assigningRole = null; + this.#assigning = null; this.#browser.setQuery(""); if (this.#mode === "roles") { this.#setActiveEntry("roles"); @@ -961,7 +1145,7 @@ export class ModelHubComponent implements Component { } if (matchesSelectCancel(data)) { - if (this.#assigningRole !== null) { + if (this.#assigning !== null) { this.#cancelAssign(); return; } @@ -975,8 +1159,8 @@ export class ModelHubComponent implements Component { } const entry = this.#activeEntry(); - const rolesView = entry.kind === "roles" && this.#assigningRole === null; - const lockedView = entry.kind === "provider" && entry.locked && this.#assigningRole === null; + const rolesView = entry.kind === "roles" && this.#assigning === null; + const lockedView = entry.kind === "provider" && entry.locked && this.#assigning === null; if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { this.#focus = this.#focus === "scope" ? "list" : "scope"; @@ -1030,7 +1214,7 @@ export class ModelHubComponent implements Component { } #isBrowserView(entry: SidebarEntry): boolean { - if (this.#assigningRole !== null) return true; + if (this.#assigning !== null) return true; return entry.kind === "recent" || entry.kind === "all" || (entry.kind === "provider" && !entry.locked); } @@ -1074,16 +1258,50 @@ export class ModelHubComponent implements Component { if (entry && !this.#isHopSkipped(entry)) { // Scope changes keep an active assignment (scoping helps find the // model); landing on the Roles view cancels it. - if (entry.kind === "roles") this.#assigningRole = null; + if (entry.kind === "roles") this.#assigning = null; this.#setActiveEntry(entry.id); return; } } } - /** Row count of the roles view: every visible role plus the trailing "+ New role…" row. */ + /** Row count of the roles view (roles, their fallback entries, and the trailing "+ New role…" row). */ get #rolesRowCount(): number { - return this.#roleIds.length + 1; + return this.#rolesRows.length; + } + + /** Enter/click activation for a Roles-view row. */ + #activateRolesRow(row: RolesRow): void { + switch (row.kind) { + case "role": + this.#startAssign(row.role); + return; + case "chainKey": + this.#startAssignFallback(row.role, null); + return; + case "fallback": + this.#startAssignFallback(row.role, row.chainIndex); + return; + case "newFallback": + this.#startAssignFallbackKey(); + return; + case "newRole": + this.#openRoleNameStrip(); + return; + case "separator": + return; + } + } + + /** Step the roles cursor by one row, wrapping and skipping separator rows. */ + #stepRoleIndex(from: number, delta: -1 | 1): number { + const count = Math.max(1, this.#rolesRows.length); + let index = from; + for (let i = 0; i < count; i++) { + index = (index + delta + count) % count; + if (this.#rolesRows[index]?.kind !== "separator") break; + } + return index; } #handleRolesViewInput(data: string): void { @@ -1095,41 +1313,50 @@ export class ModelHubComponent implements Component { } return; } - const rowCount = Math.max(1, this.#rolesRowCount); if (matchesSelectUp(data)) { - this.#roleIndex = (this.#roleIndex - 1 + rowCount) % rowCount; + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, -1); return; } if (matchesSelectDown(data)) { - this.#roleIndex = (this.#roleIndex + 1) % rowCount; + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, 1); return; } - const role = this.#roleIds[this.#roleIndex]; + const row = this.#rolesRows[this.#roleIndex]; + const role = row?.kind === "role" ? row.role : undefined; if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - if (role) { - this.#startAssign(role); - } else { - // The virtual "+ New role…" row. - this.#openRoleNameStrip(); - } + if (row) this.#activateRolesRow(row); return; } if (matchesKey(data, "backspace") || matchesKey(data, "delete")) { if (role) this.#unassignRole(role); + else if (row?.kind === "fallback") this.#removeFallback(row); + else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []); return; } - // Cycle reordering: [ / shift+↑ moves the role earlier, ] / shift+↓ later. + // Reordering: [ / shift+↑ moves the row earlier, ] / shift+↓ later — + // cycle order on a role row, chain order on a fallback row. if (matchesKey(data, "shift+up")) { if (role) this.#moveCycleMembership(role, -1); + else if (row?.kind === "fallback") this.#moveFallback(row, -1); return; } if (matchesKey(data, "shift+down")) { if (role) this.#moveCycleMembership(role, 1); + else if (row?.kind === "fallback") this.#moveFallback(row, 1); return; } const printable = extractPrintableText(data); if (printable === "x") { if (role) this.#unassignRole(role); + else if (row?.kind === "fallback") this.#removeFallback(row); + else if (row?.kind === "chainKey") this.#setFallbackChain(row.role, []); + return; + } + if (printable === "f") { + if (row?.kind === "newFallback") this.#startAssignFallbackKey(); + else if (row && row.kind !== "newRole" && row.kind !== "separator") { + this.#startAssignFallback(row.role, null); + } return; } if (printable === "c") { @@ -1138,10 +1365,12 @@ export class ModelHubComponent implements Component { } if (printable === "[") { if (role) this.#moveCycleMembership(role, -1); + else if (row?.kind === "fallback") this.#moveFallback(row, -1); return; } if (printable === "]") { if (role) this.#moveCycleMembership(role, 1); + else if (row?.kind === "fallback") this.#moveFallback(row, 1); return; } if (printable === "n") { @@ -1204,9 +1433,8 @@ export class ModelHubComponent implements Component { if (overSidebar) { this.#moveSidebar(event.wheel); } else if (overBody) { - if (entry.kind === "roles" && this.#assigningRole === null) { - const count = Math.max(1, this.#rolesRowCount); - this.#roleIndex = (this.#roleIndex + event.wheel + count) % count; + if (entry.kind === "roles" && this.#assigning === null) { + this.#roleIndex = this.#stepRoleIndex(this.#roleIndex, event.wheel > 0 ? 1 : -1); } else if (this.#isBrowserView(entry)) { this.#browser.routeMouse(event, bodyLine); } @@ -1216,7 +1444,7 @@ export class ModelHubComponent implements Component { if (event.motion) { this.#sidebarHover = overSidebar ? this.#sidebarEntryIndexAt(contentLine) : null; - if (overBody && entry.kind === "roles" && this.#assigningRole === null) { + if (overBody && entry.kind === "roles" && this.#assigning === null) { const roleLine = bodyLine - this.#rolesRowStart; this.#roleHover = roleLine >= 0 && roleLine < this.#rolesRowCount ? roleLine : null; } else { @@ -1235,7 +1463,7 @@ export class ModelHubComponent implements Component { const clicked = index !== null ? this.#entries[index] : undefined; if (clicked && clicked.kind !== "separator") { const already = clicked.id === this.#activeEntryId; - if (clicked.kind === "roles") this.#assigningRole = null; + if (clicked.kind === "roles") this.#assigning = null; this.#setActiveEntry(clicked.id); // A click on Roles is a deliberate dive into the rows. if (clicked.kind === "roles") this.#focus = "list"; @@ -1247,22 +1475,20 @@ export class ModelHubComponent implements Component { } if (overBody) { - if (entry.kind === "roles" && this.#assigningRole === null) { + if (entry.kind === "roles" && this.#assigning === null) { this.#focus = "list"; const roleLine = bodyLine - this.#rolesRowStart; if (roleLine >= 0 && roleLine < this.#rolesRowCount) { - if (roleLine === this.#roleIndex) { - const role = this.#roleIds[roleLine]; - if (role) { - this.#startAssign(role); + const rowDef = this.#rolesRows[roleLine]; + if (rowDef && rowDef.kind !== "separator") { + if (roleLine === this.#roleIndex) { + this.#activateRolesRow(rowDef); } else { - this.#openRoleNameStrip(); + this.#roleIndex = roleLine; } - } else { - this.#roleIndex = roleLine; } } - } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) { + } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) { if (this.#lockedLoginLine !== null && bodyLine === this.#lockedLoginLine) { this.#requestLogin(entry); } @@ -1374,9 +1600,22 @@ export class ModelHubComponent implements Component { } #statusRow(width: number): string { - if (this.#assigningRole !== null) { - const info = getRoleInfo(this.#assigningRole, this.#settings); - const label = info.tag ?? info.name ?? this.#assigningRole; + if (this.#assigning !== null) { + if (this.#assigning.kind === "fallbackKey") { + return truncateToWidth( + theme.fg("accent", " New fallback chain — Enter picks the model it protects, Esc cancels"), + width, + ); + } + const info = getRoleInfo(this.#assigning.role, this.#settings); + const label = info.tag ?? info.name ?? this.#assigning.role; + if (this.#assigning.kind === "fallback") { + const verb = this.#assigning.index === null ? "Adding fallback for" : "Replacing fallback of"; + return truncateToWidth( + theme.fg("accent", ` ${verb} ${theme.bold(label)} — Enter picks the fallback model, Esc cancels`), + width, + ); + } return truncateToWidth( theme.fg("accent", ` Assigning ${theme.bold(label)} — Enter assigns, Esc cancels`), width, @@ -1390,7 +1629,7 @@ export class ModelHubComponent implements Component { text = this.#mode === "pick" ? this.#pickerHint : `Recently used models${scopedSuffix}`; break; case "roles": - text = "Model roles — assignments fall back to auto-selection when cleared"; + text = "Model roles — f adds a retry fallback, cleared roles fall back to auto-selection"; break; case "provider": if (entry.locked) { @@ -1415,22 +1654,71 @@ export class ModelHubComponent implements Component { #renderRolesView(width: number, rows: number): string[] { const lines: string[] = []; lines.push(""); - this.#rolesRowStart = lines.length + 1; // +1 for the status row offset handled by caller + // First row's offset in bodyLine coordinates: the mouse router's + // `bodyLine` has already dropped the status row, so this is just the + // leading blank line — no extra status-row offset here. + this.#rolesRowStart = lines.length; let tagWidth = 0; - for (const role of this.#roleIds) { - const info = getRoleInfo(role, this.#settings); - tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? role)); + for (const rowDef of this.#rolesRows) { + if (rowDef.kind !== "role") continue; + const info = getRoleInfo(rowDef.role, this.#settings); + tagWidth = Math.max(tagWidth, visibleWidth(info.tag ?? info.name ?? rowDef.role)); } const cycleOrder = this.#cycleOrder(); - for (let i = 0; i < this.#roleIds.length && lines.length < rows - 3; i++) { - const role = this.#roleIds[i]; - const info = getRoleInfo(role, this.#settings); - const assignment = this.#roles[role]; + for (let i = 0; i < this.#rolesRows.length && lines.length < rows - 2; i++) { + const rowDef = this.#rolesRows[i]; + if (!rowDef) continue; const selected = i === this.#roleIndex; const hovered = i === this.#roleHover; const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; + + if (rowDef.kind === "separator") { + lines.push(` ${theme.fg("border", "─".repeat(Math.max(1, width - 6)))}`); + continue; + } + + if (rowDef.kind === "newRole" || rowDef.kind === "newFallback") { + const label = rowDef.kind === "newRole" ? "+ New role…" : "+ New fallback…"; + let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", label)}`; + line = truncateToWidth(line, width); + if (hovered && !selected) { + line = theme.bg("selectedBg", line); + } + lines.push(line); + continue; + } + + if (rowDef.kind === "chainKey") { + const key = rowDef.role; + const slash = key.lastIndexOf("/"); + const tail = key.slice(slash + 1); + const keyStyled = theme.fg("dim", key.slice(0, slash + 1)) + (selected ? theme.fg("accent", tail) : tail); + let line = ` ${cursor} ${theme.fg("dim", theme.status.shadowed)} ${keyStyled}`; + line = truncateToWidth(line, width); + if (hovered && !selected) { + line = theme.bg("selectedBg", line); + } + lines.push(line); + continue; + } + + if (rowDef.kind === "fallback") { + const branch = theme.fg("dim", `${"".padEnd(tagWidth + 3)}↳`); + const selector = selected ? theme.fg("accent", rowDef.selector) : theme.fg("muted", rowDef.selector); + let line = ` ${cursor} ${branch} ${selector}`; + line = truncateToWidth(line, width); + if (hovered && !selected) { + line = theme.bg("selectedBg", line); + } + lines.push(line); + continue; + } + + const role = rowDef.role; + const info = getRoleInfo(role, this.#settings); + const assignment = this.#roles[role]; const tag = (info.tag ?? info.name ?? role).padEnd(tagWidth); let dot: string; @@ -1475,27 +1763,16 @@ export class ModelHubComponent implements Component { lines.push(line); } - // Trailing virtual row: create a custom role. - if (lines.length < rows - 2) { - const newRoleIndex = this.#roleIds.length; - const selected = this.#roleIndex === newRoleIndex; - const hovered = this.#roleHover === newRoleIndex; - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New role…")}`; - line = truncateToWidth(line, width); - if (hovered && !selected) { - line = theme.bg("selectedBg", line); - } - lines.push(line); - } - // Live preview of the quick-switch cycle, rendered with the exact // segment track the ctrl+p status uses; the selected role's chip fills. while (lines.length < rows - 1) lines.push(""); if (rows >= 2) { const cycleKey = getKeybindings().getKeys("app.model.cycleForward")[0] ?? "ctrl+p"; if (cycleOrder.length > 0) { - const activeIndex = cycleOrder.indexOf(this.#roleIds[this.#roleIndex] ?? ""); + const selectedRow = this.#rolesRows[this.#roleIndex]; + const selectedRole = + selectedRow && (selectedRow.kind === "role" || selectedRow.kind === "fallback") ? selectedRow.role : ""; + const activeIndex = cycleOrder.indexOf(selectedRole); const track = renderSegmentTrack( cycleOrder.map(role => ({ label: role })), activeIndex, @@ -1557,14 +1834,32 @@ export class ModelHubComponent implements Component { ? "←/→ choose · Enter assign/clear · Esc cancel" : "←/→ thinking level · Enter apply · Esc keep"; } - if (this.#assigningRole !== null) { - return "Enter assign · ↑/↓ providers · type to search · Esc cancel"; + if (this.#assigning !== null) { + switch (this.#assigning.kind) { + case "fallback": + return "Enter pick fallback · ↑/↓ providers · type to search · Esc cancel"; + case "fallbackKey": + return "Enter pick the protected model · ↑/↓ providers · type to search · Esc cancel"; + default: + return "Enter assign · ↑/↓ providers · type to search · Esc cancel"; + } } const entry = this.#activeEntry(); if (entry.kind === "roles") { - return this.#focus === "list" - ? "↑/↓ roles · Enter pick · x clear · t thinking · c cycle · [/] reorder · n new · ← providers" - : "↑/↓ providers · → roles · Esc close"; + if (this.#focus !== "list") { + return "↑/↓ providers · → roles · Esc close"; + } + const row = this.#rolesRows[this.#roleIndex]; + if (row?.kind === "fallback") { + return "↑/↓ rows · Enter replace · f add another · x remove · [/] reorder · ← providers"; + } + if (row?.kind === "chainKey") { + return "↑/↓ rows · Enter/f add fallback · x clear chain · ← providers"; + } + if (row?.kind === "newFallback") { + return "↑/↓ rows · Enter new model/provider fallback chain · ← providers"; + } + return "↑/↓ rows · Enter pick · f fallback · x clear · t thinking · c cycle · [/] reorder · n new"; } if (entry.kind === "provider" && entry.locked) { return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close"; @@ -1656,9 +1951,9 @@ export class ModelHubComponent implements Component { const entry = this.#activeEntry(); const bodyLines: string[] = [this.#statusRow(bodyWidth)]; - if (entry.kind === "roles" && this.#assigningRole === null) { + if (entry.kind === "roles" && this.#assigning === null) { bodyLines.push(...this.#renderRolesView(bodyWidth, contentRows - 1)); - } else if (entry.kind === "provider" && entry.locked && this.#assigningRole === null) { + } else if (entry.kind === "provider" && entry.locked && this.#assigning === null) { bodyLines.push(...this.#renderLockedView(entry, bodyWidth, contentRows - 1)); } else { this.#browser.setMaxVisible(contentRows - 1 - 5); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 82a2ce425..e101bbc5f 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -616,7 +616,7 @@ export class SelectorController { this.ctx.session.modelRegistry, this.ctx.session.scopedModels, { - onAssign: async (model, role, thinkingLevel, selector, action) => { + onAssign: async (model, role, thinkingLevel, selector) => { // `auto` is session-global: never baked into a per-role model value // (it can't round-trip through `model:`). Apply it to the session // separately and persist via `defaultThinkingLevel`. @@ -624,18 +624,6 @@ export class SelectorController { const concreteThinking = isAuto || thinkingLevel === undefined ? undefined : thinkingLevel; const selectorValue = selector ?? `${model.provider}/${model.id}`; try { - if (action === "retryFallback") { - const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking); - const fallbackChains = this.ctx.settings.get("retry.fallbackChains"); - const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : []; - this.ctx.settings.set("retry.fallbackChains", { - ...fallbackChains, - [role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)], - }); - const roleInfo = getRoleInfo(role, settings); - this.ctx.showStatus(`${roleInfo?.name ?? role} fallback model: ${fallbackSelector}`); - return; - } if (role === "default") { const { switched } = await this.ctx.session.setModel(model, role, { selector, @@ -679,6 +667,25 @@ export class SelectorController { this.ctx.showError(error instanceof Error ? error.message : String(error)); } }, + onFallbackChainChange: (role, chain) => { + try { + const chains = { ...this.ctx.settings.get("retry.fallbackChains") }; + if (chain.length === 0) { + delete chains[role]; + } else { + chains[role] = chain; + } + this.ctx.settings.set("retry.fallbackChains", chains); + const roleInfo = getRoleInfo(role, settings); + this.ctx.showStatus( + chain.length > 0 + ? `${roleInfo?.name ?? role} fallbacks: ${chain.join(" → ")}` + : `${roleInfo?.name ?? role} fallbacks cleared`, + ); + } catch (error) { + this.ctx.showError(error instanceof Error ? error.message : String(error)); + } + }, onPick: async (model, selector) => { try { // Session-only: update agent state but don't persist the model to settings. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index bd704627a..cb26281e3 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1060,6 +1060,7 @@ export interface FreshSessionResult { /** Standard thinking levels */ +/** `retry.fallbackChains` config: chain key (role name or model selector) → ordered fallback selectors. */ type RetryFallbackChains = Record; type RetryFallbackRevertPolicy = "never" | "cooldown-expiry"; @@ -1072,6 +1073,7 @@ interface RetryFallbackSelector { } interface ActiveRetryFallbackState { + /** Chain key that produced this fallback: a model-role name or a model-selector key. */ role: string; originalSelector: string; originalThinkingLevel: ConfiguredThinkingLevel | undefined; @@ -1099,6 +1101,24 @@ function parseRetryFallbackSelector( }; } +/** + * `retry.fallbackChains` keys are either model-role names (`smol`, `default`) + * or model selectors (`provider/model-id[:thinking]`). Role names never + * contain a slash, so its presence marks a model-keyed chain whose primary is + * the key itself — the chain follows the model across role reassignments. + */ +function isRetryFallbackModelKey(key: string): boolean { + return key.includes("/"); +} + +/** + * A `provider/*` fallback-chain key: matches any active model of that provider, + * so one entry covers every current and future model behind the provider. + */ +function isRetryFallbackWildcardKey(key: string): boolean { + return key.endsWith("/*"); +} + function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string { return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel); } @@ -13475,36 +13495,68 @@ export class AgentSession { const configuredChains = this.settings.get("retry.fallbackChains"); if (configuredChains === undefined) return; if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) { - const msg = "retry.fallbackChains must be a mapping of role names to selector arrays."; + const msg = "retry.fallbackChains must be a mapping of role names or model selectors to selector arrays."; logger.warn(msg); this.configWarnings.push(msg); return; } - for (const [role, chain] of Object.entries(configuredChains)) { + for (const key in configuredChains) { + const chain = (configuredChains as RetryFallbackChains)[key]; + const keyKind = isRetryFallbackModelKey(key) ? "model" : "role"; + if (keyKind === "model") { + if (isRetryFallbackWildcardKey(key)) { + const provider = key.slice(0, -2); + if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) { + const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`; + logger.warn(msg); + this.configWarnings.push(msg); + } + } else { + const parsedKey = parseRetryFallbackSelector(key, this.#modelRegistry); + if (!parsedKey) { + const msg = `Invalid model selector key in retry.fallbackChains: ${key}`; + logger.warn(msg); + this.configWarnings.push(msg); + } else if (!this.#modelRegistry.find(parsedKey.provider, parsedKey.id)) { + const msg = `retry.fallbackChains key references unknown model: ${key}`; + logger.warn(msg); + this.configWarnings.push(msg); + } + } + } if (!Array.isArray(chain)) { - const msg = `Fallback chain for role '${role}' must be an array of selector strings.`; + const msg = `Fallback chain for ${keyKind} '${key}' must be an array of selector strings.`; logger.warn(msg); this.configWarnings.push(msg); continue; } for (const selectorStr of chain) { if (typeof selectorStr !== "string") { - const msg = `Fallback chain for role '${role}' contains a non-string selector.`; + const msg = `Fallback chain for ${keyKind} '${key}' contains a non-string selector.`; logger.warn(msg); this.configWarnings.push(msg); continue; } + if (isRetryFallbackWildcardKey(selectorStr)) { + const provider = selectorStr.slice(0, -2); + if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) { + const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`; + logger.warn(msg); + this.configWarnings.push(msg); + } + continue; + } const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry); if (!parsed) { - const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`; + const msg = `Invalid fallback selector format in ${keyKind} '${key}': ${selectorStr}`; logger.warn(msg); this.configWarnings.push(msg); continue; } const exists = this.#modelRegistry.find(parsed.provider, parsed.id); if (!exists) { - const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`; + const msg = `Fallback chain for ${keyKind} '${key}' references unknown model: ${selectorStr}`; logger.warn(msg); this.configWarnings.push(msg); } @@ -13517,6 +13569,8 @@ export class AgentSession { } #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined { + if (isRetryFallbackWildcardKey(role)) return undefined; + if (isRetryFallbackModelKey(role)) return parseRetryFallbackSelector(role, this.#modelRegistry); const configuredSelector = this.settings.getModelRole(role); return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined; } @@ -13538,6 +13592,13 @@ export class AgentSession { this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs); } + /** + * Map the failing model selector to the chain key that owns it, by + * specificity: an exact model-selector key, then a `provider/*` wildcard, + * then a model role whose current assignment matches, then `default`. + * Model-oriented keys win over roles so a chain follows the model across + * role reassignments. + */ #resolveRetryFallbackRole(currentSelector: string): string | undefined { const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); if (!parsedCurrent) return undefined; @@ -13551,18 +13612,33 @@ export class AgentSession { ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent) : undefined; - for (const role of Object.keys(chains)) { - const primarySelector = this.#getRetryFallbackPrimarySelector(role); - if (primarySelector?.raw === currentSelector) return role; + const exactModelKeys: string[] = []; + const roleKeys: string[] = []; + for (const key in chains) { + if (!isRetryFallbackModelKey(key)) roleKeys.push(key); + else if (!isRetryFallbackWildcardKey(key)) exactModelKeys.push(key); } - for (const role of Object.keys(chains)) { - const primarySelector = this.#getRetryFallbackPrimarySelector(role); - if (!primarySelector) continue; - if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role; - const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector); - if (primaryBaseSelector === currentBaseSelector) return role; - if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role; + const matchesCurrent = (primary: RetryFallbackSelector | undefined): boolean => { + if (!primary) return false; + if (primary.raw === currentSelector || (currentPlainSelector && primary.raw === currentPlainSelector)) { + return true; + } + const base = formatRetryFallbackBaseSelector(primary); + return base === currentBaseSelector || (!!currentPlainBaseSelector && base === currentPlainBaseSelector); + }; + + // 1. Exact model-selector keys — most specific. + for (const key of exactModelKeys) { + if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key; } + // 2. Provider wildcard (`provider/*`) — any active model of this provider. + const wildcardKey = `${parsedCurrent.provider}/*`; + if (Array.isArray(chains[wildcardKey])) return wildcardKey; + // 3. Role keys — matched by the role's currently-assigned model. + for (const key of roleKeys) { + if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key; + } + // 4. The default chain, when default has no explicit role primary. const defaultChain = chains.default; if ( Array.isArray(defaultChain) && @@ -13574,13 +13650,45 @@ export class AgentSession { return undefined; } - #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] { - const primarySelector = this.#getRetryFallbackPrimarySelector(role); - if (!primarySelector) return []; - const chain = [primarySelector]; - const seen = new Set([primarySelector.raw]); + /** + * Parse one configured chain entry. A `provider/*` entry keeps the failing + * model's id and swaps the provider (google-antigravity/x → google/x); + * ids the target provider lacks are skipped by the candidate loop's + * registry lookup. + */ + #parseRetryFallbackChainEntry( + entry: string, + current: RetryFallbackSelector | undefined, + ): RetryFallbackSelector | undefined { + if (isRetryFallbackWildcardKey(entry)) { + if (!current) return undefined; + const provider = entry.slice(0, -2); + return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined }; + } + return parseRetryFallbackSelector(entry, this.#modelRegistry); + } + + #getRetryFallbackEffectiveChain(role: string, currentSelector?: string): RetryFallbackSelector[] { + const parsedCurrent = currentSelector + ? parseRetryFallbackSelector(currentSelector, this.#modelRegistry) + : undefined; + const seen = new Set(); + const chain: RetryFallbackSelector[] = []; + if (isRetryFallbackWildcardKey(role)) { + // A wildcard key has no fixed primary: the active model is the + // primary, followed by the configured provider-level fallbacks. + if (parsedCurrent) { + chain.push(parsedCurrent); + seen.add(parsedCurrent.raw); + } + } else { + const primarySelector = this.#getRetryFallbackPrimarySelector(role); + if (!primarySelector) return []; + chain.push(primarySelector); + seen.add(primarySelector.raw); + } for (const selector of this.#getRetryFallbackChains()[role] ?? []) { - const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry); + const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); @@ -13589,7 +13697,7 @@ export class AgentSession { } #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] { - let chain = this.#getRetryFallbackEffectiveChain(role); + let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector); const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry); if (chain.length === 0 && role === "default" && parsedCurrent) { const chains = this.#getRetryFallbackChains(); @@ -13602,7 +13710,7 @@ export class AgentSession { const seen = new Set([parsedCurrent.raw]); chain = [parsedCurrent]; for (const selector of defaultChain) { - const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry); + const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent); if (!parsed || seen.has(parsed.raw)) continue; seen.add(parsed.raw); chain.push(parsed); diff --git a/packages/coding-agent/test/agent-session-retry-fallback.test.ts b/packages/coding-agent/test/agent-session-retry-fallback.test.ts index 25c8c6791..92b5d0d27 100644 --- a/packages/coding-agent/test/agent-session-retry-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-retry-fallback.test.ts @@ -219,6 +219,215 @@ describe("AgentSession retry fallback", () => { ]); }); + it("activates a model-keyed fallback chain without any role assignment", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + [`${primaryModel.provider}/${primaryModel.id}`]: [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Recover via model-keyed chain"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe(fallbackModel.provider); + expect(session.model?.id).toBe(fallbackModel.id); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + role: `${primaryModel.provider}/${primaryModel.id}`, + }, + ]); + }); + + it("prefers a model-keyed chain over the matching role chain", async () => { + const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + const modelKeyFallback = getBundledModel("openai", "gpt-4o-mini"); + const roleChainFallback = getBundledModel("openai", "gpt-4o"); + if (!primaryModel || !modelKeyFallback || !roleChainFallback) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + default: [`${roleChainFallback.provider}/${roleChainFallback.id}`], + [`${primaryModel.provider}/${primaryModel.id}`]: [`${modelKeyFallback.provider}/${modelKeyFallback.id}`], + }, + }); + settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Model-keyed chain wins"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${modelKeyFallback.provider}/${modelKeyFallback.id}`, + ]); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${modelKeyFallback.provider}/${modelKeyFallback.id}`, + role: `${primaryModel.provider}/${primaryModel.id}`, + }, + ]); + }); + + it("applies a provider-wildcard chain to any model of that provider", async () => { + const primaryModel = getBundledModel("anthropic", "claude-opus-4-1"); + const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + // No exact key for this model and no role assignment: only the + // `anthropic/*` wildcard can match, proving provider-level coverage. + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + "anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Recover via provider wildcard"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe(fallbackModel.provider); + expect(session.model?.id).toBe(fallbackModel.id); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `${fallbackModel.provider}/${fallbackModel.id}`, + role: "anthropic/*", + }, + ]); + }); + + it("substitutes the failing model id into provider-wildcard chain entries", async () => { + const primaryModel = getBundledModel("google", "gemini-2.5-flash"); + const fallbackModel = getBundledModel("google-vertex", "gemini-2.5-flash"); + if (!primaryModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const requestedModels: string[] = []; + const fallbackAppliedEvents: Array> = []; + const agent = createFallbackAgent(primaryModel, requestedModels); + + // `google-vertex/*` is not a fixed target: it must adopt the failing + // model's id (google/gemini-2.5-flash -> google-vertex/gemini-2.5-flash). + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.maxRetries": 1, + "retry.fallbackChains": { + "google/*": ["google-vertex/*"], + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + session.subscribe(event => { + if (event.type === "retry_fallback_applied") { + fallbackAppliedEvents.push(event); + } + }); + + await session.prompt("Recover via id-preserving wildcard entry"); + await session.waitForIdle(); + + expect(requestedModels).toEqual([ + `${primaryModel.provider}/${primaryModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + expect(session.model?.provider).toBe("google-vertex"); + expect(session.model?.id).toBe(primaryModel.id); + expect(fallbackAppliedEvents).toEqual([ + { + type: "retry_fallback_applied", + from: `${primaryModel.provider}/${primaryModel.id}`, + to: `google-vertex/${primaryModel.id}`, + role: "google/*", + }, + ]); + }); + it("uses the active initial model as the default fallback primary when other role fallback chains are configured", async () => { const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const fallbackModel = getBundledModel("openai", "gpt-4o-mini"); @@ -1596,6 +1805,39 @@ describe("AgentSession retry fallback", () => { ); }); + it("warns on unknown or malformed model-selector chain keys at startup", () => { + const primaryModel = getBundledModel("openai", "gpt-4o-mini"); + if (!primaryModel) { + throw new Error("Expected bundled OpenAI test model to exist"); + } + const settings = Settings.isolated({ + "compaction.enabled": false, + "retry.fallbackChains": { + "nonexistent-provider/nonexistent-model": [`${primaryModel.provider}/${primaryModel.id}`], + [`${primaryModel.provider}/${primaryModel.id}`]: ["openai/gpt-4o"], + }, + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] }, + streamFn: () => { + throw new Error("Not exercised"); + }, + }); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + + expect(session.configWarnings).toContain( + "retry.fallbackChains key references unknown model: nonexistent-provider/nonexistent-model", + ); + expect(session.configWarnings.filter(w => w.includes(`${primaryModel.provider}/${primaryModel.id}`))).toEqual([]); + }); + it("normalizes suppression by base selector and clears it on model refresh", async () => { const future = Date.now() + 60_000; modelRegistry.suppressSelector("openai/gpt-4o:high", future); diff --git a/packages/coding-agent/test/model-hub.test.ts b/packages/coding-agent/test/model-hub.test.ts index a9d3bed7c..95fd2d3b6 100644 --- a/packages/coding-agent/test/model-hub.test.ts +++ b/packages/coding-agent/test/model-hub.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; +import { afterEach, beforeAll, describe, expect, type Mock, test, vi } from "bun:test"; import { stripVTControlCharacters } from "node:util"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; @@ -78,6 +78,7 @@ interface HubHarness { onPick: ReturnType; onLoginRequest: ReturnType; onCancel: ReturnType; + onFallbackChainChange: Mock<(role: string, chain: string[]) => void>; } const openHubs: ModelHubComponent[] = []; @@ -100,6 +101,16 @@ function createHub(options: { const onPick = vi.fn(); const onLoginRequest = vi.fn(); const onCancel = vi.fn(); + // Mirror the controller: persist chain edits so the hub's re-read sees them. + const onFallbackChainChange = vi.fn((role: string, chain: string[]) => { + const chains = { ...settings.get("retry.fallbackChains") }; + if (chain.length === 0) { + delete chains[role]; + } else { + chains[role] = chain; + } + settings.override("retry.fallbackChains", chains); + }); const hub = new ModelHubComponent( ui, settings, @@ -111,12 +122,13 @@ function createHub(options: { onPick: options.callbacks?.onPick ?? onPick, onLoginRequest: options.callbacks?.onLoginRequest ?? onLoginRequest, onCycleOrderChange: options.callbacks?.onCycleOrderChange, + onFallbackChainChange: options.callbacks?.onFallbackChainChange ?? onFallbackChainChange, onCancel: options.callbacks?.onCancel ?? onCancel, }, options.hub, ); openHubs.push(hub); - return { hub, onAssign, onUnassign, onPick, onLoginRequest, onCancel }; + return { hub, onAssign, onUnassign, onPick, onLoginRequest, onCancel, onFallbackChainChange }; } const DOWN = "\x1b[B"; @@ -304,7 +316,8 @@ describe("ModelHub", () => { hub.handleInput(UP); // All models → Roles (since Recent is removed) hub.handleInput("\n"); // dive into rows - hub.handleInput(UP); // wraps to the trailing "+ New role…" row + hub.handleInput(UP); // wraps to the trailing "+ New fallback…" row + hub.handleInput(UP); // skips the section divider up to "+ New role…" hub.handleInput("\n"); expect(footerLine(hub.render(220))).toContain("New role name:"); @@ -317,7 +330,6 @@ describe("ModelHub", () => { const call = onAssign.mock.calls[0]; expect(call?.[1]).toBe("reviewer"); expect(call?.[3]).toBe("test/reviewer-model"); - expect(call?.[4]).toBe("modelRole"); }); }); @@ -340,7 +352,6 @@ describe("ModelHub", () => { expect(call?.[1]).toBe("default"); expect(call?.[2]).toBe(ThinkingLevel.Inherit); expect(call?.[3]).toBe("openai/gpt-5.5"); - expect(call?.[4]).toBe("modelRole"); // The thinking strip follows immediately, scoped to the model's // real ladder: gpt-5.5 tops out at xhigh — no invented max tier. @@ -379,20 +390,24 @@ describe("ModelHub", () => { expect(footerLine(hub.render(220))).not.toContain("inherit"); }); - test("retry-fallback chip fires the retryFallback action without a thinking strip", () => { + test("retry-fallback chip appends the model to the default chain without a thinking strip", () => { const model = makeModel("test", "retry-fallback-model"); - const { hub, onAssign } = createHub({ models: [model], scoped: true }); + const { hub, onAssign, onFallbackChainChange } = createHub({ models: [model], scoped: true }); installTestTheme(); hub.handleInput("\n"); hub.handleInput(LEFT); // wraps to the trailing retry-fallback chip hub.handleInput("\n"); - expect(onAssign).toHaveBeenCalledTimes(1); - const call = onAssign.mock.calls[0]; - expect(call?.[1]).toBe("default"); - expect(call?.[4]).toBe("retryFallback"); + expect(onFallbackChainChange).toHaveBeenCalledWith("default", ["test/retry-fallback-model"]); + expect(onAssign).not.toHaveBeenCalled(); expect(footerLine(hub.render(220))).not.toContain("inherit"); + + // A second registration of the same model is a no-op, not a duplicate. + hub.handleInput("\n"); + hub.handleInput(LEFT); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenCalledTimes(1); }); test("overflowing role strip scrolls left so the selected chip stays visible", () => { @@ -417,6 +432,181 @@ describe("ModelHub", () => { }); }); + describe("fallback chains in the roles view", () => { + /** Hop to the Roles sidebar entry and dive into its rows. */ + function enterRolesView(hub: ModelHubComponent): void { + hub.handleInput(UP); // All models → Roles + hub.handleInput("\n"); // dive into the rows + } + + test("renders configured chain entries as indented rows under their role", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const settings = Settings.isolated({ + "retry.fallbackChains": { default: ["test/model-a", "test/model-b"] }, + }); + const { hub } = createHub({ models: [a, b], scoped: true, settings }); + + enterRolesView(hub); + const rendered = normalize(hub.render(220)); + expect(rendered).toContain("↳ test/model-a"); + expect(rendered).toContain("↳ test/model-b"); + }); + + test("f on a role opens fallback assignment and Enter appends the picked model", () => { + const a = makeModel("test", "model-a"); + const settings = Settings.isolated({}); + const { hub, onFallbackChainChange, onAssign } = createHub({ models: [a], scoped: true, settings }); + + enterRolesView(hub); + hub.handleInput("f"); // add a fallback for the first role (default) + expect(normalize(hub.render(220))).toContain("Adding fallback for"); + + hub.handleInput("\n"); // pick the only model + expect(onFallbackChainChange).toHaveBeenCalledWith("default", ["test/model-a"]); + expect(onAssign).not.toHaveBeenCalled(); // no role assignment, no thinking strip + expect(normalize(hub.render(220))).toContain("↳ test/model-a"); + }); + + test("x removes a chain entry and Enter on an entry replaces it", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const settings = Settings.isolated({ + "retry.fallbackChains": { default: ["test/model-a", "test/model-b"] }, + }); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true, settings }); + + enterRolesView(hub); + hub.handleInput(DOWN); // default → its first chain entry (model-a) + hub.handleInput("\n"); // replace this entry + expect(normalize(hub.render(220))).toContain("Replacing fallback of"); + for (const ch of "model-b") hub.handleInput(ch); // search: arrows hop scopes in assign mode + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]); + + hub.handleInput("x"); // cursor landed on the replaced entry — remove it + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", []); + expect(normalize(hub.render(220))).not.toContain("↳"); + }); + + test("] moves a chain entry later and the cursor follows it", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const settings = Settings.isolated({ + "retry.fallbackChains": { default: ["test/model-a", "test/model-b"] }, + }); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true, settings }); + + enterRolesView(hub); + hub.handleInput(DOWN); // first chain entry (model-a) + hub.handleInput("]"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b", "test/model-a"]); + + // Cursor followed the moved entry: x removes model-a, not model-b. + hub.handleInput("x"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("default", ["test/model-b"]); + }); + + test("clicking a roles row hits the row under the pointer", () => { + const a = makeModel("test", "model-a"); + const { hub } = createHub({ models: [a], scoped: true }); + + hub.handleInput(UP); // All models → Roles + // Derive the pointer row from the frame itself: the fullscreen + // overlay paints from screen row 0, so frame index == screen row. + const frame = hub.render(220).map(line => stripVTControlCharacters(line)); + const screenRow = frame.findIndex(line => line.includes("DEFAULT")); + expect(screenRow).toBeGreaterThan(0); + const sgr = `\x1b[<0;61;${screenRow + 1}M`; // SGR reports are 1-based + hub.handleInput(sgr); // select (dive into rows) + hub.handleInput(sgr); // click-again activates + expect(normalize(hub.render(220))).toContain("Assigning DEFAULT"); + }); + + test("fallbacks chip keys a new chain by the selected model", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true }); + + for (const ch of "model-a") hub.handleInput(ch); + hub.handleInput("\n"); // open the strip for model-a + hub.handleInput(LEFT); // retry-fallback + hub.handleInput(LEFT); // fallbacks:test/* + hub.handleInput(LEFT); // fallbacks:model-a + hub.handleInput("\n"); + expect(normalize(hub.render(220))).toContain("Adding fallback for test/model-a"); + + for (const ch of "model-b") hub.handleInput(ch); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/model-a", ["test/model-b"]); + const rendered = normalize(hub.render(220)); + expect(rendered).toContain("test/model-a"); + expect(rendered).toContain("↳ test/model-b"); + }); + + test("provider chip keys the chain by provider/*", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true }); + + for (const ch of "model-a") hub.handleInput(ch); + hub.handleInput("\n"); + hub.handleInput(LEFT); // retry-fallback + hub.handleInput(LEFT); // fallbacks:test/* + hub.handleInput("\n"); + expect(normalize(hub.render(220))).toContain("Adding fallback for test/*"); + + for (const ch of "model-b") hub.handleInput(ch); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/*", ["test/model-b"]); + }); + + test("+ New fallback… picks the protected model, then keys the chain via the strip", () => { + const a = makeModel("test", "model-a"); + const b = makeModel("test", "model-b"); + const { hub, onFallbackChainChange } = createHub({ models: [a, b], scoped: true }); + + enterRolesView(hub); + hub.handleInput(UP); // wrap to the trailing "+ New fallback…" + hub.handleInput("\n"); + expect(normalize(hub.render(220))).toContain("New fallback chain"); + + for (const ch of "model-a") hub.handleInput(ch); + hub.handleInput("\n"); // pick the protected model + const strip = footerLine(hub.render(220)); + expect(strip).toContain("for test/model-a"); + expect(strip).toContain("for test/*"); + + hub.handleInput("\n"); // key by the exact model + expect(normalize(hub.render(220))).toContain("Adding fallback for test/model-a"); + for (const ch of "model-b") hub.handleInput(ch); + hub.handleInput("\n"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/model-a", ["test/model-b"]); + }); + + test("model-keyed chains render below the separator and x clears the whole chain", () => { + const a = makeModel("test", "model-a"); + const settings = Settings.isolated({ + "retry.fallbackChains": { "test/*": ["test/model-a"] }, + }); + const { hub, onFallbackChainChange } = createHub({ models: [a], scoped: true, settings }); + + enterRolesView(hub); + const rendered = normalize(hub.render(220)); + expect(rendered).toContain("test/*"); + expect(rendered).toContain("↳ test/model-a"); + expect(rendered).toContain("+ New fallback…"); + expect(rendered).toMatch(/─{10,}/); // the roles/fallbacks divider + + hub.handleInput(UP); // + New fallback… + hub.handleInput(UP); // ↳ test/model-a + hub.handleInput(UP); // test/* header (separator is skipped) + hub.handleInput("x"); + expect(onFallbackChainChange).toHaveBeenLastCalledWith("test/*", []); + expect(normalize(hub.render(220))).not.toContain("↳ test/model-a"); + }); + }); + describe("pick mode", () => { test("disables models below the current context size and picks the first enabled one", () => { const small = makeModel("test", "a-small", 4096);