diff options
Diffstat (limited to 'src/app/store.test.ts')
| -rw-r--r-- | src/app/store.test.ts | 1193 |
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(); + } + }); +}); |
