diff options
| -rw-r--r-- | .dispatch/transport-contract.reference.md | 7 | ||||
| -rw-r--r-- | .dispatch/wire.reference.md | 17 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 32 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 5 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 22 | ||||
| -rw-r--r-- | src/app/store.test.ts | 82 |
6 files changed, 159 insertions, 6 deletions
diff --git a/.dispatch/transport-contract.reference.md b/.dispatch/transport-contract.reference.md index 13387fc..980d38b 100644 --- a/.dispatch/transport-contract.reference.md +++ b/.dispatch/transport-contract.reference.md @@ -881,6 +881,13 @@ export interface ConversationOpenMessage { * workspace. */ readonly workspaceId: string; + /** + * The conversation's persisted per-conversation model when set (absent = + * default provider model), so a frontend can stamp the new tab with the + * conversation's ACTUAL model rather than the globally-selected one + * (fix-toolcall-duplicate-keys plan Part 4). + */ + readonly model?: string; } /** diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md index a57125f..07dabc8 100644 --- a/.dispatch/wire.reference.md +++ b/.dispatch/wire.reference.md @@ -6,6 +6,14 @@ > > **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes. > +> **2026-08-25 delta (per-conversation model on ConversationMeta — ADDITIVE, no version bump):** +> `ConversationMeta` gains an optional `model?: string` (`<credential>/<model>`, absENT = default +> provider model). Surfaced on `GET /conversations` and the `conversation.open` WS broadcast so the +> FE can stamp a tab with the conversation's ACTUAL model instead of the globally-selected one +> (fix-toolcall-duplicate-keys plan Part 4 — prevents the stored model being overwritten by +> `activeModel`). See `transport-contract.reference.md` for the `ConversationOpenMessage.model` +> addition. +> > **2026-08-20 delta (Named Agents — ADDITIVE, no version bump):** `@dispatch/wire` now exports two > named-agent shapes the FE consumes (`named-agents-plan.md`): `NamedAgent` > `{ id, name, model }` (a user-curated model preset; `model` = canonical `<provider>/<model>`) and @@ -680,6 +688,15 @@ export interface ConversationMeta { * that have never been compacted. */ readonly compactedFrom?: string; + /** + * The persisted per-conversation model name (`<credential>/<model>`), when + * one has been set. Absent/null means the conversation uses the default + * provider model. Surfaced on the open-conversation list + `conversation.open` + * broadcast so a client can stamp a tab with the conversation's ACTUAL model + * instead of the currently-selected global model (fix-toolcall-duplicate-keys + * plan Part 4). + */ + readonly model?: string; } // ─── Compaction ────────────────────────────────────────────────────────────── 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({ |
