From 04d7dd2f99d365ffbc85b284fa9302c79133d249 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 2 Aug 2026 06:59:42 +0900 Subject: [PATCH] fix(coding-agent): show current role bindings in the model assignment menu After picking a model the assignment menu listed bare role rows ("Set as EXECUTOR (Executor)"), so the model each role is bound to was invisible; the only way to learn it was to scan the whole 800+ entry model list for role badges. The menu now renders each role's current binding inline. Lore-id: 7c41ab9e Constraint: keep one #roles snapshot for labels, badges, and ranking, rebuilt at catalog boundaries Constraint: literal pi/default inherits; literal default remains a valid bare model selector Constraint: resolved and unavailable selector text must be sanitized and width-bounded before TUI rendering Constraint: failed refresh recovery must preserve the last coherent catalog and surface both errors Constraint: provider refreshes stage strict catalog loading before committing rows, roles, and tabs Constraint: presentation failures roll back to the last coherent catalog view before surfacing errors Constraint: role-refresh diagnostics are assigned before rollback rendering Rejected: separate role-overview screen | adds a step to the flow the user is already in Rejected: badges only on model rows | that is the status quo that fails at 800+ models Confidence: high Scope-risk: narrow Reversibility: easy Tested: resolved/unset/effective-default/inherited/qualified-alias/unavailable, fallback chains, catalog arrival/removal, provider/offline refresh recovery, presentation rollback, hostile resolved and unresolved metadata Not-tested: live provider failure against a production discovery service --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/components/model-selector.ts | 291 ++++++++-- ...-selector-action-menu-role-binding.test.ts | 509 ++++++++++++++++++ 3 files changed, 752 insertions(+), 49 deletions(-) create mode 100644 packages/coding-agent/test/model-selector-action-menu-role-binding.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 20fc3aa4cd..b5ed301e73 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ - Managed session preparation now preserves native `content_too_large` storage failures as `artifact_capacity_exceeded` instead of misreporting `binding_invalid: prepare:store`. - The issue-1979 Korean prose wrap test now cleans up inherited multiplexer env vars (`TMUX`, `TMUX_PANE`, etc.) so it deterministically exercises the plain-terminal render path regardless of the CI runner's terminal session (#1979). - The SDK operation inventory now classifies the local-only `/import-session` seam as a locked exclusion and regenerates the committed matrix, fixing the shard-5 `accepts the committed generated matrix` gate failure introduced by the Codex import command (#3714). +- The model selector's assignment menu now shows the model each role currently resolves to (`Set as EXECUTOR (Executor) — now: anthropic/claude-haiku-4-5`), distinguishing an unset default, a role that inherits the default, and a configured-but-unresolvable selector. Previously the role rows were unlabeled, so the only way to learn a role's model was to scan the whole 800+ entry model list for role badges. ## [0.12.7] - 2026-07-31 ## [0.12.6] - 2026-07-31 diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 5eb0852241..4f60a81d30 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -11,7 +11,9 @@ import { TabBar, Text, type TUI, + truncateToWidth, } from "@gajae-code/tui"; +import { sanitizeText } from "@gajae-code/utils"; import { getModelProfilePresentation, groupModelProfilesForPresetLanding, @@ -96,6 +98,26 @@ interface RoleAssignment { thinkingLevel: ThinkingLevel; } +type RoleAssignments = Record; + +interface MaterializedCatalog { + models: ModelItem[]; + canonicalModels: CanonicalModelItem[]; + roles: RoleAssignments; +} + +interface ModelSelectorViewSnapshot { + roles: RoleAssignments; + allModels: ModelItem[]; + filteredModels: ModelItem[]; + canonicalModels: CanonicalModelItem[]; + filteredCanonicalModels: CanonicalModelItem[]; + selectedIndex: number; + providers: ProviderTabState[]; + activeTabIndex: number; + tabBar: TabBar | null; +} + export type ModelSelectorSelection = | { kind: "assignment"; @@ -246,6 +268,9 @@ function isInheritedRoleSelector(value: string): boolean { return value === "default" || value === "pi/default"; } +/** Width bound for an unresolvable selector echoed back into the assignment menu. */ +const ROLE_BINDING_MAX_WIDTH = 48; + function getDefaultAliasThinkingLevel(value: string | undefined): ThinkingLevel | undefined { const normalized = value?.trim(); if (!normalized?.startsWith("pi/default:")) return undefined; @@ -327,7 +352,7 @@ export class ModelSelectorComponent extends Container { #canonicalModels: CanonicalModelItem[] = []; #filteredCanonicalModels: CanonicalModelItem[] = []; #selectedIndex: number = 0; - #roles = {} as Record; + #roles: RoleAssignments = {}; #settings = null as unknown as Settings; #modelRegistry = null as unknown as ModelRegistry; #onSelectCallback = (() => {}) as RoleSelectCallback; @@ -412,7 +437,7 @@ export class ModelSelectorComponent extends Container { this.#viewMode = this.#temporaryOnly || initialSearchInput || scopedModels.length > 0 ? "models" : "presets"; // Load current role assignments from settings - this.#loadRoleModels(); + this.#rebuildRoleModels(); // Add top border this.addChild(new DynamicBorder()); @@ -495,7 +520,8 @@ export class ModelSelectorComponent extends Container { }); } - #loadRoleModels(): void { + #loadRoleModels(): RoleAssignments { + const roles: RoleAssignments = {}; const allModels = this.#modelRegistry.getAll(); const matchPreferences = { usageOrder: this.#settings.getStorage()?.getModelUsageOrder() }; const agentModelOverrides = this.#settings.get("task.agentModelOverrides"); @@ -511,7 +537,7 @@ export class ModelSelectorComponent extends Container { modelRegistry: this.#modelRegistry, }); if (resolved.model) { - this.#roles[role] = { + roles[role] = { model: resolved.model, thinkingLevel: resolved.explicitThinkingLevel && resolved.thinkingLevel !== undefined @@ -526,11 +552,22 @@ export class ModelSelectorComponent extends Container { this.#isActiveDefaultFallback()) && this.#currentModel ) { - this.#roles.default = { + roles.default = { model: this.#currentModel, thinkingLevel: this.#currentThinkingLevel ?? ThinkingLevel.Inherit, }; } + return roles; + } + + /** + * Re-resolve every role binding against the CURRENT model catalog. Role bindings + * are resolved snapshots, so any catalog change (initial async load, provider + * refresh) must rebuild them or badges, ranking, and the assignment menu keep + * reporting models that the catalog has since gained or lost. + */ + #rebuildRoleModels(): void { + this.#roles = this.#loadRoleModels(); } refreshRoleAssignments( @@ -539,9 +576,7 @@ export class ModelSelectorComponent extends Container { if ("currentModel" in options) this.#currentModel = options.currentModel; if ("currentThinkingLevel" in options) this.#currentThinkingLevel = options.currentThinkingLevel; if ("activeModelProfile" in options) this.#activeModelProfile = options.activeModelProfile; - this.#roles = {}; - this.#loadRoleModels(); - this.#applyTabFilter(); + this.#refreshCatalogView(); } #resolveProviderAuthState(providerId: string): ProviderAuthState { @@ -550,7 +585,7 @@ export class ModelSelectorComponent extends Container { return this.#modelRegistry.hasConfiguredProviderAuth(providerId) ? "configured" : "none"; } - #sortModels(models: ModelItem[]): void { + #sortModels(models: ModelItem[], roles: RoleAssignments = this.#roles): void { // Sort: default-tagged model first, then MRU, then provider ranking const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; const mruIndex = new Map(mruOrder.map((key, i) => [key, i])); @@ -561,7 +596,7 @@ export class ModelSelectorComponent extends Container { } } - const modelRank = (item: ModelItem) => computeModelRank(item.model, this.#roles); + const modelRank = (item: ModelItem) => computeModelRank(item.model, roles); const dateRe = /-(\d{8})$/; const latestRe = /-latest$/; @@ -630,7 +665,7 @@ export class ModelSelectorComponent extends Container { }); } - #sortCanonicalModels(models: CanonicalModelItem[]): void { + #sortCanonicalModels(models: CanonicalModelItem[], roles: RoleAssignments = this.#roles): void { const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; const mruIndex = new Map(mruOrder.map((key, i) => [key, i])); const providerAuthStateById = new Map(); @@ -640,7 +675,7 @@ export class ModelSelectorComponent extends Container { } } - const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, this.#roles); + const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, roles); models.sort((a, b) => { const aRank = modelRank(a); @@ -669,23 +704,40 @@ export class ModelSelectorComponent extends Container { }); } - async #loadModels(): Promise { + #buildScopedModelItems(): ModelItem[] { + return this.#scopedModels.map(scoped => ({ + kind: "provider", + provider: scoped.model.provider, + id: scoped.model.id, + model: scoped.model, + selector: `${scoped.model.provider}/${scoped.model.id}`, + thinkingLevel: scoped.thinkingLevel, + explicitThinkingLevel: scoped.explicitThinkingLevel, + })); + } + + #buildAvailableModelItems(): ModelItem[] { + return this.#modelRegistry.getAvailable().map((model: Model) => ({ + kind: "provider", + provider: model.provider, + id: model.id, + model, + selector: `${model.provider}/${model.id}`, + })); + } + async #loadModels( + options: { refreshRegistry?: boolean; throwOnCatalogError?: boolean; commit?: boolean } = {}, + ): Promise { let models: ModelItem[]; // Use scoped models if provided via --models flag if (this.#scopedModels.length > 0) { - models = this.#scopedModels.map(scoped => ({ - kind: "provider", - provider: scoped.model.provider, - id: scoped.model.id, - model: scoped.model, - selector: `${scoped.model.provider}/${scoped.model.id}`, - thinkingLevel: scoped.thinkingLevel, - explicitThinkingLevel: scoped.explicitThinkingLevel, - })); + models = this.#buildScopedModelItems(); } else { // Reload config and cached discovery state without blocking on live provider refresh - await this.#modelRegistry.refresh("offline"); + if (options.refreshRegistry !== false) { + await this.#modelRegistry.refresh("offline"); + } // Check for models.json errors const loadError = this.#modelRegistry.getError(); @@ -697,24 +749,25 @@ export class ModelSelectorComponent extends Container { // Load available models (built-in models still work even if models.json failed) try { - const availableModels = this.#modelRegistry.getAvailable(); - models = availableModels.map((model: Model) => ({ - kind: "provider", - provider: model.provider, - id: model.id, - model, - selector: `${model.provider}/${model.id}`, - })); + models = this.#buildAvailableModelItems(); } catch (error) { + if (options.throwOnCatalogError) throw error; this.#allModels = []; this.#filteredModels = []; this.#canonicalModels = []; this.#filteredCanonicalModels = []; this.#errorMessage = error instanceof Error ? error.message : String(error); + this.#rebuildRoleModels(); return; } } + const catalog = this.#materializeModels(models); + if (options.commit !== false) this.#commitMaterializedCatalog(catalog); + return catalog; + } + + #materializeModels(models: ModelItem[]): MaterializedCatalog { const candidateModels = models.map(item => item.model); const canonicalRecords = this.#modelRegistry.getCanonicalModels({ availableOnly: this.#scopedModels.length === 0, @@ -758,20 +811,76 @@ export class ModelSelectorComponent extends Container { return item; }) .filter((item): item is CanonicalModelItem => item !== undefined); + const roles = this.#loadRoleModels(); - this.#sortModels(models); - this.#sortCanonicalModels(canonicalModels); - this.#allModels = models; - this.#filteredModels = models; - this.#canonicalModels = canonicalModels; - this.#filteredCanonicalModels = canonicalModels; - this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1)); + this.#sortModels(models, roles); + this.#sortCanonicalModels(canonicalModels, roles); + return { models, canonicalModels, roles }; + } + + #commitMaterializedCatalog(catalog: MaterializedCatalog): void { + this.#roles = catalog.roles; + this.#allModels = catalog.models; + this.#filteredModels = catalog.models; + this.#canonicalModels = catalog.canonicalModels; + this.#filteredCanonicalModels = catalog.canonicalModels; + this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, catalog.models.length - 1)); + } + + #captureViewSnapshot(): ModelSelectorViewSnapshot { + return { + roles: this.#roles, + allModels: this.#allModels, + filteredModels: this.#filteredModels, + canonicalModels: this.#canonicalModels, + filteredCanonicalModels: this.#filteredCanonicalModels, + selectedIndex: this.#selectedIndex, + providers: this.#providers, + activeTabIndex: this.#activeTabIndex, + tabBar: this.#tabBar, + }; + } + + #restoreViewSnapshot(snapshot: ModelSelectorViewSnapshot): void { + this.#roles = snapshot.roles; + this.#allModels = snapshot.allModels; + this.#filteredModels = snapshot.filteredModels; + this.#canonicalModels = snapshot.canonicalModels; + this.#filteredCanonicalModels = snapshot.filteredCanonicalModels; + this.#selectedIndex = snapshot.selectedIndex; + this.#providers = snapshot.providers; + this.#activeTabIndex = snapshot.activeTabIndex; + this.#tabBar = snapshot.tabBar; + } + + #refreshCatalogView(): boolean { + const previousView = this.#captureViewSnapshot(); + try { + const models = + this.#scopedModels.length > 0 ? this.#buildScopedModelItems() : this.#buildAvailableModelItems(); + const catalog = this.#materializeModels(models); + this.#buildProviderTabs(catalog.models); + this.#commitMaterializedCatalog(catalog); + this.#updateTabBar(); + this.#applyTabFilter(); + return true; + } catch (error) { + this.#errorMessage = error instanceof Error ? error.message : String(error); + this.#restoreViewSnapshot(previousView); + try { + this.#updateTabBar(); + this.#applyTabFilter(); + } catch { + // Keep the last-good component state even if the terminal view cannot be rebuilt. + } + return false; + } } - #buildProviderTabs(): void { + #buildProviderTabs(models: readonly ModelItem[] = this.#allModels): void { const activeTabId = this.#getActiveTab().id; const providerSet = new Set(); - for (const item of this.#allModels) { + for (const item of models) { providerSet.add(item.provider); } for (const provider of this.#modelRegistry.getDiscoverableProviders()) { @@ -806,12 +915,58 @@ export class ModelSelectorComponent extends Container { if (this.#scopedModels.length > 0 || !providerId) { return; } - await this.#modelRegistry.refreshProvider(providerId); - await this.#loadModels(); - this.#buildProviderTabs(); - this.#updateTabBar(); - this.#applyTabFilter(); - this.#tui.requestRender(); + + let refreshError: unknown; + try { + await this.#modelRegistry.refreshProvider(providerId); + } catch (error) { + refreshError = error; + } + + let catalog: MaterializedCatalog | undefined; + try { + catalog = await this.#loadModels({ + refreshRegistry: refreshError === undefined, + throwOnCatalogError: true, + commit: false, + }); + } catch (catalogError) { + if (refreshError !== undefined) { + const refreshMessage = refreshError instanceof Error ? refreshError.message : String(refreshError); + const recoveryMessage = catalogError instanceof Error ? catalogError.message : String(catalogError); + throw new Error(`${refreshMessage}; catalog recovery failed: ${recoveryMessage}`, { + cause: refreshError, + }); + } + throw catalogError; + } + if (!catalog) throw new Error("Model catalog could not be materialized."); + + const previousView = this.#captureViewSnapshot(); + try { + this.#buildProviderTabs(catalog.models); + this.#commitMaterializedCatalog(catalog); + this.#updateTabBar(); + this.#applyTabFilter(); + this.#tui.requestRender(); + } catch (presentationError) { + const finalError = + refreshError !== undefined + ? new Error( + `${refreshError instanceof Error ? refreshError.message : String(refreshError)}; catalog presentation failed: ${presentationError instanceof Error ? presentationError.message : String(presentationError)}`, + { cause: refreshError }, + ) + : presentationError; + this.#restoreViewSnapshot(previousView); + try { + this.#updateTabBar(); + this.#applyTabFilter(); + } catch { + // Preserve the original presentation failure and the last-good component state. + } + throw finalError; + } + if (refreshError !== undefined) throw refreshError; } #updateTabBar(): void { @@ -1193,11 +1348,12 @@ export class ModelSelectorComponent extends Container { } #formatAssignedModelLabel(model: Model, thinkingLevel: ThinkingLevel | undefined): string { - let label = `${model.provider}/${model.id}`; + const modelLabel = sanitizeText(`${model.provider}/${model.id}`).replace(/\s+/g, " ").trim(); + let label = modelLabel; if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) { label += ` (${getThinkingLevelMetadata(thinkingLevel).label})`; } - return label; + return truncateToWidth(label, ROLE_BINDING_MAX_WIDTH); } #renderPresetLanding(): void { @@ -1510,7 +1666,7 @@ export class ModelSelectorComponent extends Container { const prefix = i === this.#selectedActionIndex ? theme.fg("accent", `${theme.nav.cursor} `) : " "; const role = GJC_MODEL_ASSIGNMENT_TARGET_IDS[i]; const label = role - ? `Set as ${GJC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${GJC_MODEL_ASSIGNMENT_TARGETS[role].name})` + ? `Set as ${GJC_MODEL_ASSIGNMENT_TARGETS[role].tag ?? role.toUpperCase()} (${GJC_MODEL_ASSIGNMENT_TARGETS[role].name}) — now: ${this.#formatRoleBinding(role)}` : i === GJC_MODEL_ASSIGNMENT_TARGET_IDS.length ? "Set for all role agents" : "Set for all targets"; @@ -1520,6 +1676,43 @@ export class ModelSelectorComponent extends Container { } } + /** + * Describe what a role currently points at so the assignment menu can show the + * existing binding inline. Without it the role rows carry no binding and the + * only way to learn a role's model is to scan the whole (800+ entry) model list + * for role badges. + */ + #formatRoleBinding(role: GjcModelAssignmentTargetId): string { + const target = GJC_MODEL_ASSIGNMENT_TARGETS[role]; + const configured = + target.settingsPath === "modelRoles" + ? this.#settings.getModelRole(role) + : this.#settings.get("task.agentModelOverrides")[role]; + const selectors = normalizeModelSelectorValue(configured); + const head = selectors[0]; + const assigned = this.#roles[role]; + if (!head) { + if (assigned) return this.#formatAssignedModelLabel(assigned.model, assigned.thinkingLevel); + return role === "default" ? "unset" : "inherits default"; + } + const inheritedThinkingLevel = getDefaultAliasThinkingLevel(head); + const isInheritedAlias = head === "pi/default" || inheritedThinkingLevel !== undefined; + // A role agent pinned to the `pi/default` alias inherits whatever the default + // resolves to. Classify that before the resolved lookup: the alias itself resolves + // to the default's model, which would otherwise render as a concrete id and hide the + // inheritance. A fallback chain merely headed by the alias is NOT inherited — + // resolution can advance to a tail entry — so it falls through to the resolved + // binding below. + if (role !== "default" && selectors.length === 1 && isInheritedAlias) { + return inheritedThinkingLevel ? `inherits default (${inheritedThinkingLevel})` : "inherits default"; + } + if (assigned) return this.#formatAssignedModelLabel(assigned.model, assigned.thinkingLevel); + // Configured selectors are permissively validated, so bound and sanitize the raw + // value before it reaches the terminal. + const displayed = truncateToWidth(sanitizeText(head).replace(/\s+/g, " ").trim(), ROLE_BINDING_MAX_WIDTH); + return `${displayed} (unavailable)`; + } + #renderThinkingMenu(choice: PendingThinkingChoice): void { const targetLabel = choice.roles ? choice.roles.includes("default") diff --git a/packages/coding-agent/test/model-selector-action-menu-role-binding.test.ts b/packages/coding-agent/test/model-selector-action-menu-role-binding.test.ts new file mode 100644 index 0000000000..798514766b --- /dev/null +++ b/packages/coding-agent/test/model-selector-action-menu-role-binding.test.ts @@ -0,0 +1,509 @@ +import { beforeAll, describe, expect, test, vi } from "bun:test"; +import { ThinkingLevel } from "@gajae-code/agent-core"; +import { getBundledModel, type Model } from "@gajae-code/ai"; +import type { ModelRegistry } from "@gajae-code/coding-agent/config/model-registry"; +import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { ModelSelectorComponent } from "@gajae-code/coding-agent/modes/components/model-selector"; +import { getThemeByName, setThemeInstance } from "@gajae-code/coding-agent/modes/theme/theme"; +import type { TUI } from "@gajae-code/tui"; + +function normalizeRenderedText(text: string): string { + return text + .replace(/\x1b\[[0-9;]*m/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +type TestRegistryOptions = { + getAvailable?: () => Model[]; + refresh?: (mode: "offline") => Promise; + refreshProvider?: (providerId: string) => Promise; + getDiscoverableProviders?: () => string[]; + requestRender?: () => void; +}; +function createSelector( + model: Model, + settings: Settings, + knownModels: Model[], + scopedModels: Array<{ model: Model }> = [{ model }], + registryOptions: TestRegistryOptions = {}, +): ModelSelectorComponent { + const modelRegistry = { + getAll: () => knownModels, + getAvailable: registryOptions.getAvailable ?? (() => knownModels), + getError: () => undefined, + refresh: registryOptions.refresh ?? (async () => {}), + refreshProvider: registryOptions.refreshProvider ?? (async () => {}), + hasConfiguredProviderAuth: () => false, + getDiscoverableProviders: registryOptions.getDiscoverableProviders ?? (() => []), + getProviderDiscoveryState: () => undefined, + getCanonicalModels: () => [], + resolveCanonicalModel: () => undefined, + } as unknown as ModelRegistry; + const ui = { requestRender: vi.fn(registryOptions.requestRender ?? (() => {})) } as unknown as TUI; + + return new ModelSelectorComponent( + ui, + model, + settings, + modelRegistry, + scopedModels, + () => {}, + () => {}, + ); +} + +let testTheme = await getThemeByName("red-claw"); + +function installTestTheme(): void { + if (!testTheme) throw new Error("Failed to load theme for ModelSelector tests"); + setThemeInstance(testTheme); +} + +/** Open the assignment menu for the first model and return its normalized rendering. */ +async function renderActionMenu(model: Model, settings: Settings, knownModels: Model[]): Promise { + installTestTheme(); + const selector = createSelector(model, settings, knownModels); + await Bun.sleep(0); + installTestTheme(); + selector.handleInput("\n"); + installTestTheme(); + return normalizeRenderedText(selector.render(240).join("\n")); +} + +describe("ModelSelector assignment menu role bindings", () => { + beforeAll(async () => { + testTheme = await getThemeByName("red-claw"); + if (!testTheme) throw new Error("Failed to load theme for ModelSelector tests"); + }); + + test("shows the model each role currently resolves to", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + const executorModel = getBundledModel("anthropic", "claude-haiku-4-5"); + if (!model || !executorModel) throw new Error("Expected bundled anthropic models"); + + const settings = Settings.isolated({ + modelRoles: { default: `${model.provider}/${model.id}:low` }, + "task.agentModelOverrides": { executor: `${executorModel.provider}/${executorModel.id}` }, + }); + + const rendered = await renderActionMenu(model, settings, [model, executorModel]); + + expect(rendered).toContain(`Set as DEFAULT (Default) — now: ${model.provider}/${model.id} (low)`); + expect(rendered).toContain(`Set as EXECUTOR (Executor) — now: ${executorModel.provider}/${executorModel.id}`); + expect(rendered).toContain("Set as ARCHITECT (Architect) — now: inherits default"); + }); + + test("distinguishes unset, inherited, and unresolvable role bindings", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + + const settings = Settings.isolated({ + "task.agentModelOverrides": { + planner: "retired-provider/retired-model", + critic: "default", + }, + }); + + const rendered = await renderActionMenu(model, settings, [model]); + + expect(rendered).toContain("Set as DEFAULT (Default) — now: unset"); + expect(rendered).toContain("Set as PLANNER (Planner) — now: retired-provider/retired-model (unavailable)"); + expect(rendered).toContain("Set as CRITIC (Critic) — now: default (unavailable)"); + }); + + test("reports the effective default when an active profile supplies it", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + + const selector = createSelector(model, Settings.isolated({}), [model]); + await Bun.sleep(0); + installTestTheme(); + selector.handleInput("\n"); + selector.refreshRoleAssignments({ + currentModel: model, + currentThinkingLevel: ThinkingLevel.High, + activeModelProfile: "profile-a", + }); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain(`Set as DEFAULT (Default) — now: ${model.provider}/${model.id} (high)`); + }); + + test("restores the last-good view and shows role-refresh failures", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const replacement = { ...model, id: "replacement", name: "replacement" }; + const catalog: Model[] = [model]; + let failProviderTabs = false; + + const selector = createSelector( + model, + Settings.isolated({ modelRoles: { default: `${model.provider}/${model.id}` } }), + catalog, + [], + { + getDiscoverableProviders: () => { + if (failProviderTabs) { + failProviderTabs = false; + throw new Error("role refresh presentation failed"); + } + return [model.provider]; + }, + }, + ); + await Bun.sleep(0); + catalog.splice(0, 1, replacement); + failProviderTabs = true; + selector.refreshRoleAssignments(); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain("claude-sonnet-4-5"); + expect(rendered).toContain("role refresh presentation failed"); + expect(rendered).not.toContain("replacement"); + }); + + test("reports inheritance aliases as inherited even when the default resolves", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + + const settings = Settings.isolated({ + modelRoles: { default: `${model.provider}/${model.id}` }, + "task.agentModelOverrides": { + critic: "pi/default", + planner: "pi/default:high", + }, + }); + + const rendered = await renderActionMenu(model, settings, [model]); + + // The alias resolves to the default's model; the menu must still report the + // relationship instead of a concrete id that hides the inheritance. + expect(rendered).toContain("Set as CRITIC (Critic) — now: inherits default"); + expect(rendered).toContain("Set as PLANNER (Planner) — now: inherits default (high)"); + expect(rendered).toContain(`Set as DEFAULT (Default) — now: ${model.provider}/${model.id}`); + }); + + test("treats a literal default model id as a model, not an inheritance alias", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const literalDefault = { ...model, id: "default", name: "default" }; + + const settings = Settings.isolated({ + modelRoles: { default: `${model.provider}/${model.id}` }, + "task.agentModelOverrides": { critic: "default" }, + }); + + const rendered = await renderActionMenu(model, settings, [model, literalDefault]); + + expect(rendered).toContain(`Set as CRITIC (Critic) — now: ${literalDefault.provider}/${literalDefault.id}`); + expect(rendered).not.toContain("Set as CRITIC (Critic) — now: inherits default"); + }); + + test("resolves an alias-headed fallback chain to its effective entry", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + const tailModel = getBundledModel("anthropic", "claude-haiku-4-5"); + if (!model || !tailModel) throw new Error("Expected bundled anthropic models"); + + const settings = Settings.isolated({ + modelRoles: { default: "retired-provider/retired-model" }, + "task.agentModelOverrides": { + executor: ["pi/default", `${tailModel.provider}/${tailModel.id}`], + }, + }); + + const rendered = await renderActionMenu(model, settings, [model, tailModel]); + + // The chain is only headed by the alias: an unresolvable default advances + // resolution to the tail, so claiming inheritance would be a lie. + expect(rendered).toContain(`Set as EXECUTOR (Executor) — now: ${tailModel.provider}/${tailModel.id}`); + expect(rendered).not.toContain("Set as EXECUTOR (Executor) — now: inherits default"); + expect(rendered).toContain("Set as DEFAULT (Default) — now: retired-provider/retired-model (unavailable)"); + }); + + test("re-resolves role bindings once the model catalog finishes loading", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + const lateModel = getBundledModel("anthropic", "claude-haiku-4-5"); + if (!model || !lateModel) throw new Error("Expected bundled anthropic models"); + + const settings = Settings.isolated({ + modelRoles: { default: `${model.provider}/${model.id}` }, + "task.agentModelOverrides": { executor: `${lateModel.provider}/${lateModel.id}` }, + }); + + installTestTheme(); + const catalog: Model[] = [model]; + const selector = createSelector(model, settings, catalog, []); + // The provider's models land while the selector is still loading its catalog. + catalog.push(lateModel); + await Bun.sleep(0); + installTestTheme(); + selector.handleInput("\n"); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain(`Set as EXECUTOR (Executor) — now: ${lateModel.provider}/${lateModel.id}`); + expect(rendered).not.toContain( + `Set as EXECUTOR (Executor) — now: ${lateModel.provider}/${lateModel.id} (unavailable)`, + ); + + catalog.splice(1, 1); + selector.refreshRoleAssignments(); + installTestTheme(); + const refreshed = normalizeRenderedText(selector.render(240).join("\n")); + + expect(refreshed).toContain( + `Set as EXECUTOR (Executor) — now: ${lateModel.provider}/${lateModel.id} (unavailable)`, + ); + }); + + test("materializes a replacement catalog after provider refresh failure", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const replacement = { ...model, id: "replacement", name: "replacement" }; + const catalog: Model[] = [model]; + + const selector = createSelector( + model, + Settings.isolated({ modelRoles: { default: `${model.provider}/${model.id}` } }), + catalog, + [], + { + getDiscoverableProviders: () => [model.provider], + refreshProvider: async () => { + catalog.splice(0, 1, replacement); + throw new Error("provider refresh failed"); + }, + }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\t"); + selector.handleInput("\t"); + await Bun.sleep(0); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain("replacement"); + expect(rendered).toContain("provider refresh failed"); + expect(rendered).not.toContain("claude-sonnet-4-5"); + }); + + test("keeps the last good catalog when provider refresh succeeds but catalog loading fails", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const replacement = { ...model, id: "replacement", name: "replacement" }; + const catalog: Model[] = [model]; + let catalogReadFailed = false; + let catalogReadAttempts = 0; + + const selector = createSelector( + model, + Settings.isolated({ modelRoles: { default: `${model.provider}/${model.id}` } }), + catalog, + [], + { + getAvailable: () => { + catalogReadAttempts++; + if (catalogReadFailed) throw new Error(`catalog read failed #${catalogReadAttempts}`); + return catalog; + }, + getDiscoverableProviders: () => [model.provider], + refreshProvider: async () => { + catalog.splice(0, 1, replacement); + catalogReadFailed = true; + }, + }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\t"); + selector.handleInput("\t"); + await Bun.sleep(0); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain("claude-sonnet-4-5"); + expect(rendered).toContain("catalog read failed #2"); + expect(rendered).not.toContain("replacement"); + expect(catalogReadAttempts).toBe(2); + }); + + test("rolls back the catalog when presentation fails after provider refresh", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const replacement = { ...model, id: "replacement", name: "replacement" }; + const catalog: Model[] = [model]; + let failNextRender = false; + + const selector = createSelector( + model, + Settings.isolated({ modelRoles: { default: `${model.provider}/${model.id}` } }), + catalog, + [], + { + getDiscoverableProviders: () => [model.provider], + refreshProvider: async () => { + catalog.splice(0, 1, replacement); + failNextRender = true; + }, + requestRender: () => { + if (failNextRender) { + failNextRender = false; + throw new Error("presentation failed"); + } + }, + }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\t"); + selector.handleInput("\t"); + await Bun.sleep(0); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain("claude-sonnet-4-5"); + expect(rendered).toContain("presentation failed"); + expect(rendered).not.toContain("replacement"); + }); + + test("keeps the last good catalog when offline refresh fails", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const replacement = { ...model, id: "replacement", name: "replacement" }; + const catalog: Model[] = [model]; + let refreshCalls = 0; + const selector = createSelector( + model, + Settings.isolated({ modelRoles: { default: `${model.provider}/${model.id}` } }), + catalog, + [], + { + refresh: async () => { + refreshCalls++; + if (refreshCalls > 1) throw new Error("offline refresh failed"); + }, + getDiscoverableProviders: () => [model.provider], + refreshProvider: async () => { + catalog.splice(0, 1, replacement); + }, + }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\t"); + selector.handleInput("\t"); + await Bun.sleep(0); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain("claude-sonnet-4-5"); + expect(rendered).toContain("offline refresh failed"); + expect(rendered).not.toContain("replacement"); + }); + + test("keeps the last good catalog when rejected refresh recovery cannot read models", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + const replacement = { ...model, id: "replacement", name: "replacement" }; + const catalog: Model[] = [model]; + let catalogReadFailed = false; + let catalogReadAttempts = 0; + + const selector = createSelector( + model, + Settings.isolated({ modelRoles: { default: `${model.provider}/${model.id}` } }), + catalog, + [], + { + getAvailable: () => { + catalogReadAttempts++; + if (catalogReadFailed) throw new Error(`catalog read failed #${catalogReadAttempts}`); + return catalog; + }, + getDiscoverableProviders: () => [model.provider], + refreshProvider: async () => { + catalog.splice(0, 1, replacement); + catalogReadFailed = true; + throw new Error("provider refresh failed"); + }, + }, + ); + await Bun.sleep(0); + installTestTheme(); + + selector.handleInput("\t"); + selector.handleInput("\t"); + await Bun.sleep(0); + installTestTheme(); + const rendered = normalizeRenderedText(selector.render(240).join("\n")); + + expect(rendered).toContain("claude-sonnet-4-5"); + expect(rendered).toContain("provider refresh failed"); + expect(rendered).toContain("catalog recovery failed: catalog read failed #2"); + expect(catalogReadAttempts).toBe(2); + expect(rendered).not.toContain("replacement"); + }); + + test("bounds and sanitizes an unresolvable selector before rendering it", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + + const hostile = `\x1b[31mred\x1b]0;title\x07/gone\nsecond-line-${"x".repeat(80)}`; + const settings = Settings.isolated({ + modelRoles: { default: `${model.provider}/${model.id}` }, + "task.agentModelOverrides": { architect: hostile }, + }); + + installTestTheme(); + const selector = createSelector(model, settings, [model]); + await Bun.sleep(0); + installTestTheme(); + selector.handleInput("\n"); + installTestTheme(); + const frame = selector.render(240).join("\n"); + const architectRow = frame.split("\n").find(line => line.includes("Set as ARCHITECT")); + if (!architectRow) throw new Error("Expected an architect row in the assignment menu"); + + expect(architectRow).toContain("(unavailable)"); + // No OSC title write and no raw SGR from the selector value survive into the row. + expect(architectRow).not.toContain("\x1b]0;"); + expect(architectRow).not.toContain("\x1b[31m"); + // Bounded: the echoed selector cannot grow the row without limit. + expect(normalizeRenderedText(architectRow).length).toBeLessThan(120); + }); + + test("bounds and sanitizes resolved model metadata before rendering it", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected bundled model anthropic/claude-sonnet-4-5"); + + const hostileProvider = "evil\x1b]0;provider-title\x07"; + const hostileId = `model\nsecond-line-${"x".repeat(80)}`; + const hostileModel = { ...model, provider: hostileProvider, id: hostileId, name: hostileId }; + const settings = Settings.isolated({ + modelRoles: { default: `${hostileModel.provider}/${hostileModel.id}` }, + }); + + installTestTheme(); + const selector = createSelector(model, settings, [model, hostileModel]); + await Bun.sleep(0); + installTestTheme(); + selector.handleInput("\n"); + installTestTheme(); + const frame = selector.render(240).join("\n"); + const defaultRow = frame.split("\n").find(line => line.includes("Set as DEFAULT")); + if (!defaultRow) throw new Error("Expected a default row in the assignment menu"); + + expect(defaultRow).not.toContain("\x1b]0;"); + expect(defaultRow).not.toContain("(unavailable)"); + expect(normalizeRenderedText(defaultRow)).toContain("evil/model second-line-"); + expect(normalizeRenderedText(defaultRow).length).toBeLessThan(120); + }); +});