summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/adapters/ws/logic.test.ts32
-rw-r--r--src/adapters/ws/logic.ts5
-rw-r--r--src/app/store.svelte.ts22
-rw-r--r--src/app/store.test.ts82
4 files changed, 135 insertions, 6 deletions
diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts
index dd2b773..43cf8ff 100644
--- a/src/adapters/ws/logic.test.ts
+++ b/src/adapters/ws/logic.test.ts
@@ -232,6 +232,38 @@ describe("parseServerMessage", () => {
});
});
+ it("parses a conversation.open message with a persisted model", () => {
+ const data = JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ model: "openrouter/stealth/ox-alpha",
+ });
+ const result = parseServerMessage(data);
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ model: "openrouter/stealth/ox-alpha",
+ });
+ });
+
+ it("omits the model when conversation.open carries a non-string/empty model", () => {
+ const result = parseServerMessage(
+ JSON.stringify({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ model: 42,
+ }),
+ );
+ expect(result).toEqual({
+ type: "conversation.open",
+ conversationId: "c1",
+ workspaceId: "w1",
+ });
+ });
+
it("returns null for conversation.open with missing conversationId", () => {
expect(
parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })),
diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts
index 03ef763..72a6338 100644
--- a/src/adapters/ws/logic.ts
+++ b/src/adapters/ws/logic.ts
@@ -116,10 +116,15 @@ export function parseServerMessage(data: string): WsServerMessage | null {
case "conversation.open": {
if (typeof parsed.conversationId !== "string") return null;
if (typeof parsed.workspaceId !== "string") return null;
+ // Forward the persisted per-conversation model when the backend includes
+ // it, so a client can stamp the opened tab with the conversation's ACTUAL
+ // model (fix-toolcall-duplicate-keys plan Part 4).
+ const model = parsed.model;
const msg: ConversationOpenMessage = {
type: "conversation.open",
conversationId: parsed.conversationId,
workspaceId: parsed.workspaceId,
+ ...(typeof model === "string" && model.length > 0 ? { model } : {}),
};
return msg;
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 08a7cd8..52136ee 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -1077,15 +1077,20 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
* stamped with the conversation's actual `workspaceId`, NOT the viewer's
* currently active workspace.
*/
- function openConversation(conversationId: string, workspaceId: string): void {
+ function openConversation(conversationId: string, workspaceId: string, model?: string): void {
if (chatStores.has(conversationId)) return;
- const store = createChatFor(conversationId, activeModel, workspaceId);
+ // Stamp the tab with the conversation's PERSISTED model when the open
+ // broadcast carries it (falling back to the current global selection) —
+ // never let the globally-active model overwrite a conversation's real one
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const tabModel = model ?? activeModel;
+ const store = createChatFor(conversationId, tabModel, workspaceId);
chatStores.set(conversationId, store);
void store.load();
subscribeChat(conversationId);
tabsStore.openTab({
conversationId,
- model: activeModel,
+ model: tabModel,
title: "Conversation",
workspaceId,
});
@@ -1172,13 +1177,18 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const existingIds = new Set(chatStores.keys());
for (const conv of data.conversations) {
if (!existingIds.has(conv.id)) {
- const store = createChatFor(conv.id, activeModel, conv.workspaceId);
+ // Stamp the tab with the conversation's PERSISTED model (when the
+ // list carries it) instead of the global active model — the fix for
+ // tabs showing/overwriting the wrong model
+ // (fix-toolcall-duplicate-keys plan Part 4).
+ const model = conv.model ?? activeModel;
+ const store = createChatFor(conv.id, model, conv.workspaceId);
chatStores.set(conv.id, store);
void store.load();
subscribeChat(conv.id);
tabsStore.openTab({
conversationId: conv.id,
- model: activeModel,
+ model,
title: conv.title,
workspaceId: conv.workspaceId,
});
@@ -1218,7 +1228,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
onMessage: handleServerMessage,
onChat: handleChatMessage,
onConversationOpen(msg: ConversationOpenMessage): void {
- openConversation(msg.conversationId, msg.workspaceId);
+ openConversation(msg.conversationId, msg.workspaceId, msg.model);
},
onConversationStatusChanged(msg: ConversationStatusChangedMessage): void {
const { conversationId, status, workspaceId } = msg;
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index c41efc2..f8b878a 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -799,6 +799,88 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("fetchOpenConversations stamps tabs with the conversation's persisted model (not the global active model)", async () => {
+ const ws = fakeSocket();
+ const fetchImpl: typeof fetch = async (
+ input: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
+ // The authoritative open-conversation list — one conversation whose
+ // persisted model differs from the global activeModel.
+ if (url.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-model",
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ title: "Model conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "openrouter/stealth/ox-alpha",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // global activeModel is the default until anything changes it
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ // fetchOpenConversations (boot reconcile) opens the conversation's tab —
+ // stamped with the PERSISTED model from the list, not activeModel.
+ await vi.waitFor(() => {
+ const tab = store.tabs.find((t) => t.conversationId === "conv-model");
+ expect(tab?.model).toBe("openrouter/stealth/ox-alpha");
+ });
+
+ store.dispose();
+ });
+
+ it("conversation.open WS message carrying a model stamps the tab with it", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // The user stays on their current (draft) tab; the backend broadcasts
+ // `conversation.open` with the conversation's persisted model.
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "conv-ws",
+ workspaceId: "default",
+ model: "openrouter/stealth/ox-alpha",
+ });
+
+ await vi.waitFor(() => {
+ const tab = store.tabs.find((t) => t.conversationId === "conv-ws");
+ expect(tab?.model).toBe("openrouter/stealth/ox-alpha");
+ expect(tab?.workspaceId).toBe("default");
+ });
+
+ store.dispose();
+ });
+
it("chat.delta routes to the matching tab only", () => {
const ws = fakeSocket();
const store = createAppStore({