Merge ee35f505503fe0c3964dd91007b55d9ade9912ae into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
吹衣轻飏 2026-03-21 11:31:53 +08:00 committed by GitHub
commit 6e80607b9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 204 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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