summaryrefslogtreecommitdiffhomepage
path: root/src/app/store.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/store.test.ts')
-rw-r--r--src/app/store.test.ts1193
1 files changed, 1152 insertions, 41 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a945ea7..90c11b3 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1,9 +1,29 @@
import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract";
import type { SurfaceServerMessage } from "@dispatch/ui-contract";
-import { describe, expect, it, vi } from "vitest";
+import type { NamedAgent } from "@dispatch/wire";
+import { IDBFactory } from "fake-indexeddb";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
+// Never let an omitted injection reach a real backend. Track rejected requests
+// too: the app deliberately catches network errors, which must not hide a bad fake.
+const unexpectedFetch = vi.fn<typeof fetch>(async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
+ throw new Error(`Unexpected fetch: ${method} ${url}`);
+});
+
+beforeEach(() => {
+ unexpectedFetch.mockClear();
+ vi.stubGlobal("fetch", unexpectedFetch);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ expect(unexpectedFetch).not.toHaveBeenCalled();
+});
+
interface FakeSocket extends WebSocketLike {
sent: string[];
resolveOpen(): void;
@@ -104,17 +124,124 @@ interface FakeFetchOptions {
models?: readonly string[];
history?: Record<string, ConversationHistoryResponse>;
model?: string | null;
+ /** Persisted Named Agents (GET /named-agents + CRUD in-memory). */
+ namedAgents?: readonly NamedAgent[];
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"];
const history = opts?.history ?? {};
- return async (input: string | URL | Request): Promise<Response> => {
+ // In-memory Named Agent store for the CRUD routes (the fake fetch mimics the
+ // backend's /named-agents surface).
+ let namedAgents: NamedAgent[] = opts?.namedAgents ? [...opts.namedAgents] : [];
+ return 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 }), { status: 200 });
+ const method =
+ init?.method ?? (typeof input !== "string" && !(input instanceof URL) ? input.method : "GET");
+ const bodyText =
+ typeof init?.body === "string"
+ ? init.body
+ : typeof input !== "string" &&
+ !(input instanceof URL) &&
+ typeof input.body !== "undefined" &&
+ "body" in input
+ ? await input.clone().text()
+ : null;
+ const { pathname, searchParams } = new URL(url);
+ if (pathname === "/models" && method === "GET") {
+ return Response.json({ models });
+ }
+ if (method === "GET") {
+ if (pathname === "/conversations" && searchParams.get("status") === "active,idle") {
+ // Most tests drive tabs via WS/local actions. Disable boot reconciliation
+ // so an in-flight empty list cannot remove a tab created by the test.
+ // Discovery tests override this route with their authoritative lists.
+ return Response.json({ error: "Discovery unavailable in this fixture" }, { status: 503 });
+ }
+ if (pathname === "/plugins") return Response.json({ plugins: [] });
+ if (pathname === "/computers") return Response.json({ computers: [] });
+ if (/^\/workspaces\/[^/]+\/plugins$/.test(pathname)) {
+ return Response.json({ workspaceId: pathname.split("/")[2], plugins: [] });
+ }
+ if (/^\/workspaces\/[^/]+\/plugins\/providers-status$/.test(pathname)) {
+ return Response.json({ enabledProviderCount: 1 });
+ }
+ // Explicitly simulate absent layout providers; retain local cached layouts.
+ if (
+ /^\/workspaces\/[^/]+\/layout$/.test(pathname) ||
+ /^\/home\/tile-layout\/(wide|tall)$/.test(pathname)
+ ) {
+ return Response.json({ error: "No layout provider" }, { status: 404 });
+ }
+ if (pathname === "/settings/vision") {
+ return Response.json({ imageLimit: 10, compactionModel: null });
+ }
+ const setting =
+ /^\/conversations\/[^/]+\/(cwd|computer|reasoning-effort|thinking|compact-percent)$/.exec(
+ pathname,
+ )?.[1];
+ if (setting !== undefined) {
+ const key = {
+ cwd: "cwd",
+ computer: "computerId",
+ "reasoning-effort": "reasoningEffort",
+ thinking: "thinking",
+ "compact-percent": "compactPercent",
+ }[setting];
+ if (key !== undefined) return Response.json({ [key]: null });
+ }
+ if (/^\/conversations\/[^/]+\/metrics$/.test(pathname)) {
+ return Response.json({ turns: [] });
+ }
+ if (/^\/conversations\/[^/]+$/.test(pathname) && searchParams.has("sinceSeq")) {
+ return Response.json(
+ history[url] ??
+ ({ chunks: [], historyEpoch: 0, latestSeq: 0 } satisfies ConversationHistoryResponse),
+ );
+ }
+ }
+ if (/^\/conversations\/[^/]+\/close$/.test(pathname) && method === "POST") {
+ return Response.json({ conversationId: pathname.split("/")[2], abortedTurn: false });
+ }
+ if (pathname === "/named-agents" && method === "GET") {
+ return new Response(JSON.stringify({ namedAgents }), { status: 200 });
+ }
+ if (pathname === "/named-agents" && method === "POST") {
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const agent: NamedAgent = {
+ id: `na-${namedAgents.length + 1}`,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = [...namedAgents, agent];
+ return new Response(JSON.stringify({ namedAgent: agent }), { status: 201 });
}
- if (url.endsWith("/model")) {
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "PUT") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ const payload =
+ bodyText === null
+ ? null
+ : (JSON.parse(bodyText) as { name?: string; model?: string } | null);
+ const updated: NamedAgent = {
+ id,
+ name: payload?.name ?? "",
+ model: payload?.model ?? "",
+ };
+ namedAgents = namedAgents.map((a) => (a.id === id ? updated : a));
+ return new Response(JSON.stringify({ namedAgent: updated }), { status: 200 });
+ }
+ if (/^\/named-agents\/[^/]+$/.test(pathname) && method === "DELETE") {
+ const id = url.split("/named-agents/")[1] ?? "";
+ namedAgents = namedAgents.filter((a) => a.id !== id);
+ return new Response(JSON.stringify({ id }), { status: 200 });
+ }
+ if (
+ /^\/conversations\/[^/]+\/model$/.test(pathname) &&
+ (method === "GET" || method === "PUT")
+ ) {
return new Response(
JSON.stringify({
conversationId: "ignored",
@@ -123,9 +250,7 @@ function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
{ status: 200 },
);
}
- const body =
- history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
- return new Response(JSON.stringify(body), { status: 200 });
+ return unexpectedFetch(input, init);
};
}
@@ -145,8 +270,8 @@ function createFakeStorage(): Storage {
getItem(key: string): string | null {
return map.get(key) ?? null;
},
- key(_index: number): string | null {
- return null;
+ key(index: number): string | null {
+ return [...map.keys()][index] ?? null;
},
removeItem(key: string) {
map.delete(key);
@@ -163,6 +288,22 @@ function activeConversationId(store: ReturnType<typeof createAppStore>): string
return id as string;
}
+describe("fakeFetchImpl", () => {
+ it.each([
+ "http://localhost:24203/unexpected",
+ new URL("http://localhost:24203/conversations/c/unknown-setting"),
+ new Request("http://localhost:24203/models", { method: "DELETE" }),
+ ])("rejects an unexpected URL or method: %s", async (input) => {
+ try {
+ await expect(fakeFetchImpl()(input)).rejects.toThrow("Unexpected fetch:");
+ expect(unexpectedFetch).toHaveBeenCalledOnce();
+ } finally {
+ // This test deliberately exercises the guard; all store tests require zero calls.
+ unexpectedFetch.mockClear();
+ }
+ });
+});
+
describe("createAppStore", () => {
it("starts with empty catalog and no surfaces", () => {
const ws = fakeSocket();
@@ -181,6 +322,29 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("probes only the portrait tile layout at boot and injects its cached document", () => {
+ const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390);
+ const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844);
+ const storage = createFakeStorage();
+ const cached = { version: 2, tiles: [{ id: "a", order: 0, colSpan: 2, rowSpan: 2 }] };
+ storage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached));
+ const fetchImpl = vi.fn(fakeFetchImpl());
+ const store = createAppStore({ socketFactory: fakeSocket, fetchImpl, localStorage: storage });
+ try {
+ expect(store.tileLayout.mode).toBe("tall");
+ expect(store.tileLayout.entries).toEqual(cached.tiles);
+ expect(
+ fetchImpl.mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.includes("/home/tile-layout/")),
+ ).toEqual([expect.stringMatching(/\/home\/tile-layout\/tall$/)]);
+ } finally {
+ store.dispose();
+ width.mockRestore();
+ height.mockRestore();
+ }
+ });
+
it("updates catalog when catalog message arrives", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -457,6 +621,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -518,17 +683,25 @@ describe("createAppStore", () => {
{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
{ seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
],
+ historyEpoch: 0,
latestSeq: 2,
};
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ 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;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
- return new Response(JSON.stringify(historyResponse), { status: 200 });
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json(historyResponse);
+ }
+ return base(input, init);
};
const ws = fakeSocket();
@@ -573,19 +746,19 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl({
- models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
}),
localStorage: createFakeStorage(),
});
ws.resolveOpen();
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.models).toEqual([
- "opencode/deepseek-v4-flash",
- "openai/gpt-4o",
- "anthropic/claude-3",
- ]);
+ await vi.waitFor(() => {
+ expect(store.models).toEqual([
+ "opencode-1/deepseek-v4-flash",
+ "openai/gpt-4o",
+ "anthropic/claude-3",
+ ]);
+ });
store.dispose();
});
@@ -599,11 +772,68 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
store.dispose();
});
+ it("does not silently switch the active model when the selected one is absent from the catalog", async () => {
+ // The catalog only has umans; the active (default/selected) OpenCode model is
+ // NOT available. The FE must NOT auto-fall back to the first catalog model —
+ // it keeps the selection and lets the model selector surface it as unavailable.
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.models).toEqual(["umans/umans-glm-5.2"]));
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => {
+ const ws = fakeSocket();
+ // Seed localStorage with a persisted tab whose model is the OBSOLETE
+ // `opencode/deepseek-v4-flash` namespace.
+ const storage = createFakeStorage();
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: [
+ {
+ conversationId: "c-legacy",
+ title: "Legacy",
+ model: "opencode/deepseek-v4-flash",
+ workspaceId: "default",
+ },
+ ],
+ activeConversationId: null,
+ }),
+ );
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+ // After restore, the restored tab's model + the persisted store are migrated.
+ const restored = store.tabs.find((t) => t.conversationId === "c-legacy");
+ expect(restored?.model).toBe("opencode-1/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+ // Issue 7: the migrated tab state is ACTUALLY written back to the storage
+ // adapter immediately (not merely kept in memory) — a re-load is now a no-op.
+ const persisted = JSON.parse(storage.getItem("dispatch.tabs") ?? "") as {
+ tabs: Array<{ conversationId: string; model: string }>;
+ };
+ const persistedModel = persisted.tabs.find((t) => t.conversationId === "c-legacy")?.model;
+ expect(persistedModel).toBe("opencode-1/deepseek-v4-flash");
+ expect(persisted.tabs.some((t) => t.model.startsWith("opencode/"))).toBe(false);
+ store.dispose();
+ });
+
it("draft: sending the first message creates a tab titled from the message", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -679,7 +909,7 @@ describe("createAppStore", () => {
store.send("first message");
// New tab opens with the default model until the persisted-model fetch resolves.
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
// Wait for the persisted model fetch to resolve.
await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
@@ -688,12 +918,148 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("fetchOpenConversations stamps tabs with the conversation's persisted model (not the global active model)", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ 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 (/\/models(\?.*)?$/.test(url)) {
+ 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 base(input, init);
+ };
+
+ 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("fetchOpenConversations seeds the chat store's cache-timer turn gap from an idle conversation's lastActivityAt", async () => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ 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.includes("/conversations?status=")) {
+ return new Response(
+ JSON.stringify({
+ conversations: [
+ {
+ id: "conv-seed",
+ createdAt: 1000,
+ lastActivityAt: 7777,
+ title: "Seed conv",
+ status: "idle",
+ workspaceId: "default",
+ model: "claude/claude-opus-4-6",
+ },
+ ],
+ }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/metrics")) {
+ return new Response(JSON.stringify({ turns: [] }), { status: 200 });
+ }
+ return base(input, init);
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.tabs.some((t) => t.conversationId === "conv-seed")).toBe(true);
+ });
+ store.selectTab("conv-seed");
+ expect(store.activeChat.cacheTimer.running).toEqual({
+ kind: "turn",
+ turnId: null,
+ startedAt: 7777,
+ });
+
+ 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({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
});
ws.resolveOpen();
@@ -891,6 +1257,135 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", async () => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: false }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.thinking).toBe(false);
+ });
+
+ store.dispose();
+ });
+
+ it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => {
+ // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend
+ // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the
+ // effort level (thinking ON, the default) — graceful, never a crash.
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response("not found", { status: 404 });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (404ing) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("treats a SPA-fallback response (200 + text/html) as 'never set' (null ⇒ ON)", async () => {
+ // In production (DISPATCH_WEB_DIR set), unknown API routes get the SPA
+ // fallback: index.html with HTTP 200 + text/html. This must NOT crash
+ // res.json() — it should leave `thinking` null (ON, the default).
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response("<!DOCTYPE html><html><body>SPA fallback</body></html>", {
+ status: 200,
+ headers: { "content-type": "text/html; charset=utf-8" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (HTML) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("setThinking PUTs the flag and updates local state from the echo", async () => {
+ const calls: { url: string; method: string; body: string | undefined }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined });
+ if (url.endsWith("/thinking") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { thinking: boolean };
+ return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: null }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setThinking(false);
+ expect(result).toEqual({ ok: true, thinking: false });
+ expect(store.thinking).toBe(false);
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking"));
+ expect(put).toBeDefined();
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false });
+
+ store.dispose();
+ });
+
it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -1112,17 +1607,21 @@ describe("createAppStore", () => {
store.dispose();
});
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ it("re-subscribes and resyncs a loaded conversation on reconnect", async () => {
const fetchedUrls: string[] = [];
- const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => {
+ const base = fakeFetchImpl();
+ 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;
fetchedUrls.push(url);
- if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ if (/\/models(\?.*)?$/.test(url)) {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
};
const ws = reconnectableSocket();
@@ -1205,9 +1704,6 @@ describe("createAppStore", () => {
status: 200,
});
}
- if (url.includes("/heartbeat")) {
- return new Response(JSON.stringify(config), { status: 200 });
- }
return base(input, init);
};
}
@@ -1223,6 +1719,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.config).toEqual({
enabled: true,
+ inactiveOnly: true,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 15,
@@ -1423,7 +1920,7 @@ describe("createAppStore", () => {
const limits = opts?.limits ?? {
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
};
const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
@@ -1431,7 +1928,7 @@ describe("createAppStore", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -1491,7 +1988,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
store.dispose();
});
@@ -1663,7 +2160,7 @@ describe("createAppStore", () => {
autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
- providerId: "openai-compat",
+ providerId: "opencode-1",
paused: true,
pausedUntil: 1_719_408_000_000,
cooldownMs: 350,
@@ -1969,6 +2466,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: typeof fetch;
puts: { imageLimit?: number; compactionModel?: string | null }[];
} {
+ const base = fakeFetchImpl();
let current = initial;
const puts: { imageLimit?: number; compactionModel?: string | null }[] = [];
return {
@@ -1976,7 +2474,7 @@ describe("createAppStore — vision settings (global)", () => {
fetchImpl: 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")) {
+ if (/\/models(\?.*)?$/.test(url)) {
return new Response(
JSON.stringify({
models: ["kimi/k2", "umans/glm-5.2"],
@@ -1997,8 +2495,7 @@ describe("createAppStore — vision settings (global)", () => {
}
return new Response(JSON.stringify(current), { status: 200 });
}
- // Default: empty history + no cwd for the other endpoints.
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ return base(input, init);
},
};
}
@@ -2061,6 +2558,7 @@ describe("createAppStore — vision settings (global)", () => {
});
it("surfaces a PUT error", async () => {
+ const base = fakeFetchImpl();
const fetchImpl: typeof fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/settings/vision")) {
@@ -2071,7 +2569,7 @@ describe("createAppStore — vision settings (global)", () => {
status: 200,
});
}
- return new Response(JSON.stringify({ models: [] }), { status: 200 });
+ return base(input, init);
};
const store = createAppStore({
socketFactory: () => fakeSocket(),
@@ -2087,3 +2585,616 @@ describe("createAppStore — vision settings (global)", () => {
store.dispose();
});
});
+
+describe("createAppStore — Named Agents", () => {
+ it("seeds namedAgents from GET /named-agents on boot", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => expect(store.namedAgents).toEqual(agents));
+ store.dispose();
+ });
+
+ it("createNamedAgent appends to the store list", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.createNamedAgent({ name: "Research", model: "umans/glm" });
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.agent.id).toBe("na-1");
+ expect(store.namedAgents).toHaveLength(1);
+ expect(store.namedAgents[0]?.name).toBe("Research");
+ }
+ store.dispose();
+ });
+
+ it("updateNamedAgent updates the record in place", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.updateNamedAgent("na-1", {
+ name: "Coding",
+ model: "opencode-1/new",
+ });
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents[0]?.name).toBe("Coding");
+ expect(store.namedAgents[0]?.model).toBe("opencode-1/new");
+ store.dispose();
+ });
+
+ it("deleteNamedAgent removes the record immediately", async () => {
+ const ws = fakeSocket();
+ const agents: readonly NamedAgent[] = [
+ { id: "na-1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "na-2", name: "Research Agent", model: "umans/glm" },
+ ];
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ namedAgents: agents }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.namedAgents).toHaveLength(agents.length));
+
+ const result = await store.deleteNamedAgent("na-1");
+ expect(result.ok).toBe(true);
+ expect(store.namedAgents.map((a) => a.id)).toEqual(["na-2"]);
+ store.dispose();
+ });
+
+ it("selectModel records a Named Agent snapshot on the active conversation", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ expect(store.activeModel).toBe("opencode-1/claude-sonnet-4.5");
+ expect(store.namedAgentSnapshot?.namedAgentName).toBe("Coding Agent");
+ store.dispose();
+ });
+
+ it("selectModel without a snapshot clears a prior Named Agent selection", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.selectModel("opencode-1/claude-sonnet-4.5", {
+ namedAgentId: "na-1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ store.selectModel("openai/gpt-4o");
+
+ expect(store.activeModel).toBe("openai/gpt-4o");
+ expect(store.namedAgentSnapshot).toBeNull();
+ store.dispose();
+ });
+});
+
+describe("conversation load-on-demand", () => {
+ async function setup(
+ ws: WebSocketLike,
+ count = 3,
+ activeConversationId: string | null = "lazy-0",
+ workspaceCount = 1,
+ ) {
+ const storage = createFakeStorage();
+ const conversations = Array.from({ length: count }, (_, i) => ({
+ id: `lazy-${i}`,
+ title: `Conversation ${i}`,
+ model: "openai/gpt-4o",
+ workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`,
+ status: i === 1 ? "active" : "idle",
+ lastActivityAt: 1234,
+ }));
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: conversations.slice(0, Math.ceil(count / 2)).map((c) => ({
+ conversationId: c.id,
+ title: c.title,
+ model: c.model,
+ workspaceId: c.workspaceId,
+ })),
+ activeConversationId,
+ }),
+ );
+ const base = fakeFetchImpl();
+ const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+ const url = String(input);
+ if (url.includes("/conversations?status=")) {
+ return Response.json({ conversations });
+ }
+ if (/\/conversations\/[^/?]+\?sinceSeq=/.test(url)) {
+ return Response.json({
+ chunks: [
+ { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "hello" } },
+ ],
+ historyEpoch: 0,
+ latestSeq: 2,
+ });
+ }
+ if (url.endsWith("/metrics")) return Response.json({ turns: [] });
+ return base(input, init);
+ });
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ chatFlushScheduler: null,
+ });
+ const requests = () =>
+ fetchImpl.mock.calls
+ .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search)
+ .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url));
+ await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount)));
+ if (activeConversationId !== null) {
+ await vi.waitFor(() =>
+ expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`),
+ );
+ }
+ return { store, requests };
+ }
+
+ // These exercise 300 real Svelte-backed tabs and count requests, not elapsed
+ // time. Allow contention without shrinking the regression's large-tab fixture.
+ it("boots 300 persisted/discovered tabs with only active history + metrics", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws, 300);
+ try {
+ ws.resolveOpen();
+ expect(requests()).toEqual([
+ "/conversations/lazy-0?sinceSeq=0&limit=192",
+ "/conversations/lazy-0/metrics",
+ ]);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(1);
+ expect(store.tabs[299]?.title).toBe("Conversation 299");
+ expect(store.conversationStatus("lazy-1")).toBe("active");
+ ws.feedServerMessage({
+ type: "conversation.statusChanged",
+ conversationId: "lazy-1",
+ workspaceId: "default",
+ status: "queued",
+ });
+ expect(store.conversationStatus("lazy-1")).toBe("queued");
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => {
+ const ws = fakeSocket();
+ const { store } = await setup(ws, 339, "lazy-0", 24);
+ try {
+ ws.resolveOpen();
+ const subscriptions = parseSent(ws).filter(
+ (message) => (message as { type: string }).type === "chat.subscribe",
+ );
+ expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]);
+ store.setActiveWorkspace("workspace-1");
+ store.selectTab("lazy-1");
+ expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" });
+ expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" });
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("does not load any history while the active slot is a draft", async () => {
+ const { store, requests } = await setup(fakeSocket(), 300, null);
+ try {
+ expect(requests()).toEqual([]);
+ store.selectTab("lazy-0");
+ await vi.waitFor(() => expect(requests()).toContain("/conversations/lazy-0/metrics"));
+ expect(requests()).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ }, 20_000);
+
+ it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ store.selectTab("lazy-2");
+ store.selectTab("lazy-0");
+ store.selectTab("lazy-2");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2);
+ expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => {
+ const ws = reconnectableSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.open();
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.sent.length = 0;
+ const before = requests().length;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open();
+ await vi.waitFor(() => expect(requests()).toHaveLength(before + 4));
+ expect(
+ requests()
+ .slice(before)
+ .every((u) => /lazy-(0|watch)[/?]/.test(u)),
+ ).toBe(true);
+ expect(
+ parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"),
+ ).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("loads watches, including unloaded tabs, and loads disposed watches again", async () => {
+ const { store, requests } = await setup(fakeSocket());
+ try {
+ const watch = store.watchConversation("lazy-1");
+ store.watchConversation("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(watch.chunks).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(requests()).toHaveLength(4);
+ store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ store.unwatchConversation("lazy-watch");
+ const reopened = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(8));
+ expect(reopened.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("merges pre-load live deltas with history without duplicate bubbles", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: "lazy-1", turnId: "t1", text: "hi" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "t1", delta: "hello" },
+ });
+ expect(requests()).toHaveLength(2);
+ store.selectTab("lazy-1");
+ expect(store.activeChat.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "hi" },
+ { type: "text", text: "hello" },
+ ]);
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ expect(store.activeChat.chunks.every((c) => !c.provisional)).toBe(true);
+ expect(store.activeChat.generating).toBe(true);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("compaction reloads loaded stores but does not load unseen conversations", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "lazy-1", turnId: "old" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: "lazy-1", turnId: "old", delta: "stale" },
+ });
+ for (const id of ["lazy-1", "unknown", "lazy-0"]) {
+ ws.feedServerMessage({
+ type: "conversation.compacted",
+ conversationId: id,
+ newConversationId: `${id}-archive`,
+ messagesSummarized: 10,
+ historyEpoch: 0,
+ messagesKept: 2,
+ });
+ }
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ expect(requests().every((u) => u.includes("lazy-0"))).toBe(true);
+ store.selectTab("lazy-1");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ expect(store.activeChat.chunks).toHaveLength(2);
+ } finally {
+ store.dispose();
+ }
+ });
+
+ it("keeps a watched store loaded when it becomes a tab, and reloads a closed tab", async () => {
+ const ws = fakeSocket();
+ const { store, requests } = await setup(ws);
+ try {
+ ws.resolveOpen();
+ const watch = store.watchConversation("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(4));
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ store.selectTab("lazy-watch");
+ expect(store.activeChat.chunks).toEqual(watch.chunks);
+ store.unwatchConversation("lazy-watch");
+ expect(requests()).toHaveLength(4);
+ store.newDraft(); // Closing a background tab must not activate/load its neighbor.
+ store.closeTab("lazy-watch");
+ ws.feedServerMessage({
+ type: "conversation.open",
+ conversationId: "lazy-watch",
+ workspaceId: "default",
+ });
+ expect(requests()).toHaveLength(4);
+ store.selectTab("lazy-watch");
+ await vi.waitFor(() => expect(requests()).toHaveLength(6));
+ } finally {
+ store.dispose();
+ }
+ });
+});
+
+it.each([
+ null,
+ {},
+ { conversations: {} },
+])("reports malformed conversation lists: %j", async (body) => {
+ const ws = fakeSocket();
+ const base = fakeFetchImpl();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ fetchImpl: async (input, init) =>
+ String(input).includes("/conversations?status=") ? Response.json(body) : base(input, init),
+ });
+ try {
+ ws.resolveOpen();
+ await vi.waitFor(() => expect(store.fatalError).toContain("Invalid conversations response"));
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("compaction queued acknowledgement routing", () => {
+ it.each([
+ "surface-first",
+ "ack-first",
+ "drained-before-ack",
+ ])("uses only authoritative queue snapshots: %s", (order) => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ ws.resolveOpen();
+ store.send("queued text", [{ url: "data:image/png;base64,AAAA" }]);
+ const cid = activeConversationId(store);
+ const send = parseSent(ws).find((m) => (m as { type: string }).type === "chat.send") as {
+ requestId: string;
+ };
+ expect(send.requestId).toEqual(expect.any(String));
+ const sourceChat = store.activeChat;
+ const watch = store.watchConversation("other-conversation");
+ watch.send("unrelated local echo");
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "message-queue", region: "inline", title: "Queue", scope: "conversation" }],
+ });
+ const spec = (messages: { id: string; text: string; timestamp: number }[]) => ({
+ id: "message-queue",
+ region: "inline",
+ title: "Queue",
+ fields: [{ kind: "custom" as const, rendererId: "message-queue", payload: { messages } }],
+ });
+ const queued = spec([{ id: "q1", text: "queued text", timestamp: 1 }]);
+ const drained = spec([]);
+ const ack = () =>
+ ws.feedServerMessage({
+ type: "chat.queued",
+ conversationId: cid,
+ reason: "compaction",
+ messageId: "q1",
+ requestId: send.requestId,
+ });
+ if (order === "ack-first") {
+ ack();
+ expect(store.surface("message-queue", cid)).toBeNull();
+ }
+ ws.feedSurfaceMessage({ type: "surface", conversationId: cid, spec: queued });
+ expect(store.surface("message-queue", cid)).toEqual(queued);
+ if (order === "drained-before-ack") {
+ ws.feedSurfaceMessage({
+ type: "update",
+ update: { surfaceId: "message-queue", conversationId: cid, spec: drained },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: cid, turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "user-message", conversationId: cid, turnId: "t1", text: "queued text" },
+ });
+ }
+ ack();
+ ack();
+ expect(store.surface("message-queue", cid)).toEqual(
+ order === "drained-before-ack" ? drained : queued,
+ );
+ expect(sourceChat.chunks.map((c) => c.chunk)).toEqual(
+ order === "drained-before-ack" ? [{ type: "text", text: "queued text" }] : [],
+ );
+ expect(sourceChat.error).toBeNull();
+ expect(watch.chunks.map((c) => c.chunk)).toEqual([
+ { type: "text", text: "unrelated local echo" },
+ ]);
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.send")).toHaveLength(
+ 2,
+ );
+ expect(parseSent(ws).filter((m) => (m as { type: string }).type === "chat.queue")).toHaveLength(
+ 0,
+ );
+ store.dispose();
+ });
+});
+
+it("routes omitted replay only to the matching watch/tab without WS resend or an error banner", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ indexedDB: new IDBFactory(),
+ localStorage: createFakeStorage(),
+ chatFlushScheduler: null,
+ });
+ try {
+ ws.resolveOpen();
+ const tab = store.activeChat;
+ const watch = store.watchConversation("omitted-watch");
+ const sentBefore = ws.sent.length;
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "unknown",
+ turnId: "unknown-turn",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(false);
+ expect(tab.recovering).toBe(false);
+ ws.feedServerMessage({
+ type: "chat.resync-required",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "replay-limit",
+ });
+ expect(watch.recovering).toBe(true);
+ expect(watch.generating).toBe(true);
+ expect(tab.recovering).toBe(false);
+ expect(watch.error).toBeNull();
+ expect(tab.error).toBeNull();
+ expect(store.fatalError).toBeNull();
+ expect(ws.sent).toHaveLength(sentBefore);
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "turn-ended",
+ conversationId: "omitted-watch",
+ turnId: "t1",
+ reason: "aborted",
+ },
+ });
+ await vi.waitFor(() => expect(watch.pendingSync).toBe(false));
+ expect(watch.recovering).toBe(false);
+ expect(watch.generating).toBe(false);
+ expect(ws.sent).toHaveLength(sentBefore);
+ } finally {
+ store.dispose();
+ }
+});
+
+describe("unconfirmed first-message recovery", () => {
+ it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => {
+ const storage = createFakeStorage();
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ if (String(input).endsWith("/conversations?status=active,idle"))
+ return Response.json({ conversations: [] });
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const first = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => ws,
+ });
+ ws.resolveOpen();
+ first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]);
+ const id = first.activeConversationId;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(first.activeChat.pendingInputs).toHaveLength(1);
+ first.dispose();
+ const nextSocket = fakeSocket();
+ const next = createAppStore({
+ localStorage: storage,
+ indexedDB: new IDBFactory(),
+ fetchImpl,
+ socketFactory: () => nextSocket,
+ });
+ try {
+ nextSocket.resolveOpen();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true);
+ expect(next.activeChat.pendingInputs).toMatchObject([
+ { request: { message: "must survive" } },
+ ]);
+ const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? "";
+ nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId });
+ expect(next.activeChat.pendingInputs).toEqual([]);
+ expect(
+ parseSent(nextSocket).some((message) =>
+ ["chat.send", "chat.queue"].includes((message as { type: string }).type),
+ ),
+ ).toBe(false);
+ } finally {
+ next.dispose();
+ }
+ });
+});