From 771a4dde4904d33149e3a13f3b7dd5f614108e4c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:07:50 -0700 Subject: [PATCH 1/3] Resolve context window for custom-named providers Model identity is provider:model, and a custom provider name (an account label like xai/thegreataxios) never exact-matches the models.dev registry, which publishes bare and canonical provider/model keys only. Try the bare model id and the canonical provider/model form before falling through to the heuristic. Give heuristicWindow a grok/xai branch so an unpopulated registry still lands on a plausible window instead of the 128k default. Expose hasContextWindowFor so callers can tell a confident registry hit from the heuristic fallback, and use it to mark estimated windows in the picker footer. --- src/provider/context-window.test.ts | 37 +++++++++++++++++++++++++++++ src/provider/context-window.ts | 30 +++++++++++++++++++++-- src/tui-opentui/model-catalog.ts | 11 +++++---- 3 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 src/provider/context-window.test.ts diff --git a/src/provider/context-window.test.ts b/src/provider/context-window.test.ts new file mode 100644 index 000000000..76b9fe50b --- /dev/null +++ b/src/provider/context-window.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, afterEach } from "bun:test"; +import { + contextWindowFor, + hasContextWindowFor, + setModelContextWindows, +} from "./context-window.js"; + +describe("contextWindowFor", () => { + afterEach(() => { + setModelContextWindows(undefined); + }); + + it("resolves a custom-provider-prefixed id against the bare model registry entry", () => { + setModelContextWindows({ "grok-4.5": 500_000 }); + expect(contextWindowFor("xai/thegreataxios:grok-4.5")).toBe(500_000); + }); + + it("resolves a custom-provider-prefixed id against the canonical provider/model entry", () => { + setModelContextWindows({ "xai/grok-4.5": 500_000 }); + expect(contextWindowFor("xai/thegreataxios:grok-4.5")).toBe(500_000); + }); + + it("falls back to a grok/xai heuristic window when the registry has no entry", () => { + setModelContextWindows(undefined); + expect(contextWindowFor("xai/thegreataxios:grok-4.5")).toBe(256_000); + }); + + it("reports low confidence when a miss falls through to the heuristic", () => { + setModelContextWindows(undefined); + expect(hasContextWindowFor("xai/thegreataxios:grok-4.5")).toBe(false); + }); + + it("reports confidence when the registry has a matching entry", () => { + setModelContextWindows({ "grok-4.5": 500_000 }); + expect(hasContextWindowFor("xai/thegreataxios:grok-4.5")).toBe(true); + }); +}); diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 935c4776c..03e22b0b8 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -35,12 +35,38 @@ function heuristicWindow(model: string): number { if (m.includes("deepseek")) return 128_000; if (m.includes("glm")) return 200_000; if (m.includes("o3") || m.includes("o4")) return 200_000; + if (m.includes("grok") || m.includes("xai")) return 256_000; return DEFAULT_CONTEXT_WINDOW; } +// Model identity is `provider:model` (model-catalog.ts), and `provider` may +// itself be a custom account name (`xai/thegreataxios`) rather than the +// canonical provider models.dev publishes under (`xai`). Try, in order: the +// full identity as given, the bare model id, and `canonicalProvider/model` — +// so a custom-named provider still exact-matches the registry instead of +// silently missing and falling through to the heuristic. +function lookupCandidates(model: string): string[] { + const colonIndex = model.indexOf(":"); + if (colonIndex === -1) return [model]; + + const providerSegment = model.slice(0, colonIndex); + const bareModel = model.slice(colonIndex + 1); + const canonicalProvider = providerSegment.split("/")[0]; + + return [model, bareModel, `${canonicalProvider}/${bareModel}`]; +} + +/** True when the registry has an entry for `model` under any known form, so a + * caller can distinguish a confident lookup from the heuristic fallback. */ +export function hasContextWindowFor(model: string): boolean { + return lookupCandidates(model).some((candidate) => contextWindowRegistry[candidate] !== undefined); +} + export function contextWindowFor(model: string): number { - const exact = contextWindowRegistry[model]; - if (exact !== undefined) return exact; + for (const candidate of lookupCandidates(model)) { + const exact = contextWindowRegistry[candidate]; + if (exact !== undefined) return exact; + } return heuristicWindow(model); } diff --git a/src/tui-opentui/model-catalog.ts b/src/tui-opentui/model-catalog.ts index 0be91c472..d4d0f4f6b 100644 --- a/src/tui-opentui/model-catalog.ts +++ b/src/tui-opentui/model-catalog.ts @@ -12,7 +12,7 @@ import { isGoModelOnZenPath as defaultIsGoModelOnZenPath } from "../provider/billing-product.js" import { getActivePricingCache } from "../cost/cost-visibility.js" import { lookupModelPricing, type PricingCache } from "../cost/pricing-fetcher.js" -import { contextWindowFor } from "../provider/context-window.js" +import { contextWindowFor, hasContextWindowFor } from "../provider/context-window.js" import { modelReasoningCapability } from "../provider/reasoning-effort.js" import type { ItemDescription } from "./shell.js" @@ -272,9 +272,12 @@ function pricingImpact(pricing: PricingCache | null, model: string): string { function whatLine(model: string): string { const reasoning = modelReasoningCapability(model) const context = contextWindowFor(model) - const contextText = context > 0 ? `${Math.round(context / 1000)}k context` : "context length unknown" - const tierText = reasoning === true ? "deep reasoning" : reasoning === false ? "standard tier" : "tier unknown" - return `${tierText}. ${contextText}.` + const confident = hasContextWindowFor(model) + const contextText = context > 0 + ? `${Math.round(context / 1000)}k context${confident ? "" : " (estimated)"}` + : "context length unknown" + const reasoningText = reasoning === true ? "Deep reasoning" : reasoning === false ? "No extended reasoning" : "Reasoning support unknown" + return `${reasoningText}. ${contextText}.` } /** From 7a16c6e38bff2444eeb604255f9d46456c69030f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:36:19 -0700 Subject: [PATCH 2/3] Drop model tiers Model tiers (fast/standard/clever fallback chains) added a second selection mechanism alongside per-agent inference pins without buying anything the pins didn't already cover, and the picker footer's "tier unknown" label carried no operator-facing meaning. Remove the tier config surface end to end: settings.tiers, the ProviderTier/TierConfig family, tierProviderRefs/resolveTier and friends, task(tier=), and profile.tier (native and frontmatter). Subagent dispatch already fell back to a tier-free single-source build whenever no tier chain was configured, so buildMainSessionSources and buildSubagentSources collapse to that path unconditionally, still backed by the other configured providers for mid-run failover. /fast, /standard, and /clever are retired outright rather than re-expressed as favourites or aliases: tiers were a fallback chain per name, and neither favourites nor a single pinned model captures that shape, so there is no faithful one-to-one replacement. Per-agent model selection continues to work through profile.inference, which was already the primary mechanism. An old settings file with a tiers key still loads; the key is accepted and silently dropped on the next save, same as the existing workflowPlugins/agentPlugins precedent. The xai auth surface's "tier" (src/auth/xai/constants.ts, src/auth/xai/usage.ts) is the provider's prepaid subscription tier, an unrelated concept, and is untouched. --- src/agent/agent-search.test.ts | 5 - src/agent/agent-search.ts | 5 +- src/agent/default-agents.ts | 2 - src/agent/profile-types.ts | 14 +- src/agent/profiles.ts | 1 - src/config.test.ts | 7 +- src/config/index.ts | 12 +- src/config/inference-sources.ts | 179 +++++-------------------- src/config/settings.ts | 110 ++------------- src/plugins/admin.ts | 4 +- src/plugins/agent-plugins.test.ts | 4 +- src/plugins/data-only-agent.ts | 33 ++--- src/subagent/index.test.ts | 155 +++------------------ src/subagent/run.ts | 3 +- src/subagent/stop-policy.ts | 4 +- src/subagent/task-tool.ts | 96 +++---------- src/subagent/types.ts | 9 +- src/tui-opentui/command-surfaces.ts | 2 +- src/tui/command-registry-setup.test.ts | 8 -- src/tui/commands/built-in.test.ts | 40 +----- src/tui/commands/built-in.ts | 26 ---- src/tui/commands/registry.ts | 4 +- src/tui/runner.ts | 49 +------ tests/unit/data-only-agent.test.ts | 12 +- tests/unit/inference-sources.test.ts | 74 +--------- tests/unit/tier-failover-chain.test.ts | 57 -------- 26 files changed, 138 insertions(+), 777 deletions(-) delete mode 100644 tests/unit/tier-failover-chain.test.ts diff --git a/src/agent/agent-search.test.ts b/src/agent/agent-search.test.ts index be12c90b1..b2dfba129 100644 --- a/src/agent/agent-search.test.ts +++ b/src/agent/agent-search.test.ts @@ -12,17 +12,14 @@ const fixtures: AgentProfile[] = [ { id: "greybeard", description: "Seasoned architect — reviews for design and backwards compatibility", - tier: "clever", }, { id: "critique", description: "Code quality reviewer — tests assumptions and security smells", - tier: "standard", }, { id: "scout", description: "Fast codebase explorer — maps structure and entry points", - tier: "fast", }, ]; @@ -71,13 +68,11 @@ describe("formatAgentSearchResults", () => { id: "draper", description: "PR design reviewer from marketplace", source: "claude", - tier: "standard", systemPromptRole: body, }, ]); expect(text).toContain("### draper"); expect(text).toContain("[source: claude]"); - expect(text).toContain("[tier: standard]"); expect(text).toContain("System prompt / body:"); expect(text).toContain(body); expect(text).toContain("do not need read_file on plugin roots"); diff --git a/src/agent/agent-search.ts b/src/agent/agent-search.ts index 021e142aa..9a6628006 100644 --- a/src/agent/agent-search.ts +++ b/src/agent/agent-search.ts @@ -68,13 +68,12 @@ function truncateAgentBody(body: string): string { // MAX_AGENT_SEARCH_BODY_CHARS are truncated with an ellipsis marker. function formatAgentProfileEntry(p: AgentProfile): string { const desc = (p.description ?? "").trim(); - const tier = p.tier !== undefined ? ` [tier: ${p.tier}]` : ""; const orch = p.orchestrator === true ? " [orchestrator]" : ""; const source = p.source !== undefined ? ` [source: ${p.source}]` : ""; const header = desc.length > 0 - ? `### ${p.id}${tier}${orch}${source}\n${desc}` - : `### ${p.id}${tier}${orch}${source}`; + ? `### ${p.id}${orch}${source}\n${desc}` + : `### ${p.id}${orch}${source}`; const body = (p.systemPromptRole ?? "").trim(); if (body.length === 0) return header; return `${header}\n\nSystem prompt / body:\n${truncateAgentBody(body)}`; diff --git a/src/agent/default-agents.ts b/src/agent/default-agents.ts index c2abbfcdf..af496019b 100644 --- a/src/agent/default-agents.ts +++ b/src/agent/default-agents.ts @@ -8,7 +8,6 @@ export const defaultAgentsPlugin: AgentPlugin = { { id: "greybeard", description: "Seasoned architect — reviews for design, constraint ownership, and backwards compatibility", - tier: "clever", systemPromptRole: "You are a seasoned software architect with decades of experience. " + "You review code and designs for architectural soundness, constraint ownership " + @@ -19,7 +18,6 @@ export const defaultAgentsPlugin: AgentPlugin = { { id: "critique", description: "Code quality reviewer — tests assumptions, finds edge cases and security smells", - tier: "standard", systemPromptRole: "You are a critical code reviewer focused on code quality, test coverage, " + "edge cases, and security-adjacent issues. You challenge assumptions, look for " + diff --git a/src/agent/profile-types.ts b/src/agent/profile-types.ts index e4825e6b1..d0607fa96 100644 --- a/src/agent/profile-types.ts +++ b/src/agent/profile-types.ts @@ -28,9 +28,8 @@ export type CapabilityFilter = { tools: string[]; }; -// A single provider/model/effort combo an agent can run on. Mirrors a tier leg -// but carries an optional reasoningEffort so an agent can pin "Sonnet + medium" -// or "Grok + high" without going through the tier abstraction. +// A single provider/model/effort combo an agent can run on, so an agent can +// pin "Sonnet + medium" or "Grok + high". export type InferenceLeg = { provider: string; model: string; @@ -50,12 +49,9 @@ export type AgentProfile = { // Unique identifier, used in workflow steps as `agent: "greybeard"`. id: string; description?: string; - // Provider tier alias for this agent. Resolved via settings.tiers to a - // concrete provider and model assignment. Used when `inference` is absent. - tier?: "fast" | "standard" | "clever"; - // Explicit per-agent model selection. Takes precedence over `tier` when set, - // so an agent can declare "Sonnet + medium reasoning" without going through - // the user's tier config. See InferenceSpec for resolution rules. + // Explicit per-agent model selection. When absent, the agent runs on the + // parent session's active provider/model. See InferenceSpec for + // resolution rules. inference?: InferenceSpec; // Optional tool restriction. Controls which tools the sub-agent can call. capabilities?: CapabilityFilter; diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index 8237dc888..5b940372f 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -40,7 +40,6 @@ const InferenceSpecSchema = type({ const AgentProfileSchema = type({ id: "string", "description?": "string", - "tier?": "'fast' | 'standard' | 'clever'", "inference?": InferenceSpecSchema, "capabilities?": CapabilityFilterSchema, "systemPromptRole?": "string", diff --git a/src/config.test.ts b/src/config.test.ts index ca4d02fd1..607e2c908 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -538,14 +538,11 @@ describe("buildProviderCatalog", () => { }); }); - test("runtimeSettingsWithCatalog overlays OAuth catalog entries for tier resolution", () => { + test("runtimeSettingsWithCatalog overlays OAuth catalog entries for provider resolution", () => { const disk = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "sk", models: ["gpt-4o"] }, }, - tiers: { - clever: { provider: "xai/work", model: "grok-4" }, - }, }; const catalog = [ { @@ -568,7 +565,6 @@ describe("buildProviderCatalog", () => { apiKey: "xai-token", models: ["grok-4"], }); - expect(runtime.tiers).toEqual(disk.tiers); // Disk persist path still strips OAuth. expect(providerCatalogToSettings(catalog, "openai", disk).providers["xai/work"]).toBeUndefined(); }); @@ -633,7 +629,6 @@ describe("buildProviderCatalog", () => { agentModelFallback: "none", shell: { timeoutMs: 30_000, maxTimeoutMs: 120_000 }, tools: { timeoutMs: 60_000 }, - tiers: { fast: { provider: "fp", model: "fp-large" } }, workflowProfiles: { fast: { implement: "fp-large" } }, }; const settings = providerCatalogToSettings( diff --git a/src/config/index.ts b/src/config/index.ts index a537e82f2..f5a548a5b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -47,10 +47,6 @@ import { normalizeOpenAICompatibleBaseURL, resolveProvider, type MCPServerConfig, - type ProviderTier, - type TierAssignment, - type TierConfig, - type TierDefinition, type ResolvedProvider, type Settings, type ProviderSettings, @@ -306,9 +302,8 @@ export type Config = { workflow?: string; // Deprecated no-op retained for CLI compatibility. noWorkflow: boolean; - tiers?: Partial>; /** - * Runtime settings view for tier/provider resolution. Includes OAuth provider + * Runtime settings view for provider resolution. Includes OAuth provider * projections from the live catalog that are never written to settings.json. * Do not pass this object to saveGlobalSettings — rebuild with * providerCatalogToSettings (or re-read disk) before any persist. @@ -580,8 +575,7 @@ export async function loadConfig( : settings?.mcpServers !== undefined ? { mcpServers: settings.mcpServers, mcpServersSource: "global" as const } : { mcpServersSource: "none" as const }), - ...(settings?.tiers !== undefined ? { tiers: settings.tiers } : {}), - // Runtime view includes OAuth projections so tier resolution can see + // Runtime view includes OAuth projections so inference resolution can see // Codex/xAI providers that are never written to settings.json. Not safe // to persist as-is — use providerCatalogToSettings or re-read disk. ...(settingsForResolution !== null ? { settings: settingsForResolution } : {}), @@ -613,7 +607,7 @@ export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): Pro } // Overlay the full live catalog (including OAuth profiles) onto settings for -// runtime tier/provider resolution. OAuth credentials live in home auth stores +// runtime provider resolution. OAuth credentials live in home auth stores // and are stripped from settings.json; the catalog is the source of truth for // which OAuth providers are available right now. Never pass the result to a // disk write path — use providerCatalogToSettings for persistence. diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 1ee165879..374a5d04f 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -9,15 +9,7 @@ import { buildXaiSource, type ProviderCatalogEntry, } from "./index.js"; -import type { - ProviderTier, - Settings, - TierAssignment, - TierDefinition, - TierProviderRef, - TierSelectionMode, -} from "./settings.js"; -import { PROVIDER_TIERS, resolveTierDefinition, tierDefinitionAt } from "./settings.js"; +import type { Settings } from "./settings.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import { SOURCE_MAX_TOKENS } from "./index.js"; import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; @@ -28,32 +20,24 @@ export type BuildSourceContext = { catalog: readonly ProviderCatalogEntry[]; }; -function refKey(ref: TierProviderRef): string { - return `${ref.provider}\0${ref.model}`; -} +// A resolved provider+model, with optional reasoningEffort — the unit both +// the primary source and its backups are built from. +export type ProviderRef = { provider: string; model: string; reasoningEffort?: ReasoningEffort }; -export function normalizeTierDefinition( - raw: TierAssignment | TierDefinition | undefined, -): TierDefinition | undefined { - if (raw === undefined) return undefined; - if ("order" in raw && Array.isArray(raw.order)) { - const def = raw as TierDefinition; - return { - mode: def.mode ?? "prefer", - order: def.order.filter((r) => r.provider.length > 0 && r.model.length > 0), - }; - } - const leg = raw as TierAssignment; - if (leg.provider.length === 0 || leg.model.length === 0) return undefined; - return { mode: "pin", order: [{ provider: leg.provider, model: leg.model }] }; +function refKey(ref: ProviderRef): string { + return `${ref.provider}\0${ref.model}`; } -function preferTailFromSettings( +// Every other configured provider, one model each, so a primary source that +// fails to build (bad credentials, missing baseURL) still has somewhere to +// fall back to. Order follows settings.providers; providers already covered +// by `existing` are skipped. +function backupRefsFromSettings( settings: Settings, - existing: readonly TierProviderRef[], -): TierProviderRef[] { + existing: readonly ProviderRef[], +): ProviderRef[] { const seenProviders = new Set(existing.map((r) => r.provider)); - const tail: TierProviderRef[] = []; + const tail: ProviderRef[] = []; for (const [provider, p] of Object.entries(settings.providers)) { if (seenProviders.has(provider)) continue; const model = p.defaultModel ?? p.models[0]; @@ -64,22 +48,6 @@ function preferTailFromSettings( return tail; } -export function tierProviderRefs( - tier: ProviderTier, - settings: Settings | undefined, - options?: { fallbackChain?: boolean }, -): TierProviderRef[] { - if (settings === undefined) return []; - const def = - options?.fallbackChain === true - ? resolveTierDefinition(tier, settings) - : tierDefinitionAt(tier, settings); - if (def === null) return []; - const head = def.order; - if (def.mode === "pin") return head; - return [...head, ...preferTailFromSettings(settings, head)]; -} - function catalogEntry( catalog: readonly ProviderCatalogEntry[], provider: string, @@ -98,7 +66,7 @@ function maxTokensFor( } export function buildInferenceSourceForRef( - ref: TierProviderRef, + ref: ProviderRef, ctx: BuildSourceContext, settings: Settings | undefined, ): InferenceSource | null { @@ -195,7 +163,7 @@ export function buildInferenceSourceForRef( } export function buildSourcesFromRefs( - refs: readonly TierProviderRef[], + refs: readonly ProviderRef[], ctx: BuildSourceContext, settings: Settings | undefined, ): InferenceSource[] { @@ -212,21 +180,22 @@ export function buildSourcesFromRefs( } export function prependActiveRef( - refs: readonly TierProviderRef[], - active: TierProviderRef, -): TierProviderRef[] { + refs: readonly ProviderRef[], + active: ProviderRef, +): ProviderRef[] { const without = refs.filter((r) => refKey(r) !== refKey(active)); return [active, ...without]; } -function buildTieredSourceBundle(args: { +// Builds the primary source for `head` plus one backup per other configured +// provider, so a mid-run failure (bad credentials, dropped connection) has +// somewhere else to go. `head` always wins as defaultSource when it builds. +function buildSourceBundle(args: { settings: Settings | undefined; catalog: readonly ProviderCatalogEntry[]; - tier: ProviderTier; - head: TierProviderRef; + head: ProviderRef; reasoningEffort?: ReasoningEffort; sessionId: string; - fallbackChain: boolean; }): { sources: InferenceSource[]; defaultSource: string } { const ctx: BuildSourceContext = { sessionId: args.sessionId, @@ -234,8 +203,9 @@ function buildTieredSourceBundle(args: { ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), }; - const tierRefs = tierProviderRefs(args.tier, args.settings, { fallbackChain: args.fallbackChain }); - const refs = tierRefs.length > 0 ? prependActiveRef(tierRefs, args.head) : [args.head]; + const refs = args.settings !== undefined + ? prependActiveRef(backupRefsFromSettings(args.settings, [args.head]), args.head) + : [args.head]; const sources = buildSourcesFromRefs(refs, ctx, args.settings); const defaultId = args.head.provider; @@ -261,13 +231,11 @@ export function buildMainSessionSources(args: { reasoningEffort?: ReasoningEffort; sessionId: string; }): { sources: InferenceSource[]; defaultSource: string } { - return buildTieredSourceBundle({ + return buildSourceBundle({ settings: args.settings, catalog: args.catalog, - tier: "standard", head: { provider: args.activeProvider, model: args.activeModel }, sessionId: args.sessionId, - fallbackChain: false, ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), }); } @@ -275,100 +243,15 @@ export function buildMainSessionSources(args: { export function buildSubagentSources(args: { settings: Settings | undefined; catalog: readonly ProviderCatalogEntry[]; - tier: ProviderTier; - head: TierProviderRef; + head: ProviderRef; reasoningEffort?: ReasoningEffort; sessionId?: string; }): { sources: InferenceSource[]; defaultSource: string } { - return buildTieredSourceBundle({ + return buildSourceBundle({ settings: args.settings, catalog: args.catalog, - tier: args.tier, head: args.head, sessionId: args.sessionId ?? randomUUID(), - fallbackChain: true, ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), }); -} - -export function firstTierRef( - tier: ProviderTier, - settings: Settings | undefined, -): TierProviderRef | null { - const refs = tierProviderRefs(tier, settings); - return refs[0] ?? null; -} - -export function tierModeLabel(mode: TierSelectionMode | undefined): string { - return mode === "pin" ? "pin" : "prefer"; -} - -export function formatTierChain(raw: TierDefinition | TierAssignment | undefined): string { - const normalized = normalizeTierDefinition(raw); - if (normalized === undefined || normalized.order.length === 0) return "unset"; - const chain = normalized.order - .map((r) => { - const leg = `${r.provider}/${r.model}`; - return r.reasoningEffort !== undefined ? `${leg}@${r.reasoningEffort}` : leg; - }) - .join(" → "); - return `[${tierModeLabel(normalized.mode)}] ${chain}`; -} - -export function appendTierEntry( - existing: TierDefinition | TierAssignment | undefined, - entry: TierProviderRef, - mode?: TierSelectionMode, -): TierDefinition { - const base = normalizeTierDefinition(existing) ?? { mode: mode ?? "prefer", order: [] }; - const without = base.order.filter((r) => refKey(r) !== refKey(entry)); - return { - mode: mode ?? base.mode ?? "prefer", - order: [entry, ...without], - }; -} - -function tierDefinitionWithOrder( - base: TierDefinition, - order: TierProviderRef[], -): TierDefinition { - const mode: TierSelectionMode = base.mode ?? "prefer"; - return { mode, order }; -} - -export function cycleTierMode(existing: TierDefinition | TierAssignment | undefined): TierDefinition { - const base = normalizeTierDefinition(existing) ?? { mode: "prefer", order: [] }; - const next: TierSelectionMode = base.mode === "pin" ? "prefer" : "pin"; - return tierDefinitionWithOrder({ ...base, mode: next }, base.order); -} - -export function removeTierLeg( - existing: TierDefinition | TierAssignment | undefined, - legIndex: number, -): TierDefinition | undefined { - const base = normalizeTierDefinition(existing); - if (base === undefined || legIndex < 0 || legIndex >= base.order.length) return base; - const order = base.order.filter((_, i) => i !== legIndex); - if (order.length === 0) return undefined; - return tierDefinitionWithOrder(base, order); -} - -export function moveTierLeg( - existing: TierDefinition | TierAssignment | undefined, - legIndex: number, - direction: -1 | 1, -): TierDefinition | undefined { - const base = normalizeTierDefinition(existing); - if (base === undefined) return undefined; - const target = legIndex + direction; - if (target < 0 || target >= base.order.length) return base; - const order = [...base.order]; - const tmp = order[legIndex]; - const swap = order[target]; - if (tmp === undefined || swap === undefined) return base; - order[legIndex] = swap; - order[target] = tmp; - return tierDefinitionWithOrder(base, order); -} - -export { PROVIDER_TIERS }; \ No newline at end of file +} \ No newline at end of file diff --git a/src/config/settings.ts b/src/config/settings.ts index b20b9dd9d..ef78c4428 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -44,18 +44,6 @@ export type ProviderSettings = { opencodeGo?: boolean; }; -export type ProviderTier = "fast" | "standard" | "clever"; -export type TierAssignment = { provider: string; model: string; reasoningEffort?: ReasoningEffort }; -export type TierSelectionMode = "pin" | "prefer"; -export type TierProviderRef = { provider: string; model: string; reasoningEffort?: ReasoningEffort }; -export type TierDefinition = { - mode?: TierSelectionMode; - order: TierProviderRef[]; -}; -export type TierConfig = TierAssignment | TierDefinition; - -export const PROVIDER_TIERS: readonly ProviderTier[] = ["fast", "standard", "clever"]; - // Provider+model identity used by the models-first picker (recent / favorites). export type ModelRef = { provider: string; model: string }; @@ -67,7 +55,6 @@ export type Settings = { defaultProvider?: string; providers: Record; mcpServers?: MCPServerConfig[]; - tiers?: Partial>; // Per-phase model overrides for workflows. Keyed by profile name, then by // workflow step profile key. Example: // { "fast": { "implement": "gpt-4o-mini", "review": "gpt-4o" } } @@ -413,34 +400,15 @@ const ModelRefSchema = type({ model: "string", }); -const TierProviderRefSchema = type({ - provider: "string", - model: "string", - "reasoningEffort?": type.enumerated(...REASONING_EFFORTS), -}); - -const TierAssignmentSchema = TierProviderRefSchema; - -const TierDefinitionSchema = type({ - "mode?": "'pin' | 'prefer'", - order: TierProviderRefSchema.array(), -}); - -const TierConfigSchema = TierDefinitionSchema.or(TierAssignmentSchema); - -const TiersSchema = type({ - "fast?": TierConfigSchema, - "standard?": TierConfigSchema, - "clever?": TierConfigSchema, -}); - const SettingsSchema = type({ "defaultProvider?": "string", providers: type({ "[string]": ProviderSettingsSchema }), // mcpServers accepts both array and object forms, so it is validated by // normalizeMcpServers rather than expressed structurally here. "mcpServers?": "unknown", - "tiers?": TiersSchema, + // Model tiers were removed; an older settings file may still carry this key. + // Accepted and ignored so the file still loads, then dropped on next save. + "tiers?": "unknown", "workflowProfiles?": type({ "[string]": type({ "[string]": "string" }) }), "plugins?": type({ "[string]": type({ "enabled?": "boolean", "consented?": "boolean", "credentials?": type({ "[string]": "string" }) }) }), "pluginPaths?": "string[]", @@ -614,7 +582,6 @@ type OptionalLocalSettingsFields = { export const GLOBAL_SETTINGS_OPTIONAL_KEYS = [ "defaultProvider", "mcpServers", - "tiers", "workflowProfiles", "plugins", "pluginPaths", @@ -713,11 +680,15 @@ export async function loadSettings(path: string): Promise { `settings: "workflowPlugins"/"agentPlugins" are no longer supported and will be dropped. Install those plugins under .corbits/plugins/ (or via /plugins "add by path") and enable them in /plugins.\n`, ); } + if (s.tiers !== undefined) { + process.stderr.write( + `settings: "tiers" is no longer supported and will be dropped. Model tiers were removed; use /model to pick a provider and model directly.\n`, + ); + } // Transforms (normalize/clamp/enum) first; pickDefined only drops undefined. const optional: OptionalSettingsFields = { defaultProvider: s.defaultProvider as string | undefined, mcpServers: s.mcpServers !== undefined ? normalizeMcpServers(s.mcpServers) : undefined, - tiers: s.tiers as Settings["tiers"] | undefined, workflowProfiles: s.workflowProfiles as Settings["workflowProfiles"] | undefined, plugins: s.plugins as Settings["plugins"] | undefined, pluginPaths: s.pluginPaths as string[] | undefined, @@ -1144,69 +1115,6 @@ export function resolveProvider(input: ResolveInput): ResolvedProvider { }; } -function isTierDefinitionConfig(raw: TierConfig): raw is TierDefinition { - return "order" in raw && Array.isArray(raw.order); -} - -function tierConfigToDefinition(raw: TierConfig): TierDefinition | null { - if (isTierDefinitionConfig(raw)) { - const order = raw.order.filter((r) => r.provider.length > 0 && r.model.length > 0); - if (order.length === 0) return null; - return { mode: raw.mode ?? "prefer", order }; - } - const leg = raw; - if (leg.provider.length === 0 || leg.model.length === 0) return null; - return { mode: "pin", order: [{ provider: leg.provider, model: leg.model }] }; -} - -/** Tier config at the given name only (no fast → standard → clever walk). */ -export function tierDefinitionAt( - tier: ProviderTier, - settings: Settings, -): TierDefinition | null { - const raw = settings.tiers?.[tier]; - if (raw === undefined) return null; - const def = tierConfigToDefinition(raw); - if (def === null) return null; - const viable = def.order.filter((r) => settings.providers[r.provider] !== undefined); - if (viable.length === 0) return null; - return { mode: def.mode ?? "prefer", order: viable }; -} - -export function resolveTierDefinition( - tier: ProviderTier, - settings: Settings, -): TierDefinition | null { - const chain: ProviderTier[] = ["fast", "standard", "clever"]; - const start = chain.indexOf(tier); - if (start === -1) return null; - for (let i = start; i < chain.length; i++) { - const t = chain[i] as ProviderTier; - const raw = settings.tiers?.[t]; - if (raw === undefined) continue; - const def = tierConfigToDefinition(raw); - if (def === null) continue; - const viable = def.order.filter((r) => settings.providers[r.provider] !== undefined); - if (viable.length === 0) continue; - const mode: TierSelectionMode = def.mode ?? "prefer"; - return { mode, order: viable }; - } - return null; -} - -// Walk the fallback chain fast → standard → clever and return the first -// provider/model in the resolved tier chain. -export function resolveTier(tier: ProviderTier, settings: Settings): TierAssignment | null { - const def = resolveTierDefinition(tier, settings); - const first = def?.order[0]; - if (first === undefined) return null; - return { - provider: first.provider, - model: first.model, - ...(first.reasoningEffort !== undefined ? { reasoningEffort: first.reasoningEffort } : {}), - }; -} - import type { InferenceSpec } from "../agent/profile-types.js"; // A resolved inference leg, with reasoningEffort threaded through. @@ -1231,7 +1139,7 @@ function isLegViable(leg: { provider: string; model: string }, settings: Setting // // - "resolved" — a viable leg was found, returned in `value`. // - "fallback" — no viable leg, but the agent permits fallback (the -// caller falls through to tier / active session). +// caller falls through to the active session's model). // - "unavailable" — no viable leg, and the spec forbids fallback // (`mode: "pin"` or `agentModelFallback: "none"`). The // caller must surface this as an error rather than diff --git a/src/plugins/admin.ts b/src/plugins/admin.ts index 0f4e465a0..88a0c4f95 100644 --- a/src/plugins/admin.ts +++ b/src/plugins/admin.ts @@ -8,8 +8,8 @@ export type PluginDescriptor = { description?: string; credentials: PluginCredentialField[]; // For kind:"agent" plugins — the profiles contributed, shown so the user can - // see which sub-agents and tiers a plugin provides before enabling it. - agentProfiles?: { id: string; tier?: string; description?: string }[]; + // see which sub-agents a plugin provides before enabling it. + agentProfiles?: { id: string; description?: string }[]; /** * True when discovery found the plugin but code is not imported yet (project * or path origin still untrusted). Enabling records trust and full-loads. diff --git a/src/plugins/agent-plugins.test.ts b/src/plugins/agent-plugins.test.ts index e8468397a..71272ce99 100644 --- a/src/plugins/agent-plugins.test.ts +++ b/src/plugins/agent-plugins.test.ts @@ -20,7 +20,6 @@ function agentModule( const validProfile = { id: "explorer", description: "Repository exploration sub-agent", - tier: "fast" as const, capabilities: { mode: "allow" as const, tools: ["read_file", "search_files", "grep"] }, systemPromptRole: "You explore repositories.", }; @@ -31,7 +30,6 @@ describe("resolveAgentPluginProfiles", () => { const profiles = await resolveAgentPluginProfiles([mod], config); expect(profiles.length).toBe(1); expect(profiles[0]!.id).toBe("explorer"); - expect(profiles[0]!.tier).toBe("fast"); }); test("skips profiles from disabled plugins", async () => { @@ -57,7 +55,7 @@ describe("resolveAgentPluginProfiles", () => { test("skips malformed profiles, keeps valid ones", async () => { const { mod, config } = agentModule("p1", [ validProfile, - { id: "bad", tier: "nonexistent" }, // invalid tier + { id: "bad", maxTurns: "nonexistent" }, // invalid maxTurns type { description: "missing id" }, // missing required id ]); const profiles = await resolveAgentPluginProfiles([mod], config); diff --git a/src/plugins/data-only-agent.ts b/src/plugins/data-only-agent.ts index 05e4dfe01..be73950d5 100644 --- a/src/plugins/data-only-agent.ts +++ b/src/plugins/data-only-agent.ts @@ -52,7 +52,7 @@ const NativeCapabilitiesModeSchema = type("'allow' | 'exclude'"); // (legacy: tools: { read: true, bash: false }) // - corbitsdev: name, description, mode, color, permission: { read: "allow", bash: "deny" } // -// Native Corbits Code keys also work and win ties: tier, inference, capabilities, +// Native Corbits Code keys also work and win ties: inference, capabilities, // skills (frontmatter list, in addition to body `Load the X skill` lines). // Upstream tool-name aliases mapped to Corbits Code tool ids. Case-insensitive. @@ -229,12 +229,16 @@ function normalizePermission( return undefined; } -// Normalize the union of `tier` / `model` / `effort` / `inference` shapes into -// either a tier alias or an explicit InferenceSpec. Native `inference` wins; -// then `tier`; then `model` (object or array) with optional `effort`. +// Normalize the union of `model` / `effort` / `inference` shapes into an +// explicit InferenceSpec. Native `inference` wins; then `model` (object or +// array) with optional `effort` applied to legs that don't declare their own. +// +// A bare Claude Code `effort: high` with no `model` has nothing to attach the +// effort to now that tiers (which used to map effort to a model swap) are +// gone, so it is ignored — set `model` alongside `effort` to pin both. function normalizeInference( fm: Record | null, -): { tier?: "fast" | "standard" | "clever"; inference?: InferenceSpec } { +): { inference?: InferenceSpec } { if (fm === null) return {}; // Native explicit inference spec. @@ -247,22 +251,6 @@ function normalizeInference( if (spec !== undefined) return { inference: spec }; } - // Native tier alias. - if (fm.tier === "fast" || fm.tier === "standard" || fm.tier === "clever") { - return { tier: fm.tier }; - } - - // Claude Code `effort: high` — maps to a tier alias. - if (typeof fm.effort === "string") { - const tierFromEffort: Record = { - low: "fast", - medium: "standard", - high: "clever", - }; - const tier = tierFromEffort[fm.effort]; - if (tier !== undefined) return { tier }; - } - // `model` block: object, array, or (rejected in v1) string. if (fm.model !== undefined) { const spec = normalizeModelField(fm.model, fm.effort); @@ -460,12 +448,11 @@ export async function loadDataOnlyAgentPlugin( // to JS-plugin agents too. const systemPromptRole = promptBody; - const { tier, inference } = normalizeInference(frontmatter); + const { inference } = normalizeInference(frontmatter); const capabilities = normalizeCapabilities(frontmatter); const profile: Record = { id }; if (description !== undefined) profile.description = description; - if (tier !== undefined) profile.tier = tier; if (inference !== undefined) profile.inference = inference; if (capabilities !== undefined) profile.capabilities = capabilities; // `orchestrator: true` opts the agent into the recursion exception. Stored diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index 261585e50..c72443371 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -604,7 +604,7 @@ describe("sub-agent stop helpers", () => { expect(parsed.summary).toContain("degenerate repetition"); expect(parsed.findings).toContain("dig footer/chrome"); expect(parsed.blockers).toContain("will be refused"); - expect(parsed.blockers).toContain("not maxTurns or tier alone"); + expect(parsed.blockers).toContain("not maxTurns alone"); const hinted = appendSubAgentParentHints(report); expect(hinted).toContain("Do not re-dispatch the identical brief"); }); @@ -1035,17 +1035,12 @@ describe("createTaskTool", () => { expect(captured?.maxTurns).toBe(50); }); - test("task tier rebuilds provider from settings and wins over profile inference", async () => { + test("profile inference rebuilds provider from settings", async () => { let captured: RunSubAgentParams | undefined; const settings = { providers: { - "clever-p": { baseURL: "http://clever", apiKey: "k", models: ["clever-model"] }, "profile-p": { baseURL: "http://profile", apiKey: "k", models: ["profile-model", "pinned-model"] }, }, - tiers: { - clever: { provider: "clever-p", model: "clever-model" }, - standard: { provider: "profile-p", model: "profile-model" }, - }, }; const tool = createTaskTool({ permissionGate: testPermissionGate, @@ -1056,7 +1051,6 @@ describe("createTaskTool", () => { profiles: [ { id: "deep", - tier: "standard", inference: { order: [{ provider: "profile-p", model: "pinned-model" }] }, }, ], @@ -1066,157 +1060,53 @@ describe("createTaskTool", () => { }, }); await callTask(tool, { - description: "tier-override", + description: "profile-inference", prompt: "x", agent: "deep", - tier: "clever", - }); - expect(captured?.provider.providerName).toBe("clever-p"); - expect(captured?.provider.model).toBe("clever-model"); - expect(captured?.tier).toBe("clever"); - }); - - test("profile tier still applies when task omits tier", async () => { - let captured: RunSubAgentParams | undefined; - const settings = { - providers: { - "profile-p": { baseURL: "http://profile", apiKey: "k", models: ["profile-model"] }, - }, - tiers: { - standard: { provider: "profile-p", model: "profile-model" }, - }, - }; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings, - profiles: [{ id: "deep", tier: "standard" }], - run: async (params) => { - captured = params; - return "done"; - }, }); - await callTask(tool, { description: "profile-tier", prompt: "x", agent: "deep" }); expect(captured?.provider.providerName).toBe("profile-p"); - expect(captured?.provider.model).toBe("profile-model"); - expect(captured?.tier).toBe("standard"); + expect(captured?.provider.model).toBe("pinned-model"); }); - test("unconfigured task tier fails closed", async () => { - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: { providers: {} }, - run: async () => "done", - }); - const out = await callTask(tool, { - description: "bad-tier", - prompt: "x", - tier: "clever", - }); - expect(out).toContain("Error:"); - expect(out).toContain("clever"); - expect(out).toContain("not configured"); - }); - - test("task tier targeting OAuth provider resolves via live catalog", async () => { + test("profile inference targeting OAuth provider resolves via live catalog", async () => { let captured: RunSubAgentParams | undefined; - // Realistic disk shape: OAuth never lands in settings.json. Tiers name - // xAI; only the live catalog supplies the provider credentials. const diskSettings = { providers: {}, - tiers: { - clever: { provider: "xai/work", model: "grok-4" }, - standard: { provider: "xai/work", model: "grok-3" }, - fast: { provider: "xai/work", model: "grok-3-mini" }, - }, }; const catalog = [ - { - name: "codex/home", - baseURL: "https://chatgpt.com/backend-api", - apiKey: "codex-token", - models: ["gpt-5.3-codex"], - codexProfile: "home", - }, { name: "xai/work", baseURL: "https://api.x.ai/v1", apiKey: "xai-token", - models: ["grok-4", "grok-3", "grok-3-mini"], + models: ["grok-4"], xaiProfile: "work", }, ]; - const parentProvider = { - providerName: "codex/home", - baseURL: "https://chatgpt.com/backend-api", - apiKey: "codex-token", - model: "gpt-5.3-codex", - }; const tool = createTaskTool({ permissionGate: testPermissionGate, cwd: "/repo", getWorkdirBase: () => "/repo/.corbits", - provider: parentProvider, + provider, settings: diskSettings, catalog, + profiles: [ + { + id: "deep", + inference: { order: [{ provider: "xai/work", model: "grok-4" }] }, + }, + ], run: async (params) => { captured = params; return "done"; }, }); - await callTask(tool, { - description: "oauth-tier", - prompt: "x", - tier: "clever", - }); + await callTask(tool, { description: "oauth-profile-inference", prompt: "x", agent: "deep" }); expect(captured?.provider.providerName).toBe("xai/work"); expect(captured?.provider.model).toBe("grok-4"); expect(captured?.provider.apiKey).toBe("xai-token"); - expect(captured?.tier).toBe("clever"); }); - test("profile tier targeting OAuth resolves via live catalog", async () => { - let captured: RunSubAgentParams | undefined; - const diskSettings = { - providers: {}, - tiers: { - standard: { provider: "xai/work", model: "grok-3" }, - }, - }; - const catalog = [ - { - name: "xai/work", - baseURL: "https://api.x.ai/v1", - apiKey: "xai-token", - models: ["grok-3"], - xaiProfile: "work", - }, - ]; - const tool = createTaskTool({ - permissionGate: testPermissionGate, - cwd: "/repo", - getWorkdirBase: () => "/repo/.corbits", - provider, - settings: diskSettings, - catalog, - profiles: [{ id: "deep", tier: "standard" }], - run: async (params) => { - captured = params; - return "done"; - }, - }); - await callTask(tool, { description: "oauth-profile-tier", prompt: "x", agent: "deep" }); - expect(captured?.provider.providerName).toBe("xai/work"); - expect(captured?.provider.model).toBe("grok-3"); - expect(captured?.tier).toBe("standard"); - }); - - test("task tier targeting OAuth fails closed when catalog lacks the provider", async () => { + test("profile inference fails closed when pinned and unavailable", async () => { const tool = createTaskTool({ permissionGate: testPermissionGate, cwd: "/repo", @@ -1226,16 +1116,11 @@ describe("createTaskTool", () => { providers: { "api-only": { baseURL: "http://api", apiKey: "k", models: ["m"] }, }, - tiers: { - clever: { provider: "xai/missing", model: "grok-4" }, - }, }, - catalog: [ + profiles: [ { - name: "api-only", - baseURL: "http://api", - apiKey: "k", - models: ["m"], + id: "deep", + inference: { mode: "pin", order: [{ provider: "xai/missing", model: "grok-4" }] }, }, ], run: async () => "done", @@ -1243,10 +1128,10 @@ describe("createTaskTool", () => { const out = await callTask(tool, { description: "missing-oauth", prompt: "x", - tier: "clever", + agent: "deep", }); expect(out).toContain("Error:"); - expect(out).toContain("not configured"); + expect(out).toContain("unavailable"); }); test("rejects task maxTurns above the cap", async () => { diff --git a/src/subagent/run.ts b/src/subagent/run.ts index af3edc865..c2e279cb8 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -453,11 +453,10 @@ export async function runSubAgent(params: RunSubAgentParams): Promise { const head = { provider: params.provider.providerName, model: params.provider.model }; const bundle = - params.tier !== undefined && params.settings !== undefined && params.catalog !== undefined + params.settings !== undefined && params.catalog !== undefined ? buildSubagentSources({ settings: params.settings, catalog: params.catalog, - tier: params.tier, head, ...(params.provider.reasoningEffort !== undefined ? { reasoningEffort: params.provider.reasoningEffort } diff --git a/src/subagent/stop-policy.ts b/src/subagent/stop-policy.ts index 62194a0be..38c77513a 100644 --- a/src/subagent/stop-policy.ts +++ b/src/subagent/stop-policy.ts @@ -281,7 +281,7 @@ export function forcedStopReport( : reason === "stalled" ? "Leaf went quiet (e.g. parked on a long-running background command) past the stall timeout after an initial nudge; parent may re-dispatch to finish or check on the background work directly." : reason === "repetition" - ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns or tier alone." + ? "The model looped the same output window mid-stream; the tail of the loop is in Findings. Re-dispatching the identical brief will be refused and would likely loop again — change prompt/intent/success_criteria/do_not/agent, not maxTurns alone." : "Leaf turn budget exhausted; parent may re-dispatch for remaining work."; // Demote nested report-section headings so runSubAgent's parse/format pass // cannot clobber this outer Summary/Blockers with an agent-shaped envelope @@ -346,7 +346,7 @@ const THRASH_PARENT_HINT = "[Sub-agent stopped for progressive thrash (re-read pressure). Do not re-dispatch the identical brief (it will be refused) — change scope, success_criteria, and do_not; continue from Findings.]"; const REPETITION_PARENT_HINT = - "[Sub-agent aborted after its streamed output degenerated into a loop. Do not re-dispatch the identical brief — it will be refused and would likely loop again; change prompt, intent, success_criteria, do_not, and/or agent (tier alone does not change the fingerprint).]"; + "[Sub-agent aborted after its streamed output degenerated into a loop. Do not re-dispatch the identical brief — it will be refused and would likely loop again; change prompt, intent, success_criteria, do_not, and/or agent (maxTurns alone does not change the fingerprint).]"; const NO_PROGRESS_PARENT_HINT = "[Sub-agent stopped for no-progress (identical tool-call fingerprint). Do not re-dispatch the identical brief (it will be refused) — tighten success_criteria and do_not, or change approach.]"; diff --git a/src/subagent/task-tool.ts b/src/subagent/task-tool.ts index 4063f3134..0a481f28d 100644 --- a/src/subagent/task-tool.ts +++ b/src/subagent/task-tool.ts @@ -14,10 +14,9 @@ import type { import { runtimeSettingsWithCatalog, type ProviderCatalogEntry } from "../config/index.js"; import { formatSubAgentTaskAuthFailureMessage } from "./inference-auth-failure.js"; import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js"; -import type { Settings, ProviderTier } from "../config/settings.js"; +import type { Settings } from "../config/settings.js"; import { resolveSubAgentMaxTurns, - resolveTier, resolveInferenceWithPolicy, validateTaskMaxTurns, } from "../config/settings.js"; @@ -63,14 +62,13 @@ export const TaskToolArgs = type({ "do_not?": "string[]", "report_focus?": "string", "maxTurns?": "number", - "tier?": "'fast' | 'standard' | 'clever'", }); export const taskToolDefinition: ToolDefinition = { name: "task", description: - "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns or tier alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", + "Spawn a sub-agent (a short-lived child agent) for one self-contained job. This is not a checklist item — use manage_tasks for your own work list. The sub-agent has the full file, search, and shell toolset, uses this session's permission gate (saved grants and auto mode when eligible; you may be prompted for other consequential actions), and returns a structured report (Summary / Findings / Blockers / Paths). Use it to parallelize exploration (\"map every caller of X\") or hand off a well-scoped implementation so your own context stays focused. Fire several task calls in one turn to run sub-agents in parallel. When launching multiple agents with the same profile, assign each a distinct lens in description and prompt so they do not duplicate work. The sub-agent cannot ask you questions. Depending on dispatch configuration it either shares your working tree directly, or runs isolated in its own git worktree snapshotted from your last commit — in the isolated case, any uncommitted or untracked changes in your working tree are excluded. Write a clear brief: context = durable background; prompt = actionable goal; goals = optional manage_tasks seeds. Prefer the typed spawn contract so leaves finish without thrashing: intent (explore|implement|review|plan|general), success_criteria (done-when checklist), do_not (scope fence), report_focus (what Findings must cover). After thrash / no-progress / repetition / never-acted salvage, re-dispatching the identical brief (same prompt/agent/intent/success_criteria/do_not) is refused — change the brief to retry; maxTurns alone does not unlock it. Turn-budget salvage may invite a higher maxTurns a few times, then stops recommending re-dispatch until a successful complete resets the same-brief retry budget.", inputSchema: { type: "object", properties: { @@ -118,19 +116,13 @@ export const taskToolDefinition: ToolDefinition = { agent: { type: "string", description: - "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify tier, capability restrictions, and role. Role drives reasoning-effort defaults (orchestrator high, leaf medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", + "Optional agent profile id from search_agents (or .agents/agents/). Profiles specify capability restrictions and role. Role drives reasoning-effort defaults (orchestrator high, leaf medium) unless the profile pins inference.reasoningEffort; parent session effort is inheritance only when the role default is unsupported on the model.", }, maxTurns: { type: "number", description: "Optional inference-turn budget for this worker only (not the parent session limit). Defaults to settings or 30; hard cap 100.", }, - tier: { - type: "string", - enum: ["fast", "standard", "clever"], - description: - "Optional provider tier override for this spawn only (fast | standard | clever). Wins over profile inference and profile tier; fails closed when the tier is unconfigured.", - }, }, required: ["description", "prompt"], }, @@ -209,7 +201,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { do_not: rawDoNot, report_focus: rawReportFocus, maxTurns: rawMaxTurns, - tier: rawTaskTier, } = parsed; const description = rawDesc.trim(); const context = rawCtx?.trim(); @@ -234,21 +225,20 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { let provider: SubAgentProvider = typeof deps.provider === "function" ? deps.provider() : deps.provider; - // Snapshot parent effort before profile/tier rebuilds so role-default + // Snapshot parent effort before profile-inference rebuilds so role-default // resolution can fall back to inheritance without reading a mutated provider. const parentEffort = provider.reasoningEffort; - // Explicit profile inference / task-tier pin (if any). Distinct from the - // parent snapshot so resolveEffortForRole can apply pin > role > parent. + // Explicit profile inference pin (if any). Distinct from the parent + // snapshot so resolveEffortForRole can apply pin > role > parent. let effortPin: ReasoningEffort | undefined; let capabilities: CapabilityFilter | undefined; let systemPromptRole: string | undefined; let orchestrator = false; - let tier: ProviderTier | undefined; let profileMaxTurns: number | undefined; const diskSettings = deps.settings !== undefined ? resolveDep(deps.settings) : undefined; const catalog = deps.catalog !== undefined ? resolveDep(deps.catalog) : undefined; // OAuth providers live in the live catalog, not settings.json. Overlay so - // tier/inference resolution can target Codex/xAI the same way the TUI does. + // inference resolution can target Codex/xAI the same way the TUI does. const settings = catalog !== undefined ? runtimeSettingsWithCatalog(diskSettings, catalog) @@ -256,8 +246,8 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { const profiles = deps.profiles !== undefined ? resolveDep(deps.profiles) : undefined; // Rebuild provider from a resolved provider/model assignment. Shared by - // task(tier=), profile.inference, and profile.tier so fail-closed effort - // validation and settings lookup stay consistent. + // profile.inference so fail-closed effort validation and settings + // lookup stay consistent. const applyResolvedProvider = ( resolved: { provider: string; @@ -338,64 +328,23 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { if (profile.orchestrator === true && deps.allowOrchestrator !== false) { orchestrator = true; } - // Profile inference/tier apply only when task(tier=) is omitted — the - // caller override wins so a cheap/fast dispatch can still use a clever - // profile's tools without paying for the profile's pinned model. - if (rawTaskTier === undefined && settings !== undefined) { - // Per-agent pinned inference (provider/model/effort) wins over the - // tier alias when both are declared. Resolution uses policy - // (mode: pin / agentModelFallback: none) so a forbidden fallback - // surfaces as a dispatch error rather than silently running on the - // parent's provider. - let resolved: - | { provider: string; model: string; reasoningEffort?: ReasoningEffort } - | null = null; - if (profile.inference !== undefined) { - const outcome = resolveInferenceWithPolicy(profile.inference, settings); - if (outcome.kind === "unavailable") { - return taskToolResult( - call.id, - `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, - ); - } - if (outcome.kind === "resolved") resolved = outcome.value; - } - if (resolved === null && profile.tier !== undefined) { - const assignment = resolveTier(profile.tier as ProviderTier, settings); - if (assignment !== null) { - resolved = assignment; - } + // Per-agent pinned inference (provider/model/effort), if declared. + // Resolution uses policy (mode: pin / agentModelFallback: none) so a + // forbidden fallback surfaces as a dispatch error rather than + // silently running on the parent's provider. + if (profile.inference !== undefined && settings !== undefined) { + const outcome = resolveInferenceWithPolicy(profile.inference, settings); + if (outcome.kind === "unavailable") { + return taskToolResult( + call.id, + `Error: agent "${agentId}" unavailable: ${outcome.reason}. Set agentModelFallback: "active" (or change the spec mode to "prefer") to fall back to the active session.`, + ); } - if (resolved !== null) { - const err = applyResolvedProvider(resolved, `agent "${agentId}"`); + if (outcome.kind === "resolved") { + const err = applyResolvedProvider(outcome.value, `agent "${agentId}"`); if (err !== null) return taskToolResult(call.id, err); } - if (profile.tier !== undefined) { - tier = profile.tier as ProviderTier; - } - } - } - - // task(tier=) is highest precedence: overrides profile inference/tier and - // the parent provider. Fail closed when settings or the tier chain is missing. - if (rawTaskTier !== undefined) { - const taskTier = rawTaskTier as ProviderTier; - if (settings === undefined) { - return taskToolResult( - call.id, - `Error: task tier "${taskTier}" requires configured settings.providers.`, - ); - } - const assignment = resolveTier(taskTier, settings); - if (assignment === null) { - return taskToolResult( - call.id, - `Error: task tier "${taskTier}" is not configured. Set settings.tiers.${taskTier} (or the legacy tier assignment) before dispatching.`, - ); } - const err = applyResolvedProvider(assignment, `task tier "${taskTier}"`); - if (err !== null) return taskToolResult(call.id, err); - tier = taskTier; } // Role-based effort: pin > orchestrator/leaf default > parent inheritance. @@ -578,7 +527,6 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool { cwd: worktreeCwd ?? deps.cwd, workdirBase: deps.getWorkdirBase(), provider, - ...(tier !== undefined ? { tier } : {}), ...(settings !== undefined ? { settings } : {}), ...(catalog !== undefined ? { catalog } : {}), description, diff --git a/src/subagent/types.ts b/src/subagent/types.ts index 002f497fe..2599842e0 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -10,7 +10,7 @@ import type { ToolPlugin } from "@intx/tools-posix"; import type { CapabilityFilter, AgentProfile } from "../agent/profiles.js"; import type { ProviderCatalogEntry } from "../config/index.js"; -import type { Settings, ProviderTier } from "../config/settings.js"; +import type { Settings } from "../config/settings.js"; import type { ShellTimeoutConfig } from "../plugins/shell-guard-plugin.js"; import type { PermissionGate } from "../permission/gate.js"; import type { ReasoningEffort } from "../provider/reasoning-effort.js"; @@ -27,9 +27,9 @@ export type SubAgentProvider = { // resolveEffortForRole — leaves default to medium, orchestrators to high, // so a primary /agent high selection does not force every leaf onto high. reasoningEffort?: ReasoningEffort; - // Mirrors ProviderCatalogEntry.bifrostVirtualKey. Without it the generic - // (no-tier) dispatch path builds a plain openai-compatible source and the - // gateway never receives the x-bf-vk header. + // Mirrors ProviderCatalogEntry.bifrostVirtualKey. Without it the dispatch + // path builds a plain openai-compatible source and the gateway never + // receives the x-bf-vk header. bifrostVirtualKey?: boolean; }; @@ -71,7 +71,6 @@ export type RunSubAgentParams = { cwd: string; workdirBase: string; provider: SubAgentProvider; - tier?: ProviderTier; settings?: Settings; catalog?: readonly ProviderCatalogEntry[]; description: string; diff --git a/src/tui-opentui/command-surfaces.ts b/src/tui-opentui/command-surfaces.ts index 72e77ccfa..61a45a01f 100644 --- a/src/tui-opentui/command-surfaces.ts +++ b/src/tui-opentui/command-surfaces.ts @@ -52,7 +52,7 @@ export type PluginEntry = { readonly canRevokeTrust?: boolean readonly credentials: readonly PluginCredentialFieldEntry[] readonly credentialValues: Readonly> - readonly agentProfiles?: readonly { readonly id: string; readonly tier?: string; readonly description?: string }[] + readonly agentProfiles?: readonly { readonly id: string; readonly description?: string }[] /** Absolute path an untrusted path-origin plugin was discovered at. */ readonly originPath?: string } diff --git a/src/tui/command-registry-setup.test.ts b/src/tui/command-registry-setup.test.ts index b49d097fc..76138cb09 100644 --- a/src/tui/command-registry-setup.test.ts +++ b/src/tui/command-registry-setup.test.ts @@ -16,14 +16,6 @@ describe("session command registry setup", () => { expect(getCommand("goal")).toBeDefined(); }); - test("hides tier commands until a tier is configured", () => { - setUpCommandRegistry(undefined, []); - expect(listCommands().map((c) => c.name)).not.toContain("fast"); - - setUpCommandRegistry({ providers: {}, tiers: { fast: { provider: "p", model: "m" } } }, []); - expect(listCommands().map((c) => c.name)).toContain("fast"); - }); - test("applies hidden commands from settings", () => { setUpCommandRegistry({ providers: {}, hiddenCommands: ["help"] }, []); expect(listCommands().map((c) => c.name)).not.toContain("help"); diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 5c9623320..7ac1228de 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "bun:test"; -import { getCommand, listCommands } from "./registry.js"; +import { getCommand } from "./registry.js"; import type { CommandContext } from "./registry.js"; -import { registerBuiltInCommands, setConfiguredTiers } from "./built-in.js"; +import { registerBuiltInCommands } from "./built-in.js"; import { buildCostSummary } from "../../cost/cost-summary.js"; registerBuiltInCommands(); @@ -93,37 +93,11 @@ describe("/new command", () => { }); }); -describe("tier commands", () => { - it("registers a slash command for each provider tier", () => { - expect(getCommand("fast")).toBeDefined(); - expect(getCommand("standard")).toBeDefined(); - expect(getCommand("clever")).toBeDefined(); - }); - - it("each emits a tier-switch intent carrying its tier name", () => { - for (const tier of ["fast", "standard", "clever"] as const) { - expect(getCommand(tier)!.handler("", makeCtx())).toEqual({ type: "tier", tier }); - } - }); - - it("are hidden from the menu until configured, then appear", () => { - // Reset to nothing configured: no tier command surfaces in the menu. - setConfiguredTiers({}); - let names = listCommands().map((c) => c.name); - expect(names).not.toContain("fast"); - expect(names).not.toContain("standard"); - expect(names).not.toContain("clever"); - - // Still callable directly — visibility is display-only, never a hard gate. - expect(getCommand("fast")).toBeDefined(); - - setConfiguredTiers({ fast: { provider: "fp", model: "fp-large" } }); - names = listCommands().map((c) => c.name); - expect(names).toContain("fast"); - expect(names).not.toContain("standard"); - expect(names).not.toContain("clever"); - - setConfiguredTiers({}); +describe("removed tier commands", () => { + it("/fast, /standard, /clever are not registered", () => { + expect(getCommand("fast")).toBeUndefined(); + expect(getCommand("standard")).toBeUndefined(); + expect(getCommand("clever")).toBeUndefined(); }); }); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index f84102951..4bad7a493 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -1,5 +1,4 @@ import { registerCommand } from "./registry.js"; -import { PROVIDER_TIERS, type ProviderTier, type TierConfig } from "../../config/settings.js"; import { formatGoalStatus, type GoalSetOpts } from "../../agent/goal.js"; import { formatCostCommandOutput } from "../../cost/cost-summary.js"; import { @@ -8,19 +7,6 @@ import { resolveChangelogPath, } from "../../changelog/index.js"; -// Which tiers are currently assigned. Defaults to empty so /fast, /standard, -// /clever stay out of the slash menu until the user configures one; the runner -// syncs this whenever tier state changes. getCommand still resolves them -// regardless, so an in-flight reconfigure never strands a typed command. -const configuredTiers = new Set(); - -export function setConfiguredTiers(tiers: Partial>): void { - configuredTiers.clear(); - for (const tier of PROVIDER_TIERS) { - if (tiers[tier] !== undefined) configuredTiers.add(tier); - } -} - const GOAL_CLEAR_ALIASES = new Set(["clear", "stop", "off", "reset", "none", "cancel"]); /** @@ -304,16 +290,4 @@ export function registerBuiltInCommands(): void { }, }); - // One slash command per provider tier so a configured tier is one keystroke to - // switch to. The handler only emits the intent; the runner resolves the tier's - // current provider+model against live state and applies it, so a tier reassigned - // mid-session via /model takes effect immediately on the next / call. - for (const tier of PROVIDER_TIERS) { - registerCommand({ - name: tier, - description: `Switch the active model to the ${tier} tier`, - handler: () => ({ type: "tier", tier: tier as ProviderTier }), - available: () => configuredTiers.has(tier), - }); - } } diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index b97b073a9..451cdd57c 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -1,4 +1,3 @@ -import type { ProviderTier } from "../../config/settings.js"; import type { GoalSnapshot, GoalSetOpts, GoalResumeOpts } from "../../agent/goal.js"; import type { CostSummary } from "../../cost/cost-summary.js"; @@ -30,7 +29,6 @@ export type CommandResult = | { type: "modal"; modal: "agent" | "codex-login" | "xai-login" } | { type: "workflow"; name: string; args?: string } | { type: "paste-image" } - | { type: "tier"; tier: ProviderTier } | { type: "noop" }; export type SubcommandDefinition = { @@ -51,7 +49,7 @@ export type CommandDefinition = { handler: (args: string, ctx: CommandContext) => CommandResult; // Optional visibility gate. When present and returns false the command is // omitted from listCommands (the slash menu) but still callable via - // getCommand — so a tier command stays resolvable even mid-reconfigure. + // getCommand. available?: () => boolean; }; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 4fe5821c9..38b0bb26c 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -24,7 +24,6 @@ import { localSettingsPath, markTelemetryNoticeShown, pushRecentModel, - resolveTier, saveGlobalSettings, saveLocalSettings, shellTimeoutFromSettings, @@ -35,7 +34,6 @@ import { type Settings, type LocalSettings, type PluginConfig, - type ProviderTier, } from "../config/settings.js"; import { providerChoices } from "../tui-opentui/provider-setup.js"; import type { SessionModeScope } from "../tui-opentui/command-surfaces.js"; @@ -79,7 +77,7 @@ import { type CommandContext, type CommandResult, } from "./commands/registry.js"; -import { registerBuiltInCommands, setConfiguredTiers } from "./commands/built-in.js"; +import { registerBuiltInCommands } from "./commands/built-in.js"; import type { PluginModule } from "../plugins/loader.js"; import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; import { TELEMETRY_NOTICE } from "../telemetry/index.js"; @@ -111,7 +109,6 @@ import { createChatDirector } from "../agent/director.js"; import { createGoalGovernor } from "../agent/goal.js"; import { createGoalEvaluator } from "../agent/goal-evaluator.js"; import { loadGoalState, saveGoalState } from "../session/goal-state.js"; -import { buildInferenceSourceForRef, tierProviderRefs } from "../config/inference-sources.js"; import { loadAgentProfiles, type AgentProfile } from "../agent/profiles.js"; import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js"; import { createPermissionGate } from "../permission/gate.js"; @@ -375,7 +372,6 @@ export function setUpCommandRegistry( ): void { const pluginConfig = settings?.plugins ?? {}; registerBuiltInCommands(); - setConfiguredTiers(settings?.tiers ?? {}); registerWorkflowPlugins(plugins, pluginConfig); registerCommandPlugins(plugins, pluginConfig); setHiddenCommands(settings?.hiddenCommands ?? []); @@ -709,7 +705,7 @@ export async function runTUI(initialConfig: Config): Promise { .map((m) => toDescriptor(m)) .filter((d): d is PluginDescriptor => d !== undefined); // Attach agent profiles to their descriptors so the /plugins UI can show - // which sub-agents and tiers a plugin contributes. + // which sub-agents a plugin contributes. for (const mod of livePluginModules) { if (mod.manifest?.kind !== "agent" || mod.agentPlugin === undefined) continue; const desc = pluginDescriptors.find((d) => d.id === mod.manifest!.id); @@ -719,7 +715,6 @@ export async function runTUI(initialConfig: Config): Promise { .filter((a): a is Record => typeof a === "object" && a !== null && "id" in a) .map((a) => ({ id: String(a["id"]), - ...(typeof a["tier"] === "string" ? { tier: a["tier"] } : {}), ...(typeof a["description"] === "string" ? { description: a["description"] } : {}), })); } @@ -809,8 +804,7 @@ export async function runTUI(initialConfig: Config): Promise { await persistPluginSettings(); }, verify: async (id, credentials) => { - // Agent plugins verify by checking they contribute valid profiles and - // that each profile's tier resolves to a configured provider. + // Agent plugins verify by checking they contribute valid profiles. const agentMod = livePluginModules.find((m) => m.manifest?.id === id && m.manifest?.kind === "agent"); if (agentMod !== undefined) { const verifyDiag = createPluginLoadDiagnostics(); @@ -821,14 +815,7 @@ export async function runTUI(initialConfig: Config): Promise { ); emitPluginWarningSummary(verifyDiag); if (profiles.length === 0) return { ok: false, message: "No valid agent profiles found" }; - // Check tier resolution so the user knows if the provider is configured. - const unresolved = profiles.filter( - (p) => p.tier !== undefined && resolveTier(p.tier as ProviderTier, config.settings ?? { providers: {} }) === null, - ); - const tierHint = unresolved.length > 0 - ? ` (${unresolved.length} unresolved tier${unresolved.length === 1 ? "" : "s"} — set in /model → tiers)` - : ""; - return { ok: true, message: `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}${tierHint}` }; + return { ok: true, message: `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}` }; } // Tool plugins verify by loading (the factory must construct without // error and yield at least one tool). @@ -1228,31 +1215,10 @@ export async function runTUI(initialConfig: Config): Promise { } // Goal governor survives director rebuilds; reattached in the factory below. - // Evaluator prefers the fast tier when configured, else the live session model. - // Fail-open if inference fails. + // Evaluator runs on the live session model. const goalGovernor = createGoalGovernor({ evaluate: createGoalEvaluator({ - getSource: () => { - const settings = config.settings; - const refs = tierProviderRefs("fast", settings, { fallbackChain: true }); - const head = refs[0]; - - if (head !== undefined) { - const fast = buildInferenceSourceForRef( - head, - { - sessionId, - catalog: config.providers, - ...(config.reasoningEffort !== undefined - ? { reasoningEffort: config.reasoningEffort } - : {}), - }, - settings, - ); - if (fast !== null) return fast; - } - return liveSource; - }, + getSource: () => liveSource, deps: inferenceDeps, }), onChange: (snap) => { @@ -1861,9 +1827,6 @@ export async function runTUI(initialConfig: Config): Promise { case "workflow": systemRow(workflowController.start(result.name)); return; - case "tier": - systemRow(`Tier ${result.tier} selected`); - return; case "noop": return; case "overlay": diff --git a/tests/unit/data-only-agent.test.ts b/tests/unit/data-only-agent.test.ts index 73c947543..f7c0962e6 100644 --- a/tests/unit/data-only-agent.test.ts +++ b/tests/unit/data-only-agent.test.ts @@ -157,22 +157,22 @@ describe("loadDataOnlyAgentPlugin", () => { expect(agent.capabilities!.tools).toEqual(["run_shell"]); }); - test("tier alias is accepted", async () => { + test("bare tier frontmatter is ignored (tiers were removed)", async () => { const dir = await makePlugin({ "agents/a.md": "---\ntier: clever\n---\nbody\n", }); const plugin = await loadDataOnlyAgentPlugin(dir, { pluginId: "p" }); - const agent = plugin!.agentPlugin.agents[0] as { tier?: string }; - expect(agent.tier).toBe("clever"); + const agent = plugin!.agentPlugin.agents[0] as { inference?: unknown }; + expect(agent.inference).toBeUndefined(); }); - test("Claude Code effort:high maps to tier clever", async () => { + test("bare Claude Code effort:high is ignored without a model to attach it to", async () => { const dir = await makePlugin({ "agents/a.md": "---\neffort: high\n---\nbody\n", }); const plugin = await loadDataOnlyAgentPlugin(dir, { pluginId: "p" }); - const agent = plugin!.agentPlugin.agents[0] as { tier?: string }; - expect(agent.tier).toBe("clever"); + const agent = plugin!.agentPlugin.agents[0] as { inference?: unknown }; + expect(agent.inference).toBeUndefined(); }); test("native inference block (single leg) is accepted", async () => { diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index 06521db7f..c4974f793 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -1,15 +1,8 @@ import { test, expect } from "bun:test"; import { - appendTierEntry, buildInferenceSourceForRef, buildMainSessionSources, buildSubagentSources, - cycleTierMode, - formatTierChain, - moveTierLeg, - normalizeTierDefinition, - removeTierLeg, - tierProviderRefs, } from "../../src/config/inference-sources.js"; import type { Settings } from "../../src/config/settings.js"; @@ -40,35 +33,6 @@ const catalog: ProviderCatalogEntry[] = [ }, ]; -test("normalizeTierDefinition upgrades legacy assignment to pin chain", () => { - const def = normalizeTierDefinition({ provider: "openai", model: "gpt-4o" }); - expect(def).toEqual({ mode: "pin", order: [{ provider: "openai", model: "gpt-4o" }] }); -}); - -test("appendTierEntry prepends without duplicate refs", () => { - const next = appendTierEntry( - { mode: "prefer", order: [{ provider: "openai", model: "gpt-4o" }] }, - { provider: "local", model: "llama" }, - ); - expect(next.order.map((r) => r.provider)).toEqual(["local", "openai"]); - const again = appendTierEntry(next, { provider: "openai", model: "gpt-4o" }); - expect(again.order).toHaveLength(2); -}); - -test("prefer mode appends settings providers as fallback tail", () => { - const settings: Settings = { - providers: { - openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-4o"] }, - local: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama"] }, - }, - tiers: { - fast: { mode: "prefer", order: [{ provider: "openai", model: "gpt-4o-mini" }] }, - }, - }; - const refs = tierProviderRefs("fast", settings, { fallbackChain: true }); - expect(refs.map((r) => `${r.provider}/${r.model}`)).toEqual(["openai/gpt-4o-mini", "local/llama"]); -}); - test("buildInferenceSourceForRef uses bifrost provider when flag set", () => { const source = buildInferenceSourceForRef( { provider: "bifrost", model: "gpt-4o" }, @@ -79,7 +43,7 @@ test("buildInferenceSourceForRef uses bifrost provider when flag set", () => { expect(source?.baseURL).toBe("http://localhost:8080/v1"); }); -test("buildInferenceSourceForRef applies tier leg reasoning effort", () => { +test("buildInferenceSourceForRef applies leg reasoning effort", () => { const settings: Settings = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-5"] }, @@ -93,18 +57,12 @@ test("buildInferenceSourceForRef applies tier leg reasoning effort", () => { expect(source?.defaults?.providerOptions).toEqual({ reasoning_effort: "high" }); }); -test("buildMainSessionSources uses standard tier chain with active head", () => { +test("buildMainSessionSources backs the active head with other configured providers", () => { const settings: Settings = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-4o", "gpt-4o-mini"] }, local: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama"] }, }, - tiers: { - standard: { - mode: "prefer", - order: [{ provider: "openai", model: "gpt-4o-mini" }], - }, - }, }; const bundle = buildMainSessionSources({ settings, @@ -117,37 +75,21 @@ test("buildMainSessionSources uses standard tier chain with active head", () => expect(bundle.defaultSource).toBe("openai"); }); -test("buildSubagentSources uses full fallback chain for tier", () => { +test("buildSubagentSources backs the head with other configured providers", () => { const settings: Settings = { providers: { openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-4o"] }, local: { baseURL: "http://localhost:11434/v1", keyless: true, models: ["llama"] }, }, - tiers: { - clever: { mode: "prefer", order: [{ provider: "openai", model: "gpt-4o" }] }, - }, }; const bundle = buildSubagentSources({ settings, catalog: [...catalog], - tier: "clever", head: { provider: "openai", model: "gpt-4o" }, sessionId: "sub", }); expect(bundle.sources.length).toBeGreaterThanOrEqual(2); -}); - -test("cycleTierMode toggles pin and prefer", () => { - expect(cycleTierMode({ mode: "pin", order: [] }).mode).toBe("prefer"); - expect(formatTierChain({ mode: "pin", order: [{ provider: "a", model: "m" }] })).toContain("pin"); -}); - -test("formatTierChain shows reasoning effort on legs", () => { - const label = formatTierChain({ - mode: "pin", - order: [{ provider: "openai", model: "gpt-5", reasoningEffort: "high" }], - }); - expect(label).toContain("gpt-5@high"); + expect(bundle.defaultSource).toBe("openai"); }); test("buildInferenceSourceForRef routes OpenCode Go models by protocol", () => { @@ -209,11 +151,3 @@ test("buildInferenceSourceForRef uses anthropic provider when flag set", () => { expect(source?.provider).toBe("anthropic"); expect(source?.baseURL).toBe("https://api.anthropic.com"); }); - -test("removeTierLeg and moveTierLeg edit the chain", () => { - const def = { mode: "prefer" as const, order: [{ provider: "a", model: "1" }, { provider: "b", model: "2" }] }; - const one = removeTierLeg(def, 0); - expect(one?.order.map((r) => r.provider)).toEqual(["b"]); - expect(removeTierLeg(one, 0)).toBeUndefined(); - expect(moveTierLeg(def, 1, -1)?.order.map((r) => r.provider)).toEqual(["b", "a"]); -}); \ No newline at end of file diff --git a/tests/unit/tier-failover-chain.test.ts b/tests/unit/tier-failover-chain.test.ts deleted file mode 100644 index 263eebdd4..000000000 --- a/tests/unit/tier-failover-chain.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { test, expect } from "bun:test"; -import { createSourceRegistry } from "@intx/agent"; -import { buildMainSessionSources } from "../../src/config/inference-sources.js"; -import type { ProviderCatalogEntry } from "../../src/config/index.js"; -import type { Settings } from "../../src/config/settings.js"; - -const catalog: ProviderCatalogEntry[] = [ - { - name: "primary", - baseURL: "https://primary.test/v1", - apiKey: "pk", - models: ["big"], - defaultModel: "big", - }, - { - name: "fallback", - baseURL: "https://fallback.test/v1", - apiKey: "fk", - models: ["small"], - defaultModel: "small", - }, -]; - -test("main session source list matches agent registry failover order", () => { - const settings: Settings = { - providers: { - primary: { baseURL: catalog[0]!.baseURL, apiKey: "pk", models: ["big"] }, - fallback: { baseURL: catalog[1]!.baseURL, apiKey: "fk", models: ["small"] }, - }, - tiers: { - standard: { - mode: "pin", - order: [{ provider: "fallback", model: "small" }], - }, - }, - }; - - const bundle = buildMainSessionSources({ - settings, - catalog, - activeProvider: "primary", - activeModel: "big", - sessionId: "sess", - }); - - expect(bundle.sources.map((s) => s.id)).toEqual(["primary", "fallback"]); - expect(bundle.defaultSource).toBe("primary"); - - const reg = createSourceRegistry({ - sources: bundle.sources, - defaultSource: bundle.defaultSource, - }); - expect(reg.active.id).toBe("primary"); - expect(reg.failOverToNextSource()).toBe(true); - expect(reg.active.id).toBe("fallback"); - expect(reg.failOverToNextSource()).toBe(false); -}); \ No newline at end of file From 732dfbac997b930b82447b563502a44c3473c70d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:55:17 -0700 Subject: [PATCH 3/3] Pick a provider first, then a model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker was one flat list of every provider-account-model combination — 30+ rows, the same models repeated once per account, providers interleaved. Group it: the top level now shows one row per provider (each account is already its own catalog entry, e.g. codex/abk-labs vs codex/dirtroad, so no extra grouping key is needed), selecting one descends into that provider's models, and Escape at the model level steps back to the provider level instead of closing the picker. Recent and favorite picks stay flat at the top so the default model is reachable without descending, same as before. The active model's row (and its provider group, when the row itself is not already surfaced via Recent) reads "(current)" so the pick is identifiable at a glance. Both levels reuse the same scrolling list overlay the picker already used, so a short terminal scrolls within the picker rather than overflowing it. Escape only stepped back for permissions/operator overlays before — extended to model_picker so the provider level can be restored via the same dismiss path, harmless no-op for a caller that sets no onCancel. The description-zone fallback for a provider row shows its model count so the zone is never blank before descending. --- src/tui-opentui/overlays.ts | 3 + src/tui-opentui/product-host.test.ts | 159 ++++++++++++++++++++++++++- src/tui-opentui/product-host.ts | 138 ++++++++++++++++++++++- src/tui-opentui/runner-host.test.ts | 7 +- src/tui-opentui/shell.ts | 9 +- 5 files changed, 305 insertions(+), 11 deletions(-) diff --git a/src/tui-opentui/overlays.ts b/src/tui-opentui/overlays.ts index 91ef61f39..b8e20c83e 100644 --- a/src/tui-opentui/overlays.ts +++ b/src/tui-opentui/overlays.ts @@ -170,6 +170,8 @@ export type OpenModelPickerOpts = { readonly describe?: (itemId: string) => ItemDescription | null /** Bare-key claim on the focused row (e.g. `f` to toggle favorite). */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean + /** Per-open Esc/dismiss — the provider-first picker steps back to the provider level instead of closing outright. */ + readonly onCancel?: () => void } export function openModelPickerOverlay( @@ -186,5 +188,6 @@ export function openModelPickerOverlay( ...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), ...(opts?.describe !== undefined ? { describe: opts.describe } : {}), ...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}), + ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), }) } diff --git a/src/tui-opentui/product-host.test.ts b/src/tui-opentui/product-host.test.ts index af0ec75ff..daa4c08cf 100644 --- a/src/tui-opentui/product-host.test.ts +++ b/src/tui-opentui/product-host.test.ts @@ -6,13 +6,14 @@ import { EventEmitter } from "node:events" import { describe, expect, test } from "bun:test" import type { PermissionRequest } from "../permission/types.js" import { createHarness } from "./harness.js" -import { acceptOverlaySelection } from "./shell.js" +import { acceptOverlaySelection, closeInsetOverlay, moveOverlaySelection } from "./shell.js" import { mountProductHost, operatorResultFromSelection, permissionChoices, type ProductHostConfig, } from "./product-host.js" +import { buildModelsFirstCatalog } from "./model-catalog.js" function makeFakeSessionPort(): { readonly sends: string[] @@ -271,6 +272,162 @@ describe("mountProductHost", () => { }) }) +describe("provider-first model picker", () => { + // Mirrors the bug-report shape: several providers, one (codex) with three + // accounts, plus a favorite so the top level has a reachable-without-descending pick. + const providers = { + "codex/abk-labs": { models: ["gpt-5.5", "gpt-5.6-sol"] }, + "codex/dirtroad": { models: ["gpt-5.5", "gpt-5.6-sol"] }, + "codex/fleur": { models: ["gpt-5.5", "gpt-5.6-sol"] }, + "xai/thegreataxios": { models: ["grok-4.5"] }, + "Z.AI": { models: ["glm-5", "glm-5-turbo", "glm-5.2"] }, + } + + async function mountPicker(overrides: Partial = {}) { + const harness = await createHarness({ width: 80, height: 24 }) + const port = makeFakeSessionPort() + const catalog = buildModelsFirstCatalog({ + providers, + favorites: [{ provider: "codex/abk-labs", model: "gpt-5.5" }], + }) + const selected: string[] = [] + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + onModelSelect: (id) => selected.push(id), + ...overrides, + }) + return { harness, host, selected } + } + + test("top level lists providers (one row per account), not one row per model", async () => { + const { harness, host } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + // Each codex account is its own row; the account name appears once, + // not once per model it exposes. + expect(frame).toContain("codex/abk-labs") + expect(frame).toContain("codex/dirtroad") + expect(frame).toContain("codex/fleur") + expect(frame).toContain("xai/thegreataxios") + // The favorite is a leaf row, reachable without descending — it, not + // its provider group, carries the model name at the top level. + expect(frame).toContain("gpt-5.5") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("selecting a provider descends into its models; Escape returns to the provider level", async () => { + const { harness, host } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + + const items = host.shell.overlayItems + const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios")) + expect(xaiIndex).toBeGreaterThanOrEqual(0) + moveOverlaySelection(host.shell, xaiIndex) + acceptOverlaySelection(host.shell) + await harness.renderOnce() + + const modelFrame = harness.captureCharFrame() + expect(modelFrame).toContain("grok-4.5") + expect(modelFrame).not.toContain("codex/abk-labs") + + closeInsetOverlay(host.shell) + await harness.renderOnce() + const backFrame = harness.captureCharFrame() + expect(backFrame).toContain("codex/abk-labs") + expect(host.shell.overlayList).not.toBeNull() + } finally { + host.dispose() + harness.destroy() + } + }) + + test("selecting a model at the model level applies the pick", async () => { + const { harness, host, selected } = await mountPicker() + try { + host.openModels?.() + await harness.renderOnce() + const items = host.shell.overlayItems + const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios")) + moveOverlaySelection(host.shell, xaiIndex) + acceptOverlaySelection(host.shell) + await harness.renderOnce() + + acceptOverlaySelection(host.shell) + expect(selected).toEqual(["xai/thegreataxios:grok-4.5"]) + } finally { + host.dispose() + harness.destroy() + } + }) + + test("the current model's row reads \"(current)\" at a glance", async () => { + const harness = await createHarness({ width: 80, height: 24 }) + const port = makeFakeSessionPort() + const catalog = buildModelsFirstCatalog({ providers, recent: [{ provider: "xai/thegreataxios", model: "grok-4.5" }] }) + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + onModelSelect: () => {}, + }) + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + expect(frame).toContain("xai/thegreataxios / grok-4.5 (current)") + } finally { + host.dispose() + harness.destroy() + } + }) + + test("fits and scrolls within a short terminal instead of overflowing it", async () => { + const port = makeFakeSessionPort() + const harness = await createHarness({ width: 80, height: 10 }) + try { + const catalog = buildModelsFirstCatalog({ providers }) + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + onModelSelect: () => {}, + }) + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + // Five provider rows do not all fit a 10-row terminal alongside the + // overlay chrome; the picker renders without throwing and the frame + // stays within the terminal's own line count. + expect(frame.replace(/\n$/, "").split("\n").length).toBeLessThanOrEqual(10) + expect(host.shell.overlayList).not.toBeNull() + } finally { + host.dispose() + } + } finally { + harness.destroy() + } + }) +}) + describe("mount failure", () => { test("destroys the renderer when gate wiring throws", async () => { const harness = await createHarness({ width: 80, height: 24 }) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index de6bf83ad..1af9cc838 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -58,6 +58,72 @@ import type { StreamRow } from "./stream.js" import type { PendingImageAttachment } from "../tui/image-attachments.js" +const PROVIDER_GROUP_PREFIX = "providerGroup:" + +function providerGroupRowId(provider: string): string { + return `${PROVIDER_GROUP_PREFIX}${provider}` +} + +function providerFromGroupRowId(id: string): string | null { + return id.startsWith(PROVIDER_GROUP_PREFIX) ? id.slice(PROVIDER_GROUP_PREFIX.length) : null +} + +/** Provider (account) segment of a `provider:model` row id. */ +function providerOfRowId(id: string): string { + const i = id.indexOf(":") + return i === -1 ? id : id.slice(0, i) +} + +/** Provider label segment of a `Provider Label / model` row label. */ +function providerLabelOfRow(label: string): string { + const i = label.indexOf(" / ") + return i === -1 ? label : label.slice(0, i) +} + +type ModelGroup = { + readonly label: string + readonly rows: ProductHostModelOption[] +} + +/** + * Split a flat, section-tagged models list into the provider-first picker's + * top level (recent/favorites/unconnected pass through flat; each distinct + * provider collapses into one group row, in first-seen order) plus the + * per-provider model rows reached by descending into a group. Rows with no + * `section` (a caller not using buildModelsFirstCatalog) pass through + * ungrouped, preserving today's single-level picker for that caller. + */ +function groupModelsForPicker( + models: readonly ProductHostModelOption[], +): { readonly top: ProductHostModelOption[]; readonly groups: ReadonlyMap } { + const top: ProductHostModelOption[] = [] + const groups = new Map() + for (const row of models) { + if (row.section !== "provider") { + top.push(row) + continue + } + const provider = providerOfRowId(row.id) + let group = groups.get(provider) + if (group === undefined) { + group = { label: providerLabelOfRow(row.label), rows: [] } + groups.set(provider, group) + top.push({ id: providerGroupRowId(provider), label: group.label, section: "provider" }) + } + group.rows.push(row) + } + return { top, groups } +} + +/** Suffix the row matching `activeId` (if any) so it reads as the current pick. */ +function annotateCurrent( + rows: readonly ProductHostModelOption[], + activeId: string | undefined, +): ProductHostModelOption[] { + if (activeId === undefined) return [...rows] + return rows.map((r) => (r.id === activeId ? { ...r, label: `${r.label} (current)` } : r)) +} + export type ProductHostSend = ( text: string, attachments?: readonly PendingImageAttachment[], @@ -69,9 +135,19 @@ export type ProductHostDeliver = ( attachments?: readonly PendingImageAttachment[], ) => void +/** + * `section` groups rows for the provider-first picker: "recent" and + * "favorites" stay flat at the top (already single models, reachable without + * descending); "provider" rows are grouped into one top-level entry per + * provider (or per account, since each configured provider entry is already + * account-scoped — `codex/abk-labs`, `codex/dirtroad`); "unconnected" stays + * flat as a "connect →" row. Omitted (from a caller not using + * buildModelsFirstCatalog) falls back to one flat list, unwrapped. + */ export type ProductHostModelOption = { readonly id: string readonly label: string + readonly section?: "recent" | "favorites" | "provider" | "unconnected" } export type ProductHostConfig = { @@ -433,21 +509,47 @@ export async function mountProductHost( const onSelect = config.onModelSelect const onConnect = config.onConnectProvider const onFavoriteToggle = config.onFavoriteToggle - openModels = (): void => { + + // Provider rows have no catalog entry of their own to describe; fall back + // to a plain model count so the description zone is never blank. + const describe = (itemId: string): ItemDescription | null => { + const groupProvider = providerFromGroupRowId(itemId) + if (groupProvider !== null) { + const { groups } = groupModelsForPicker(currentModels) + const count = groups.get(groupProvider)?.rows.length ?? 0 + return { + what: `${count} model${count === 1 ? "" : "s"} available.`, + impact: "Press Enter to see them.", + tone: "plain", + } + } + return currentDescribeModel?.(itemId) ?? null + } + + const openLevel = (items: readonly ProductHostModelOption[], onCancel?: () => void): void => { openModelPickerOverlay(shell, { - items: currentModels.map((m) => m.label), - itemIds: currentModels.map((m) => m.id), + items: items.map((m) => m.label), + itemIds: items.map((m) => m.id), onAccept: (sel) => { - const id = sel.id ?? currentModels[sel.index]?.id + const id = sel.id ?? items[sel.index]?.id if (!id) return const providerName = id.startsWith("connect:") ? id.slice("connect:".length) : null if (providerName !== null) { onConnect?.(providerName) return } + const groupProvider = providerFromGroupRowId(id) + if (groupProvider !== null) { + const { groups } = groupModelsForPicker(currentModels) + const group = groups.get(groupProvider) + if (group !== undefined) { + openLevel(annotateCurrent(group.rows, activeModelId()), openModels) + } + return + } onSelect(id) }, - ...(currentDescribeModel !== undefined ? { describe: currentDescribeModel } : {}), + describe, ...(onFavoriteToggle !== undefined ? { onAction: (itemId, key) => { @@ -455,14 +557,38 @@ export async function mountProductHost( // bare letter narrows the list instead of toggling a favorite. const name = typeof key.name === "string" ? key.name.toLowerCase() : "" if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false - if (itemId.startsWith("connect:")) return false + if (itemId.startsWith("connect:") || providerFromGroupRowId(itemId) !== null) return false onFavoriteToggle(itemId) return true }, } : {}), + ...(onCancel !== undefined ? { onCancel } : {}), }) } + + // Recent's first row (if any) is the model just switched to — the closest + // thing to a live "current model" id without threading one through from + // the runner. Used only to mark that row "(current)" wherever it appears. + const activeModelId = (): string | undefined => + currentModels.find((r) => r.section === "recent")?.id + + openModels = (): void => { + const { top, groups } = groupModelsForPicker(currentModels) + const activeId = activeModelId() + // The active model's own row already reads "(current)" via annotateCurrent + // below; when it lives inside a provider group, mark the group row too + // so the pick is visible without descending into it. + const activeGroupId = [...groups.entries()].find(([, g]) => + g.rows.some((r) => r.id === activeId), + )?.[0] + const withGroupMark = activeGroupId === undefined + ? top + : top.map((r) => + r.id === providerGroupRowId(activeGroupId) ? { ...r, label: `${r.label} (current)` } : r, + ) + openLevel(annotateCurrent(withGroupMark, activeId)) + } ;(shell as AppShell & { __openModels?: () => void }).__openModels = openModels } diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index 55ddcf909..e9ccd5dcf 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -6,7 +6,7 @@ import type { KeyEvent } from "@opentui/core" import type { CostSummary } from "../cost/cost-summary.js" import type { SubAgentSession } from "../subagent/session-store.js" import { createHarness } from "./harness.js" -import { closeInsetOverlay, runOverlayAction } from "./shell.js" +import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js" import { mountRunnerHost, observeSessionFromSubAgents, @@ -219,7 +219,7 @@ describe("mountRunnerHost model picker", () => { host.refreshModels([{ provider: "xai", model: "grok-4" }], []) closeInsetOverlay(host.shell) expect(host.openSurface("models")).toBe(true) - expect(host.shell.overlayItems[0]).toBe("xai / grok-4") + expect(host.shell.overlayItems[0]).toBe("xai / grok-4 (current)") } finally { host.dispose() harness.destroy() @@ -245,6 +245,9 @@ describe("mountRunnerHost model picker", () => { }) try { expect(host.openSurface("models")).toBe(true) + // Single provider, single model: top level shows the "xai" provider + // group first — descend into it before the model row is focusable. + acceptOverlaySelection(host.shell) const fKey = { name: "f", ctrl: false, meta: false, option: true } as KeyEvent expect(runOverlayAction(host.shell, fKey)).toBe(true) expect(toggled).toEqual(["xai:grok-4"]) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index ae57d08fa..28413b624 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3527,10 +3527,15 @@ export function closeInsetOverlay(shell: AppShell): void { const prior = wasPalette ? bag?.priorOverlay ?? null : null // Permissions/operator overlays back a caller awaiting ev.resolve — Esc must // still settle that promise (as a deny/cancel) or the caller hangs forever. - // Palette/mentions/copy have no such awaited caller, so they drop silently. + // model_picker's onCancel is how the provider-first picker steps back to + // the provider level instead of closing outright; harmless no-op for a + // caller that never set one. Palette/mentions/copy have no awaited caller + // and no back-navigation, so they drop silently. const cancelable = !prior && - (shell.overlayKind === "permissions" || shell.overlayKind === "operator") + (shell.overlayKind === "permissions" || + shell.overlayKind === "operator" || + shell.overlayKind === "model_picker") const onCancel = cancelable ? bag?.overlayOnCancel ?? null : null shell.overlayList = null