fix(tui): show configured default model instead of "unknown" for new sessions

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 <noreply@anthropic.com>
This commit is contained in:
cnb 2026-03-07 14:28:59 +08:00
parent a5c07fa115
commit b85353502c
4 changed files with 131 additions and 21 deletions

View File

@ -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", () => {

View File

@ -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 };
}

View File

@ -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);
});
});

View File

@ -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;
}