summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-26 00:08:40 +0900
committerAdam Malczewski <[email protected]>2026-08-26 00:08:40 +0900
commit33c7245f18346553f660434bc3ac589cff3e80c2 (patch)
tree202002771681259446ed95506b4fbb6adece2fd8
parent4e56e548872a162129efee1b09987af7893b0e0c (diff)
downloaddispatch-web-33c7245f18346553f660434bc3ac589cff3e80c2.tar.gz
dispatch-web-33c7245f18346553f660434bc3ac589cff3e80c2.zip
fix(tabs): stamp tabs with the conversation's persisted model (conv.model), not activeModel
-rw-r--r--.dispatch/transport-contract.reference.md7
-rw-r--r--.dispatch/wire.reference.md17
-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
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({