Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
970fd99
Add per-session model overrides
Jul 10, 2026
3193d9c
Fix session model selector state
Jul 12, 2026
1e7d71e
Format console session model changes
Jul 13, 2026
90de627
Trigger PR evidence checks
Jul 13, 2026
bf64641
Fix pre-commit checks
Jul 13, 2026
e0b3448
Preserve channel runtime error messages
Jul 15, 2026
a517025
Remove personal session id from tests
Jul 15, 2026
bbc09eb
Merge origin/main into session model overrides
Jul 20, 2026
a272f9e
Make per-session models opt-in
mango8853 Jul 21, 2026
97f6131
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Jul 27, 2026
1a9065a
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Jul 29, 2026
f32cba8
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Aug 8, 2026
50f034f
Fix new-session model scope fallback
mango8853 Aug 10, 2026
c4ed605
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Aug 11, 2026
0779178
Simplify per-session model overrides
mango8853 Aug 12, 2026
a091006
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Aug 12, 2026
cb12969
Simplify per-session model resolution
mango8853 Aug 14, 2026
7272a07
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Aug 14, 2026
a865bb4
Merge latest main into session model overrides
mango8853 Aug 21, 2026
9b024ef
Merge remote-tracking branch 'origin/main' into deploy/session-model-…
mango8853 Aug 21, 2026
465dc48
Merge latest main into session model overrides
mango8853 Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions console/src/features/model-selection/pendingModelOverride.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
getPersistedModelOverride,
getPendingModelOverride,
migratePendingModelOverride,
modelSlotsEqual,
setPendingModelOverride,
withPendingModelOverride,
} from "./pendingModelOverride";
import type { ExtendedSession } from "../../stores/sessionListStore";

describe("pendingModelOverride", () => {
beforeEach(() => sessionStorage.clear());

it("isolates selections by agent and session", () => {
setPendingModelOverride("agent-a", "session-a", {
provider_id: "openai",
model: "gpt-4o",
});
expect(getPendingModelOverride("agent-a", "session-a")).toEqual({
provider_id: "openai",
model: "gpt-4o",
});
expect(getPendingModelOverride("agent-a", "session-b")).toBeNull();
});

it("migrates a new-chat selection to its resolved session", () => {
setPendingModelOverride("agent-a", "new", {
provider_id: "anthropic",
model: "claude-3-5-sonnet",
});
migratePendingModelOverride("agent-a", "new", "chat-1");
expect(getPendingModelOverride("agent-a", "new")).toBeNull();
expect(getPendingModelOverride("agent-a", "chat-1")?.model).toBe(
"claude-3-5-sonnet",
);
});

it("adds the selection to the next request body", () => {
setPendingModelOverride("agent-a", "chat-1", {
provider_id: "openai",
model: "gpt-4o",
});
const result = withPendingModelOverride(
{ input: [] },
"agent-a",
"chat-1",
"chat-id-1",
);
expect(result.requestBody).toEqual({
input: [],
model_slot_override: {
provider_id: "openai",
model: "gpt-4o",
},
});
});

it("does not attach a model before the backend chat exists", () => {
setPendingModelOverride("agent-a", "new", {
provider_id: "openai",
model: "gpt-4o",
});

expect(
withPendingModelOverride({ input: [] }, "agent-a", "new", undefined),
).toEqual({ requestBody: { input: [] }, modelSlot: null });
});

it("finds a persisted override through any session identity", () => {
const sessions = [
{
id: "display-id",
name: "Chat",
messages: [],
realId: "backend-id",
sessionId: "channel-session-id",
meta: {
runtime_context: {
model_slot_override: {
provider_id: "openai",
model: "gpt-4o",
},
},
},
} as ExtendedSession,
];

expect(getPersistedModelOverride(sessions, "backend-id")).toEqual({
provider_id: "openai",
model: "gpt-4o",
});
expect(getPersistedModelOverride(sessions, "channel-session-id")).toEqual({
provider_id: "openai",
model: "gpt-4o",
});
});

it("only treats the exact persisted slot as confirmed", () => {
expect(
modelSlotsEqual(
{ provider_id: "openai", model: "gpt-4o" },
{ provider_id: "openai", model: "gpt-4o" },
),
).toBe(true);
expect(
modelSlotsEqual(
{ provider_id: "openai", model: "gpt-4o" },
{ provider_id: "openai", model: "gpt-4.1" },
),
).toBe(false);
});
});
112 changes: 112 additions & 0 deletions console/src/features/model-selection/pendingModelOverride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ModelSlotConfig } from "../../api/types";
import type { ExtendedSession } from "../../stores/sessionListStore";

