diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 7f26059b813..70a2698274c 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -405,7 +405,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", }); @@ -419,7 +419,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", () => { @@ -488,6 +489,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 52c6f54b1ca..248d4afae1c 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -923,10 +923,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) { @@ -948,21 +971,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 68065a25607..997445c298d 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -281,4 +281,129 @@ describe("tui session actions", () => { expect(state.sessionInfo.updatedAt).toBe(50); expect(btw.clear).toHaveBeenCalled(); }); + + 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); + }); + + 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 99f2b8ab2ee..93c3c64475f 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -120,17 +120,30 @@ export function createSessionActions(context: SessionActionContext) { } }; - const resolveModelSelection = (entry?: SessionInfoEntry) => { + const resolveModelSelection = ( + 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, + model: defaults.model ?? state.sessionInfo.model, + }; } return { modelProvider: state.sessionInfo.modelProvider, @@ -203,7 +216,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; }