From b85353502c2f3b1d3d213d809cad54612508cdf5 Mon Sep 17 00:00:00 2001 From: cnb Date: Sat, 7 Mar 2026 14:28:59 +0800 Subject: [PATCH 1/2] fix(tui): show configured default model instead of "unknown" for new sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused the TUI status bar to show "unknown" or a stale model: 1. `resolveModelSelection` in tui-session-actions did not receive the `defaults` parameter from `applySessionInfo`. When a session has no entry in the store yet (brand-new, never-run session), both `entry` and `state.sessionInfo` are empty, so the footer fell back to `undefined` → "unknown". Now `defaults` (containing the configured agent default model) is passed through and used as a fallback. 2. `resolveSessionModelRef` in session-utils preferred the last-run runtime `model/modelProvider` over the explicit `modelOverride/ providerOverride`. After `/model` switches back to default (which clears the override), the stale runtime model was still returned. The priority is now: override → runtime → configured defaults. Supersedes: #27735 Co-Authored-By: Claude Opus 4.6 --- src/gateway/session-utils.test.ts | 37 +++++++++++++++++- src/gateway/session-utils.ts | 42 ++++++++++++-------- src/tui/tui-session-actions.test.ts | 60 +++++++++++++++++++++++++++++ src/tui/tui-session-actions.ts | 13 ++++++- 4 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 943aea46e90..12c577cf707 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -330,7 +330,7 @@ describe("gateway session utils", () => { }); describe("resolveSessionModelRef", () => { - test("prefers runtime model/provider from session entry", () => { + test("prefers override over runtime model when both are present", () => { const cfg = createModelDefaultsConfig({ primary: "anthropic/claude-opus-4-6", }); @@ -344,7 +344,8 @@ describe("resolveSessionModelRef", () => { providerOverride: "anthropic", }); - expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + // Override reflects user intent for the *next* run and wins over last-run runtime model. + expect(resolved).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); }); test("preserves openrouter provider when model contains vendor prefix", () => { @@ -413,6 +414,38 @@ describe("resolveSessionModelRef", () => { expect(resolved).toEqual({ provider: "anthropic", model: "claude-sonnet-4-6" }); }); + + test("preserves providerOverride for slash-containing modelOverride (wrapper providers)", () => { + const cfg = createModelDefaultsConfig({ + primary: "anthropic/claude-opus-4-6", + }); + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "s-wrapper", + updatedAt: Date.now(), + modelOverride: "anthropic/claude-sonnet-4-6", + providerOverride: "openrouter", + }); + + // providerOverride should be preserved as-is; re-parsing the model string + // would incorrectly treat "anthropic" as the provider. + expect(resolved).toEqual({ provider: "openrouter", model: "anthropic/claude-sonnet-4-6" }); + }); + + test("uses runtime model when no override is set", () => { + const cfg = createModelDefaultsConfig({ + primary: "anthropic/claude-opus-4-6", + }); + + const resolved = resolveSessionModelRef(cfg, { + sessionId: "s-runtime-only", + updatedAt: Date.now(), + modelProvider: "openai-codex", + model: "gpt-5.3-codex", + }); + + expect(resolved).toEqual({ provider: "openai-codex", model: "gpt-5.3-codex" }); + }); }); describe("resolveSessionModelIdentityRef", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 969c60c378c..9458f1858df 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -639,10 +639,33 @@ export function resolveSessionModelRef( defaultModel: DEFAULT_MODEL, }); - // Prefer the last runtime model recorded on the session entry. - // This is the actual model used by the latest run and must win over defaults. + // Prefer explicit per-session override (set via /model or at spawn time). + // This reflects the user's intent for the next run and should take precedence + // over the runtime model recorded from a previous run. let provider = resolved.provider; let model = resolved.model; + const storedModelOverride = entry?.modelOverride?.trim(); + if (storedModelOverride) { + const explicitOverrideProvider = entry?.providerOverride?.trim(); + if (explicitOverrideProvider) { + // Provider is explicitly set alongside the override — use it directly. + // Re-parsing would incorrectly split vendor-prefixed model names + // (e.g. modelOverride="anthropic/claude-sonnet-4-6" with providerOverride="openrouter"). + return { provider: explicitOverrideProvider, model: storedModelOverride }; + } + const overrideProvider = provider || DEFAULT_PROVIDER; + const parsedOverride = parseModelRef(storedModelOverride, overrideProvider); + if (parsedOverride) { + provider = parsedOverride.provider; + model = parsedOverride.model; + } else { + provider = overrideProvider; + model = storedModelOverride; + } + return { provider, model }; + } + + // Fall back to the last runtime model recorded on the session entry. const runtimeModel = entry?.model?.trim(); const runtimeProvider = entry?.modelProvider?.trim(); if (runtimeModel) { @@ -664,21 +687,6 @@ export function resolveSessionModelRef( } return { provider, model }; } - - // Fall back to explicit per-session override (set at spawn/model-patch time), - // then finally to configured defaults. - const storedModelOverride = entry?.modelOverride?.trim(); - if (storedModelOverride) { - const overrideProvider = entry?.providerOverride?.trim() || provider || DEFAULT_PROVIDER; - const parsedOverride = parseModelRef(storedModelOverride, overrideProvider); - if (parsedOverride) { - provider = parsedOverride.provider; - model = parsedOverride.model; - } else { - provider = overrideProvider; - model = storedModelOverride; - } - } return { provider, model }; } diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 5e4a427c4a9..e1e491e7ce1 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -271,4 +271,64 @@ describe("tui session actions", () => { expect(state.sessionInfo.modelProvider).toBe("openai"); expect(state.sessionInfo.updatedAt).toBe(50); }); + + it("falls back to defaults model when session entry is not found", async () => { + // When the current session key has no entry in the store (brand-new session), + // the TUI should show the configured default model, not "unknown". + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 0, + defaults: { + modelProvider: "my-copilot", + model: "claude-opus-4.6", + contextTokens: 200000, + }, + sessions: [], + }); + + const state: TuiStateAccess = { + agentDefaultId: "main", + sessionMainKey: "agent:main:main", + sessionScope: "global", + agents: [], + currentAgentId: "main", + currentSessionKey: "agent:main:main", + currentSessionId: null, + activeChatRunId: null, + historyLoaded: false, + sessionInfo: {}, + initialSessionApplied: true, + isConnected: true, + autoMessageSent: false, + toolsExpanded: false, + showThinking: false, + connectionStatus: "connected", + activityStatus: "idle", + statusTimeout: null, + lastCtrlCAt: 0, + }; + + const { refreshSessionInfo } = createSessionActions({ + client: { listSessions } as unknown as GatewayChatClient, + chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog, + tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI, + opts: {}, + state, + agentNames: new Map(), + initialSessionInput: "", + initialSessionAgentId: null, + resolveSessionKey: vi.fn(), + updateHeader: vi.fn(), + updateFooter: vi.fn(), + updateAutocompleteProvider: vi.fn(), + setActivityStatus: vi.fn(), + }); + + await refreshSessionInfo(); + + expect(state.sessionInfo.model).toBe("claude-opus-4.6"); + expect(state.sessionInfo.modelProvider).toBe("my-copilot"); + expect(state.sessionInfo.contextTokens).toBe(200000); + }); }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 55a4074fd19..eacc1ee7fa3 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -114,7 +114,10 @@ export function createSessionActions(context: SessionActionContext) { } }; - const resolveModelSelection = (entry?: SessionInfoEntry) => { + const resolveModelSelection = ( + entry?: SessionInfoEntry, + defaults?: SessionInfoDefaults | null, + ) => { if (entry?.modelProvider || entry?.model) { return { modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider, @@ -126,6 +129,12 @@ export function createSessionActions(context: SessionActionContext) { const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider; return { modelProvider: overrideProvider, model: overrideModel }; } + if (defaults?.modelProvider || defaults?.model) { + return { + modelProvider: defaults.modelProvider ?? state.sessionInfo.modelProvider, + model: defaults.model ?? state.sessionInfo.model, + }; + } return { modelProvider: state.sessionInfo.modelProvider, model: state.sessionInfo.model, @@ -194,7 +203,7 @@ export function createSessionActions(context: SessionActionContext) { next.updatedAt = entry.updatedAt; } - const selection = resolveModelSelection(entry); + const selection = resolveModelSelection(entry, defaults); if (selection.modelProvider !== undefined) { next.modelProvider = selection.modelProvider; } From ee35f505503fe0c3964dd91007b55d9ade9912ae Mon Sep 17 00:00:00 2001 From: cnb Date: Sat, 7 Mar 2026 15:34:21 +0800 Subject: [PATCH 2/2] fix(tui): align resolveModelSelection priority with gateway Reorder conditions in resolveModelSelection so that modelOverride is checked before runtime model/modelProvider, matching the priority in resolveSessionModelRef (session-utils.ts). Without this change, a session with both a runtime model (from a previous run) and a modelOverride (from /model) would display the stale runtime model in the footer instead of the user's selection. Add a test case covering override precedence over runtime model. Co-Authored-By: Claude Opus 4.6 --- src/tui/tui-session-actions.test.ts | 65 +++++++++++++++++++++++++++++ src/tui/tui-session-actions.ts | 14 ++++--- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index e1e491e7ce1..8b6b490f06d 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -331,4 +331,69 @@ describe("tui session actions", () => { expect(state.sessionInfo.modelProvider).toBe("my-copilot"); expect(state.sessionInfo.contextTokens).toBe(200000); }); + + it("prefers modelOverride over runtime model in session entry", async () => { + // After /model switch, the entry has both a runtime model (from last run) + // and a modelOverride (user's intent for next run). The override should win. + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: {}, + sessions: [ + { + key: "agent:main:main", + model: "gpt-5.3-codex", + modelProvider: "openai-codex", + modelOverride: "claude-opus-4-6", + providerOverride: "anthropic", + updatedAt: 200, + }, + ], + }); + + const state: TuiStateAccess = { + agentDefaultId: "main", + sessionMainKey: "agent:main:main", + sessionScope: "global", + agents: [], + currentAgentId: "main", + currentSessionKey: "agent:main:main", + currentSessionId: null, + activeChatRunId: null, + historyLoaded: false, + sessionInfo: {}, + initialSessionApplied: true, + isConnected: true, + autoMessageSent: false, + toolsExpanded: false, + showThinking: false, + connectionStatus: "connected", + activityStatus: "idle", + statusTimeout: null, + lastCtrlCAt: 0, + }; + + const { refreshSessionInfo } = createSessionActions({ + client: { listSessions } as unknown as GatewayChatClient, + chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog, + tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI, + opts: {}, + state, + agentNames: new Map(), + initialSessionInput: "", + initialSessionAgentId: null, + resolveSessionKey: vi.fn(), + updateHeader: vi.fn(), + updateFooter: vi.fn(), + updateAutocompleteProvider: vi.fn(), + setActivityStatus: vi.fn(), + }); + + await refreshSessionInfo(); + + // Override should take precedence over runtime model + expect(state.sessionInfo.model).toBe("claude-opus-4-6"); + expect(state.sessionInfo.modelProvider).toBe("anthropic"); + }); }); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index eacc1ee7fa3..ddb224cae5a 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -118,17 +118,21 @@ export function createSessionActions(context: SessionActionContext) { entry?: SessionInfoEntry, defaults?: SessionInfoDefaults | null, ) => { + // 1. Explicit user override (set via /model) wins over last-run runtime model. + // This matches the priority in resolveSessionModelRef (session-utils.ts). + const overrideModel = entry?.modelOverride?.trim(); + if (overrideModel) { + const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider; + return { modelProvider: overrideProvider, model: overrideModel }; + } + // 2. Fall back to runtime model from last run. if (entry?.modelProvider || entry?.model) { return { modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider, model: entry.model ?? state.sessionInfo.model, }; } - const overrideModel = entry?.modelOverride?.trim(); - if (overrideModel) { - const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider; - return { modelProvider: overrideProvider, model: overrideModel }; - } + // 3. Configured defaults. if (defaults?.modelProvider || defaults?.model) { return { modelProvider: defaults.modelProvider ?? state.sessionInfo.modelProvider,