const KEY_PREFIX = "qwenpaw-session-model-override:";

function storageKey(agentId: string, sessionId: string): string {
return `${KEY_PREFIX}${agentId}:${sessionId}`;
}

export function getPendingModelOverride(
agentId: string,
sessionId: string,
): ModelSlotConfig | null {
const raw = sessionStorage.getItem(storageKey(agentId, sessionId));
if (!raw) return null;
try {
const value = JSON.parse(raw) as Partial<ModelSlotConfig>;
if (value.provider_id && value.model) {
return { provider_id: value.provider_id, model: value.model };
}
} catch {
// Invalid pending state is discarded below.
}
sessionStorage.removeItem(storageKey(agentId, sessionId));
return null;
}

export function setPendingModelOverride(
agentId: string,
sessionId: string,
value: ModelSlotConfig | null,
): void {
const key = storageKey(agentId, sessionId);
if (value?.provider_id && value.model) {
sessionStorage.setItem(key, JSON.stringify(value));
} else {
sessionStorage.removeItem(key);
}
window.dispatchEvent(
new CustomEvent("session-model-override-changed", {
detail: { agentId, sessionId, value },
}),
);
}

export function migratePendingModelOverride(
agentId: string,
fromSessionId: string,
toSessionId: string,
): void {
if (fromSessionId === toSessionId) return;
const value = getPendingModelOverride(agentId, fromSessionId);
if (!value) return;
setPendingModelOverride(agentId, toSessionId, value);
setPendingModelOverride(agentId, fromSessionId, null);
}

export function getPersistedModelOverride(
sessions: ExtendedSession[],
...sessionIds: Array<string | undefined>
): ModelSlotConfig | null {
const identities = new Set(sessionIds.filter((value) => Boolean(value)));
if (identities.size === 0) return null;

const session = sessions.find((item) =>
[item.id, item.realId, item.sessionId].some((value) =>
value ? identities.has(value) : false,
),
);
const runtimeContext = session?.meta?.runtime_context;
if (!runtimeContext || typeof runtimeContext !== "object") return null;

const value = (runtimeContext as Record<string, unknown>).model_slot_override;
if (!value || typeof value !== "object") return null;
const slot = value as Record<string, unknown>;
return typeof slot.provider_id === "string" && typeof slot.model === "string"
? { provider_id: slot.provider_id, model: slot.model }
: null;
}

export function modelSlotsEqual(
left: ModelSlotConfig | null | undefined,
right: ModelSlotConfig | null | undefined,
): boolean {
return (
Boolean(left) &&
Boolean(right) &&
left?.provider_id === right?.provider_id &&
left?.model === right?.model
);
}

export function withPendingModelOverride(
requestBody: Record<string, unknown>,
agentId: string,
sessionId: string,
chatId: string | undefined,
): {
requestBody: Record<string, unknown>;
modelSlot: ModelSlotConfig | null;
} {
if (!chatId) return { requestBody, modelSlot: null };
const modelSlot = getPendingModelOverride(agentId, sessionId);
if (!modelSlot) return { requestBody, modelSlot: null };
return {
requestBody: {
...requestBody,
model_slot_override: modelSlot,
},
modelSlot,
};
}
1 change: 1 addition & 0 deletions console/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,7 @@
"codeSnippet_other": "Code snippet · {{count}} lines"
},
"modelSelectTooltip": "Select model",
"modelSelectAfterFirstMessage": "Send the first message before selecting a model",
"newChatTooltip": "New chat",
"searchTooltip": "Search all chats",
"chatHistoryTooltip": "Chat history",
Expand Down
1 change: 1 addition & 0 deletions console/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -3039,6 +3039,7 @@
"codeSnippet": "代码片段 · {{count}} 行"
},
"modelSelectTooltip": "模型选择",
"modelSelectAfterFirstMessage": "发送第一条消息后才能选择模型",
"newChatTooltip": "新建聊天",
"searchTooltip": "搜索所有聊天",
"chatHistoryTooltip": "聊天历史",
Expand Down
Loading
Loading