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.ts2676
1 files changed, 1338 insertions, 1338 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index c428769..947a9b0 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -5,50 +5,50 @@ import type { WebSocketLike } from "../adapters/ws";
import { createAppStore } from "./store.svelte";
interface FakeSocket extends WebSocketLike {
- sent: string[];
- resolveOpen(): void;
- feedServerMessage(data: WsServerMessage): void;
- feedSurfaceMessage(data: SurfaceServerMessage): void;
+ sent: string[];
+ resolveOpen(): void;
+ feedServerMessage(data: WsServerMessage): void;
+ feedSurfaceMessage(data: SurfaceServerMessage): void;
}
function fakeSocket(): FakeSocket {
- let onopen: (() => void) | null = null;
- let onmessage: ((ev: { data: string }) => void) | null = null;
- const sent: string[] = [];
-
- const ws: FakeSocket = {
- send(data: string) {
- sent.push(data);
- },
- close() {},
- get onopen() {
- return onopen;
- },
- set onopen(fn) {
- onopen = fn;
- },
- get onmessage() {
- return onmessage;
- },
- set onmessage(fn) {
- onmessage = fn;
- },
- get onclose() {
- return null;
- },
- set onclose(_fn) {},
- resolveOpen() {
- onopen?.();
- },
- feedServerMessage(msg: WsServerMessage) {
- onmessage?.({ data: JSON.stringify(msg) });
- },
- feedSurfaceMessage(msg: SurfaceServerMessage) {
- onmessage?.({ data: JSON.stringify(msg) });
- },
- sent,
- };
- return ws;
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ const sent: string[] = [];
+
+ const ws: FakeSocket = {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return null;
+ },
+ set onclose(_fn) {},
+ resolveOpen() {
+ onopen?.();
+ },
+ feedServerMessage(msg: WsServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ feedSurfaceMessage(msg: SurfaceServerMessage) {
+ onmessage?.({ data: JSON.stringify(msg) });
+ },
+ sent,
+ };
+ return ws;
}
/**
@@ -57,1322 +57,1322 @@ function fakeSocket(): FakeSocket {
* `sent` accumulates and `open()` can be driven again after `closeRemote()`.
*/
interface ReconnectableSocket extends WebSocketLike {
- sent: string[];
- open(): void;
- closeRemote(): void;
+ sent: string[];
+ open(): void;
+ closeRemote(): void;
}
function reconnectableSocket(): ReconnectableSocket {
- let onopen: (() => void) | null = null;
- let onmessage: ((ev: { data: string }) => void) | null = null;
- let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
- const sent: string[] = [];
- return {
- send(data: string) {
- sent.push(data);
- },
- close() {},
- get onopen() {
- return onopen;
- },
- set onopen(fn) {
- onopen = fn;
- },
- get onmessage() {
- return onmessage;
- },
- set onmessage(fn) {
- onmessage = fn;
- },
- get onclose() {
- return onclose;
- },
- set onclose(fn) {
- onclose = fn;
- },
- sent,
- open() {
- onopen?.();
- },
- closeRemote() {
- onclose?.({ code: 1006, reason: "" });
- },
- };
+ let onopen: (() => void) | null = null;
+ let onmessage: ((ev: { data: string }) => void) | null = null;
+ let onclose: ((ev: { code: number; reason: string }) => void) | null = null;
+ const sent: string[] = [];
+ return {
+ send(data: string) {
+ sent.push(data);
+ },
+ close() {},
+ get onopen() {
+ return onopen;
+ },
+ set onopen(fn) {
+ onopen = fn;
+ },
+ get onmessage() {
+ return onmessage;
+ },
+ set onmessage(fn) {
+ onmessage = fn;
+ },
+ get onclose() {
+ return onclose;
+ },
+ set onclose(fn) {
+ onclose = fn;
+ },
+ sent,
+ open() {
+ onopen?.();
+ },
+ closeRemote() {
+ onclose?.({ code: 1006, reason: "" });
+ },
+ };
}
interface FakeFetchOptions {
- models?: readonly string[];
- history?: Record<string, ConversationHistoryResponse>;
- model?: string | null;
+ models?: readonly string[];
+ history?: Record<string, ConversationHistoryResponse>;
+ model?: string | null;
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
- const history = opts?.history ?? {};
- return async (input: string | URL | Request): 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 });
- }
- if (url.endsWith("/model")) {
- return new Response(
- JSON.stringify({
- conversationId: "ignored",
- model: opts?.model ?? null,
- }),
- { status: 200 },
- );
- }
- const body =
- history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
- return new Response(JSON.stringify(body), { status: 200 });
- };
+ const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const history = opts?.history ?? {};
+ return async (input: string | URL | Request): 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 });
+ }
+ if (url.endsWith("/model")) {
+ return new Response(
+ JSON.stringify({
+ conversationId: "ignored",
+ model: opts?.model ?? null,
+ }),
+ { status: 200 },
+ );
+ }
+ const body =
+ history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
+ return new Response(JSON.stringify(body), { status: 200 });
+ };
}
function parseSent(ws: { sent: string[] }): unknown[] {
- return ws.sent.map((s) => JSON.parse(s));
+ return ws.sent.map((s) => JSON.parse(s));
}
function createFakeStorage(): Storage {
- const map = new Map<string, string>();
- return {
- get length() {
- return map.size;
- },
- clear() {
- map.clear();
- },
- getItem(key: string): string | null {
- return map.get(key) ?? null;
- },
- key(_index: number): string | null {
- return null;
- },
- removeItem(key: string) {
- map.delete(key);
- },
- setItem(key: string, value: string) {
- map.set(key, value);
- },
- };
+ const map = new Map<string, string>();
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem(key: string): string | null {
+ return map.get(key) ?? null;
+ },
+ key(_index: number): string | null {
+ return null;
+ },
+ removeItem(key: string) {
+ map.delete(key);
+ },
+ setItem(key: string, value: string) {
+ map.set(key, value);
+ },
+ };
}
function activeConversationId(store: ReturnType<typeof createAppStore>): string {
- const id = store.activeConversationId;
- expect(id).not.toBeNull();
- return id as string;
+ const id = store.activeConversationId;
+ expect(id).not.toBeNull();
+ return id as string;
}
describe("createAppStore", () => {
- it("starts with empty catalog and no surfaces", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.catalog).toEqual([]);
- expect(store.surfaces).toEqual([]);
- expect(store.lastError).toBeNull();
-
- store.dispose();
- });
-
- it("updates catalog when catalog message arrives", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- expect(store.catalog).toHaveLength(2);
- expect(store.catalog[0]?.id).toBe("s1");
- expect(store.catalog[1]?.id).toBe("s2");
-
- store.dispose();
- });
-
- it("auto-subscribes to every catalog entry when the catalog arrives", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- const subscribed = ws.sent
- .map((s) => JSON.parse(s))
- .filter((p) => p.type === "subscribe")
- .map((p) => p.surfaceId);
- expect(subscribed).toContain("s1");
- expect(subscribed).toContain("s2");
-
- store.dispose();
- });
-
- it("unsubscribes from entries that vanish from a new catalog", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- ws.sent.length = 0;
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
- });
-
- const unsubscribed = ws.sent
- .map((s) => JSON.parse(s))
- .filter((p) => p.type === "unsubscribe")
- .map((p) => p.surfaceId);
- expect(unsubscribed).toContain("s2");
- expect(unsubscribed).not.toContain("s1");
-
- store.dispose();
- });
-
- it("exposes received surface specs via `surfaces`, in catalog order", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s1", region: "sidebar", title: "Surface One" },
- { id: "s2", region: "panel", title: "Surface Two" },
- ],
- });
-
- // Only s1's spec has arrived: surfaces reflects what's actually received.
- ws.feedSurfaceMessage({
- type: "surface",
- spec: {
- id: "s1",
- region: "sidebar",
- title: "Surface One",
- fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
- },
- });
- expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]);
-
- ws.feedSurfaceMessage({
- type: "surface",
- spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
- });
- // Catalog order preserved (s1 before s2).
- expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]);
- expect(store.surfaces[0]?.fields).toHaveLength(1);
-
- store.dispose();
- });
-
- it("invoke sends an invoke message", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- store.invoke("s1", "toggle-dark", true);
-
- const invokeMsg = ws.sent.find((s) => {
- const parsed = JSON.parse(s);
- return (
- parsed.type === "invoke" &&
- parsed.surfaceId === "s1" &&
- parsed.actionId === "toggle-dark" &&
- parsed.payload === true
- );
- });
- expect(invokeMsg).toBeTruthy();
-
- store.dispose();
- });
-
- it("error message updates lastError", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "error",
- message: "Something went wrong",
- });
-
- expect(store.lastError).not.toBeNull();
- expect(store.lastError?.message).toBe("Something went wrong");
-
- store.dispose();
- });
-
- it("dispose closes the socket", () => {
- const ws = fakeSocket();
- const closeSpy = { called: false };
- const origClose = ws.close.bind(ws);
- ws.close = () => {
- closeSpy.called = true;
- origClose();
- };
-
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.dispose();
- expect(closeSpy.called).toBe(true);
- });
-
- it("exposes activeChat with empty initial messages", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.activeChat).toBeDefined();
- expect(store.activeChat.messages).toEqual([]);
- expect(store.activeChat.chunks).toEqual([]);
- expect(store.activeChat.error).toBeNull();
-
- store.dispose();
- });
-
- it("sending a message from draft creates a tab and posts chat.send", () => {
- const ws = fakeSocket();
- const storage = createFakeStorage();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws.resolveOpen();
-
- ws.sent.length = 0;
- store.send("hello world");
-
- expect(store.tabs).toHaveLength(1);
- expect(store.tabs[0]?.title).toBe("hello world");
- expect(store.activeConversationId).not.toBeNull();
-
- const msgs = parseSent(ws);
- const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as
- | { type: string; conversationId: string; message: string }
- | undefined;
- expect(chatSend).toBeTruthy();
- expect(chatSend?.message).toBe("hello world");
-
- store.dispose();
- });
-
- it("an incoming chat.delta renders in the transcript", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("test");
- const convId = activeConversationId(store);
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" },
- });
-
- expect(store.activeChat.chunks.length).toBeGreaterThan(0);
- const assistantChunks = store.activeChat.chunks.filter(
- (c) => c.role === "assistant" && c.chunk.type === "text",
- );
- expect(assistantChunks).toHaveLength(1);
- expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world");
-
- store.dispose();
- });
-
- it("chat.error sets the chat error", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("test");
- const convId = activeConversationId(store);
-
- ws.feedServerMessage({
- type: "chat.error",
- conversationId: convId,
- message: "bad request",
- });
-
- expect(store.activeChat.error).toBe("bad request");
-
- store.dispose();
- });
-
- it("turn-sealed triggers a history fetch and synced chunks render", async () => {
- const fetchedUrls: string[] = [];
- const historyResponse: ConversationHistoryResponse = {
- chunks: [
- { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
- { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
- ],
- latestSeq: 2,
- };
- const fetchImpl: typeof fetch = async (input: string | URL | Request): 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"] }), {
- status: 200,
- });
- }
- return new Response(JSON.stringify(historyResponse), { status: 200 });
- };
-
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- httpUrl: "http://localhost:24203",
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("hi");
- const convId = activeConversationId(store);
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
- });
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
- });
-
- await new Promise((r) => setTimeout(r, 50));
-
- expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
-
- await new Promise((r) => setTimeout(r, 50));
-
- expect(store.activeChat.chunks.length).toBeGreaterThan(0);
-
- store.dispose();
- });
-
- it("fetches and exposes the model catalog", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl({
- models: ["opencode/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",
- ]);
-
- store.dispose();
- });
-
- it("default model is flash", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
-
- store.dispose();
- });
-
- it("draft: sending the first message creates a tab titled from the message", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
-
- store.send("What is the meaning of life?");
-
- expect(store.tabs).toHaveLength(1);
- expect(store.tabs[0]?.title).toBe("What is the meaning of life?");
- expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId);
-
- store.dispose();
- });
-
- it("selecting a model persists it to the backend", () => {
- const ws = fakeSocket();
- const fetchImpl = fakeFetchImpl();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("hello");
- store.selectModel("openai/gpt-4o");
-
- const put = vi.fn().mockResolvedValue(
- new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), {
- status: 200,
- }),
- );
- const capturingStore = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input, init) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (init?.method === "PUT" && url.endsWith("/model")) {
- put(url, init);
- }
- return fetchImpl(input, init);
- },
- localStorage: createFakeStorage(),
- });
- capturingStore.send("hello");
- capturingStore.selectModel("openai/gpt-4o");
-
- expect(put).toHaveBeenCalledOnce();
- const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit];
- expect(callUrl.endsWith("/model")).toBe(true);
- expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" });
-
- store.dispose();
- capturingStore.dispose();
- });
-
- it("focuses a conversation with a persisted model", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- 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");
-
- // Wait for the persisted model fetch to resolve.
- await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
- expect(store.tabs[0]?.model).toBe("openai/gpt-4o");
-
- store.dispose();
- });
-
- it("chat.delta routes to the matching tab only", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first message");
- const convId1 = activeConversationId(store);
-
- store.newDraft();
- store.send("second message");
- const convId2 = activeConversationId(store);
-
- expect(convId1).not.toBe(convId2);
-
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" },
- });
- ws.feedServerMessage({
- type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: convId1,
- turnId: "turn-1",
- delta: "response to first",
- },
- });
-
- store.selectTab(convId1);
- const assistantChunks1 = store.activeChat.chunks.filter(
- (c) => c.role === "assistant" && c.chunk.type === "text",
- );
- expect(assistantChunks1).toHaveLength(1);
- expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe(
- "response to first",
- );
-
- store.selectTab(convId2);
- const assistantChunks2 = store.activeChat.chunks.filter(
- (c) => c.role === "assistant" && c.chunk.type === "text",
- );
- expect(assistantChunks2).toEqual([]);
-
- store.dispose();
- });
-
- it("closing a tab evicts its cache and drops the tab", () => {
- const ws = fakeSocket();
- const storage = createFakeStorage();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
- expect(store.tabs).toHaveLength(1);
-
- store.closeTab(convId);
-
- expect(store.tabs).toHaveLength(0);
- expect(store.activeConversationId).toBeNull();
-
- store.dispose();
- });
-
- it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => {
- const calls: { url: string; method: string }[] = [];
- 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" });
- if (url.endsWith("/close")) {
- return new Response(
- JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }),
- { status: 200 },
- );
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
- store.closeTab(convId);
- await Promise.resolve(); // flush the fire-and-forget fetch
-
- const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`));
- expect(close).toBeDefined();
- expect(close?.method).toBe("POST");
-
- store.dispose();
- });
-
- it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", 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("/reasoning-effort")) {
- return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), {
- status: 200,
- });
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- await vi.waitFor(() => {
- expect(store.reasoningEffort).toBe("xhigh");
- });
-
- store.dispose();
- });
-
- it("setReasoningEffort PUTs the level 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("/reasoning-effort") && init?.method === "PUT") {
- const sent = JSON.parse(init.body as string) as { reasoningEffort: string };
- return new Response(
- JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }),
- { status: 200 },
- );
- }
- if (url.endsWith("/reasoning-effort")) {
- return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
- status: 200,
- });
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- const result = await store.setReasoningEffort("max");
- expect(result).toEqual({ ok: true, reasoningEffort: "max" });
- expect(store.reasoningEffort).toBe("max");
-
- const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort"));
- expect(put).toBeDefined();
- // The PUT targets the workspace conversation (draft id works too) and
- // carries exactly the SetReasoningEffortRequest body.
- expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
- expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" });
-
- store.dispose();
- });
-
- it("setReasoningEffort surfaces a 400 error and leaves state unchanged", 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("/reasoning-effort") && init?.method === "PUT") {
- return new Response(JSON.stringify({ error: "bad level" }), { status: 400 });
- }
- if (url.endsWith("/reasoning-effort")) {
- return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
- status: 200,
- });
- }
- return base(input, init);
- };
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- const result = await store.setReasoningEffort("max");
- expect(result).toEqual({ ok: false, error: "bad level" });
- expect(store.reasoningEffort).toBeNull();
-
- store.dispose();
- });
-
- it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- ws.feedSurfaceMessage({
- type: "catalog",
- catalog: [
- { id: "s-global", region: "side", title: "Global", scope: "global" },
- { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" },
- ],
- });
-
- ws.sent.length = 0;
- store.send("promote the draft"); // draft → real conversation: surfaces re-scope
- const convId = activeConversationId(store);
-
- const surfaceMsgs = parseSent(ws).filter(
- (p): p is { type: string; surfaceId: string; conversationId?: string } =>
- (p as { type: string }).type === "subscribe" ||
- (p as { type: string }).type === "unsubscribe",
- );
- // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id.
- expect(
- surfaceMsgs.some(
- (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId,
- ),
- ).toBe(true);
- // The global surface is untouched — no redundant unsubscribe+subscribe round trip.
- expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false);
-
- store.dispose();
- });
-
- it("tabs persist to the injected storage and restore on a new store", () => {
- const ws = fakeSocket();
- const storage = createFakeStorage();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws.resolveOpen();
-
- store.send("persist me");
- const convId = store.tabs[0]?.conversationId;
- const title = store.tabs[0]?.title;
- expect(convId).toBeDefined();
- expect(title).toBeDefined();
-
- const raw = storage.getItem("dispatch.tabs");
- expect(raw).not.toBeNull();
- const parsed = JSON.parse(raw as string);
- expect(parsed.tabs).toHaveLength(1);
- expect(parsed.tabs[0].conversationId).toBe(convId);
- expect(parsed.tabs[0].title).toBe(title);
-
- const ws2 = fakeSocket();
- const store2 = createAppStore({
- socketFactory: () => ws2,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws2.resolveOpen();
-
- expect(store2.tabs).toHaveLength(1);
- expect(store2.tabs[0]?.conversationId).toBe(convId);
- expect(store2.tabs[0]?.title).toBe(title);
- expect(store2.activeConversationId).toBe(convId);
-
- store.dispose();
- store2.dispose();
- });
-
- it("tabs persist to globalThis.localStorage when no storage is injected", () => {
- const realLs = globalThis.localStorage;
- const memLs = createFakeStorage();
- globalThis.localStorage = memLs;
- try {
- const ws1 = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws1,
- fetchImpl: fakeFetchImpl(),
- });
- ws1.resolveOpen();
-
- store.send("persist via default");
- const convId = store.tabs[0]?.conversationId;
- const title = store.tabs[0]?.title;
- expect(convId).toBeDefined();
- expect(title).toBeDefined();
-
- const raw = globalThis.localStorage.getItem("dispatch.tabs");
- expect(raw).not.toBeNull();
- const parsed = JSON.parse(raw as string);
- expect(parsed.tabs).toHaveLength(1);
- expect(parsed.tabs[0].conversationId).toBe(convId);
- expect(parsed.tabs[0].title).toBe(title);
-
- store.dispose();
-
- const ws2 = fakeSocket();
- const store2 = createAppStore({
- socketFactory: () => ws2,
- fetchImpl: fakeFetchImpl(),
- });
- ws2.resolveOpen();
-
- expect(store2.tabs).toHaveLength(1);
- expect(store2.tabs[0]?.conversationId).toBe(convId);
- expect(store2.tabs[0]?.title).toBe(title);
- expect(store2.activeConversationId).toBe(convId);
-
- store2.dispose();
- } finally {
- globalThis.localStorage = realLs;
- }
- });
-
- it("newDraft resets to draft mode", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- expect(store.tabs).toHaveLength(1);
-
- store.newDraft();
- expect(store.activeConversationId).toBeNull();
-
- store.dispose();
- });
-
- it("selectTab switches active tab", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId1 = activeConversationId(store);
-
- store.newDraft();
- store.send("second");
- const convId2 = activeConversationId(store);
-
- store.selectTab(convId1);
- expect(store.activeConversationId).toBe(convId1);
-
- store.selectTab(convId2);
- expect(store.activeConversationId).toBe(convId2);
-
- store.dispose();
- });
-
- it("subscribes to chat for each restored tab on page load", () => {
- const storage = createFakeStorage();
- // First session: create a tab, then dispose.
- const ws1 = fakeSocket();
- const store1 = createAppStore({
- socketFactory: () => ws1,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws1.resolveOpen();
- store1.send("persist me");
- const convId = store1.tabs[0]?.conversationId as string;
- expect(convId).toBeDefined();
- store1.dispose();
-
- // Second session: the restored tab must be re-subscribed for live turns.
- const ws2 = fakeSocket();
- const store2 = createAppStore({
- socketFactory: () => ws2,
- fetchImpl: fakeFetchImpl(),
- localStorage: storage,
- });
- ws2.resolveOpen(); // flush the queued chat.subscribe
-
- const subscribed = parseSent(ws2)
- .filter((p) => (p as { type: string }).type === "chat.subscribe")
- .map((p) => (p as { conversationId: string }).conversationId);
- expect(subscribed).toContain(convId);
-
- store2.dispose();
- });
-
- it("unsubscribes from chat when a tab is closed", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
-
- ws.sent.length = 0;
- store.closeTab(convId);
-
- const unsubscribed = parseSent(ws)
- .filter((p) => (p as { type: string }).type === "chat.unsubscribe")
- .map((p) => (p as { conversationId: string }).conversationId);
- expect(unsubscribed).toContain(convId);
-
- store.dispose();
- });
-
- it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
- const fetchedUrls: string[] = [];
- const fetchImpl: typeof fetch = async (input: string | URL | Request): 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"] }), {
- status: 200,
- });
- }
- return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
- };
-
- const ws = reconnectableSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl,
- httpUrl: "http://localhost:24203",
- localStorage: createFakeStorage(),
- });
- ws.open();
-
- store.send("hi");
- const convId = activeConversationId(store);
-
- // Drop the connection, wait past the reconnect backoff, then re-open.
- ws.sent.length = 0;
- fetchedUrls.length = 0;
- ws.closeRemote();
- await new Promise((r) => setTimeout(r, 800));
- ws.open(); // reconnect → onReopen
-
- const subscribed = parseSent(ws)
- .filter((p) => (p as { type: string }).type === "chat.subscribe")
- .map((p) => (p as { conversationId: string }).conversationId);
- expect(subscribed).toContain(convId);
-
- // resync() pulled the tail from history for the reconnected conversation.
- await vi.waitFor(() => {
- expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
- });
-
- store.dispose();
- });
-
- // ── Heartbeat (workspace-scoped config + runs + watch) ───────────────────────
- //
- // The heartbeat API is a plain REST surface (not a transport-contract type),
- // so these tests fake the four endpoints + verify the store coerces the
- // untyped JSON and routes live deltas to a watch store (the run-chat modal).
-
- function heartbeatFetchImpl(opts?: {
- config?: Record<string, unknown>;
- runs?: Record<string, unknown>;
- }): typeof fetch {
- const base = fakeFetchImpl();
- const config = opts?.config ?? {
- enabled: true,
- systemPrompt: "sys",
- taskPrompt: "task",
- intervalMinutes: 15,
- model: "openai/gpt-4o",
- reasoningEffort: "medium",
- };
- const runs = opts?.runs ?? {
- runs: [
- {
- id: "run-1",
- conversationId: "hb-conv-1",
- triggeredAt: "2026-06-25T10:00:00Z",
- status: "running",
- },
- ],
- };
- return async (input, init) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const method = init?.method ?? "GET";
- if (url.includes("/heartbeat/runs") && method === "GET") {
- return new Response(JSON.stringify(runs), { status: 200 });
- }
- if (url.includes("/heartbeat/runs/") && method === "POST") {
- return new Response(JSON.stringify({ ok: true }), { status: 200 });
- }
- if (url.endsWith("/heartbeat") && method === "GET") {
- return new Response(JSON.stringify(config), { status: 200 });
- }
- if (url.endsWith("/heartbeat") && method === "PUT") {
- // Echo the patch merged onto the stored config so the round-trip is observable.
- const patch = init?.body ? JSON.parse(init.body as string) : {};
- return new Response(JSON.stringify({ ...config, ...patch }), {
- status: 200,
- });
- }
- if (url.includes("/heartbeat")) {
- return new Response(JSON.stringify(config), { status: 200 });
- }
- return base(input, init);
- };
- }
-
- it("heartbeatConfig loads + coerces the workspace config", async () => {
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: heartbeatFetchImpl(),
- localStorage: createFakeStorage(),
- });
- const result = await store.heartbeatConfig();
- expect(result.ok).toBe(true);
- if (!result.ok) throw new Error("unreachable");
- expect(result.config).toEqual({
- enabled: true,
- systemPrompt: "sys",
- taskPrompt: "task",
- intervalMinutes: 15,
- model: "openai/gpt-4o",
- reasoningEffort: "medium",
- });
- store.dispose();
- });
-
- it("heartbeatConfig surfaces an HTTP error", async () => {
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.endsWith("/heartbeat"))
- return new Response(JSON.stringify({ error: "nope" }), { status: 500 });
- return fakeFetchImpl()(input);
- },
- localStorage: createFakeStorage(),
- });
- const result = await store.heartbeatConfig();
- expect(result.ok).toBe(false);
- if (result.ok) throw new Error("unreachable");
- expect(result.error).toContain("nope");
- store.dispose();
- });
-
- it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => {
- const calls: { url: string; method: string; body: unknown }[] = [];
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input, init) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const method = init?.method ?? "GET";
- if (url.endsWith("/heartbeat") && method === "PUT") {
- calls.push({ url, method, body: JSON.parse(init?.body as string) });
- }
- return heartbeatFetchImpl()(input, init);
- },
- localStorage: createFakeStorage(),
- });
- const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 });
- expect(result.ok).toBe(true);
- expect(calls).toHaveLength(1);
- expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 });
- // The store normalizes the echoed response (interval clamped to the 1–1440 range).
- if (!result.ok) throw new Error("unreachable");
- expect(result.config.intervalMinutes).toBe(1440);
- store.dispose();
- });
-
- it("heartbeatRuns loads + coerces the run list", async () => {
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: heartbeatFetchImpl(),
- localStorage: createFakeStorage(),
- });
- const result = await store.heartbeatRuns();
- expect(result.ok).toBe(true);
- if (!result.ok) throw new Error("unreachable");
- expect(result.runs).toHaveLength(1);
- expect(result.runs[0]).toMatchObject({
- id: "run-1",
- conversationId: "hb-conv-1",
- status: "running",
- });
- store.dispose();
- });
-
- it("stopHeartbeatRun POSTs the stop endpoint", async () => {
- const calls: { url: string; method: string }[] = [];
- const store = createAppStore({
- socketFactory: () => fakeSocket(),
- fetchImpl: async (input, init) => {
- const url =
- typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- const method = init?.method ?? "GET";
- if (url.includes("/heartbeat/runs/") && method === "POST") {
- calls.push({ url, method });
- }
- return heartbeatFetchImpl()(input, init);
- },
- localStorage: createFakeStorage(),
- });
- const result = await store.stopHeartbeatRun("run-1");
- expect(result.ok).toBe(true);
- expect(calls).toHaveLength(1);
- expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop");
- expect(calls[0]?.method).toBe("POST");
- store.dispose();
- });
-
- it("watchConversation subscribes + routes live deltas to the watch store", async () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- // A heartbeat run's conversation that is NOT an open tab — watch it.
- const watch = store.watchConversation("hb-conv-watch");
- // A chat.subscribe was sent for the watched conversation.
- const subscribed = parseSent(ws).some(
- (p) =>
- (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
- (p as { conversationId?: string }).conversationId === "hb-conv-watch",
- );
- expect(subscribed).toBe(true);
-
- // Feed a live delta for the watched conversation → the watch store folds it.
- ws.feedServerMessage({
- type: "chat.delta",
- event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" },
- });
- ws.feedServerMessage({
- type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: "hb-conv-watch",
- turnId: "t1",
- delta: "hello from heartbeat",
- },
- });
-
- await vi.waitFor(() => {
- const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text");
- expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe(
- "hello from heartbeat",
- );
- });
- expect(watch.generating).toBe(true);
-
- // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent).
- ws.sent.length = 0;
- store.unwatchConversation("hb-conv-watch");
- const unsubscribed = parseSent(ws).some(
- (p) =>
- (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" &&
- (p as { conversationId?: string }).conversationId === "hb-conv-watch",
- );
- expect(unsubscribed).toBe(true);
-
- store.dispose();
- });
-
- it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => {
- const ws = fakeSocket();
- const store = createAppStore({
- socketFactory: () => ws,
- fetchImpl: fakeFetchImpl(),
- localStorage: createFakeStorage(),
- });
- ws.resolveOpen();
-
- store.send("first");
- const convId = activeConversationId(store);
- // The conversation is an open tab (already subscribed on send). Watching it
- // must REUSE the tab's store + subscription — so no NEW chat.subscribe is
- // sent (the watch path only subscribes when it creates an ephemeral store).
- // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a
- // reference-equality check is meaningless here — we assert behavior instead.)
- ws.sent.length = 0;
- store.watchConversation(convId);
- const subscribed = parseSent(ws).some(
- (p) =>
- (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
- (p as { conversationId?: string }).conversationId === convId,
- );
- expect(subscribed).toBe(false);
-
- // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream).
- ws.sent.length = 0;
- store.unwatchConversation(convId);
- const unsubscribed = parseSent(ws).some(
- (p) => (p as { type: string }).type === "chat.unsubscribe",
- );
- expect(unsubscribed).toBe(false);
-
- store.dispose();
- });
+ it("starts with empty catalog and no surfaces", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.catalog).toEqual([]);
+ expect(store.surfaces).toEqual([]);
+ expect(store.lastError).toBeNull();
+
+ store.dispose();
+ });
+
+ it("updates catalog when catalog message arrives", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ expect(store.catalog).toHaveLength(2);
+ expect(store.catalog[0]?.id).toBe("s1");
+ expect(store.catalog[1]?.id).toBe("s2");
+
+ store.dispose();
+ });
+
+ it("auto-subscribes to every catalog entry when the catalog arrives", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ const subscribed = ws.sent
+ .map((s) => JSON.parse(s))
+ .filter((p) => p.type === "subscribe")
+ .map((p) => p.surfaceId);
+ expect(subscribed).toContain("s1");
+ expect(subscribed).toContain("s2");
+
+ store.dispose();
+ });
+
+ it("unsubscribes from entries that vanish from a new catalog", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ ws.sent.length = 0;
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }],
+ });
+
+ const unsubscribed = ws.sent
+ .map((s) => JSON.parse(s))
+ .filter((p) => p.type === "unsubscribe")
+ .map((p) => p.surfaceId);
+ expect(unsubscribed).toContain("s2");
+ expect(unsubscribed).not.toContain("s1");
+
+ store.dispose();
+ });
+
+ it("exposes received surface specs via `surfaces`, in catalog order", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s1", region: "sidebar", title: "Surface One" },
+ { id: "s2", region: "panel", title: "Surface Two" },
+ ],
+ });
+
+ // Only s1's spec has arrived: surfaces reflects what's actually received.
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: {
+ id: "s1",
+ region: "sidebar",
+ title: "Surface One",
+ fields: [{ kind: "stat", label: "Tokens", value: "1,234" }],
+ },
+ });
+ expect(store.surfaces.map((s) => s.id)).toEqual(["s1"]);
+
+ ws.feedSurfaceMessage({
+ type: "surface",
+ spec: { id: "s2", region: "panel", title: "Surface Two", fields: [] },
+ });
+ // Catalog order preserved (s1 before s2).
+ expect(store.surfaces.map((s) => s.id)).toEqual(["s1", "s2"]);
+ expect(store.surfaces[0]?.fields).toHaveLength(1);
+
+ store.dispose();
+ });
+
+ it("invoke sends an invoke message", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ store.invoke("s1", "toggle-dark", true);
+
+ const invokeMsg = ws.sent.find((s) => {
+ const parsed = JSON.parse(s);
+ return (
+ parsed.type === "invoke" &&
+ parsed.surfaceId === "s1" &&
+ parsed.actionId === "toggle-dark" &&
+ parsed.payload === true
+ );
+ });
+ expect(invokeMsg).toBeTruthy();
+
+ store.dispose();
+ });
+
+ it("error message updates lastError", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "error",
+ message: "Something went wrong",
+ });
+
+ expect(store.lastError).not.toBeNull();
+ expect(store.lastError?.message).toBe("Something went wrong");
+
+ store.dispose();
+ });
+
+ it("dispose closes the socket", () => {
+ const ws = fakeSocket();
+ const closeSpy = { called: false };
+ const origClose = ws.close.bind(ws);
+ ws.close = () => {
+ closeSpy.called = true;
+ origClose();
+ };
+
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ conversationId: "test-conv",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.dispose();
+ expect(closeSpy.called).toBe(true);
+ });
+
+ it("exposes activeChat with empty initial messages", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.activeChat).toBeDefined();
+ expect(store.activeChat.messages).toEqual([]);
+ expect(store.activeChat.chunks).toEqual([]);
+ expect(store.activeChat.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("sending a message from draft creates a tab and posts chat.send", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ ws.sent.length = 0;
+ store.send("hello world");
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.title).toBe("hello world");
+ expect(store.activeConversationId).not.toBeNull();
+
+ const msgs = parseSent(ws);
+ const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as
+ | { type: string; conversationId: string; message: string }
+ | undefined;
+ expect(chatSend).toBeTruthy();
+ expect(chatSend?.message).toBe("hello world");
+
+ store.dispose();
+ });
+
+ it("an incoming chat.delta renders in the transcript", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("test");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" },
+ });
+
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ const assistantChunks = store.activeChat.chunks.filter(
+ (c) => c.role === "assistant" && c.chunk.type === "text",
+ );
+ expect(assistantChunks).toHaveLength(1);
+ expect((assistantChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world");
+
+ store.dispose();
+ });
+
+ it("chat.error sets the chat error", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("test");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "chat.error",
+ conversationId: convId,
+ message: "bad request",
+ });
+
+ expect(store.activeChat.error).toBe("bad request");
+
+ store.dispose();
+ });
+
+ it("turn-sealed triggers a history fetch and synced chunks render", async () => {
+ const fetchedUrls: string[] = [];
+ const historyResponse: ConversationHistoryResponse = {
+ chunks: [
+ { seq: 1, role: "user", chunk: { type: "text", text: "hi" } },
+ { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } },
+ ],
+ latestSeq: 2,
+ };
+ const fetchImpl: typeof fetch = async (input: string | URL | Request): 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"] }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify(historyResponse), { status: 200 });
+ };
+
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ httpUrl: "http://localhost:24203",
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("hi");
+ const convId = activeConversationId(store);
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
+ });
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+
+ store.dispose();
+ });
+
+ it("fetches and exposes the model catalog", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({
+ models: ["opencode/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",
+ ]);
+
+ store.dispose();
+ });
+
+ it("default model is flash", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("draft: sending the first message creates a tab titled from the message", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+
+ store.send("What is the meaning of life?");
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.title).toBe("What is the meaning of life?");
+ expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId);
+
+ store.dispose();
+ });
+
+ it("selecting a model persists it to the backend", () => {
+ const ws = fakeSocket();
+ const fetchImpl = fakeFetchImpl();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("hello");
+ store.selectModel("openai/gpt-4o");
+
+ const put = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ conversationId: "ignored", model: "openai/gpt-4o" }), {
+ status: 200,
+ }),
+ );
+ const capturingStore = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (init?.method === "PUT" && url.endsWith("/model")) {
+ put(url, init);
+ }
+ return fetchImpl(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ capturingStore.send("hello");
+ capturingStore.selectModel("openai/gpt-4o");
+
+ expect(put).toHaveBeenCalledOnce();
+ const [callUrl, callInit] = put.mock.calls[0] as [string, RequestInit];
+ expect(callUrl.endsWith("/model")).toBe(true);
+ expect(JSON.parse(callInit.body as string)).toEqual({ model: "openai/gpt-4o" });
+
+ store.dispose();
+ capturingStore.dispose();
+ });
+
+ it("focuses a conversation with a persisted model", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ model: "openai/gpt-4o" }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ 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");
+
+ // Wait for the persisted model fetch to resolve.
+ await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
+ expect(store.tabs[0]?.model).toBe("openai/gpt-4o");
+
+ store.dispose();
+ });
+
+ it("chat.delta routes to the matching tab only", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first message");
+ const convId1 = activeConversationId(store);
+
+ store.newDraft();
+ store.send("second message");
+ const convId2 = activeConversationId(store);
+
+ expect(convId1).not.toBe(convId2);
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: convId1,
+ turnId: "turn-1",
+ delta: "response to first",
+ },
+ });
+
+ store.selectTab(convId1);
+ const assistantChunks1 = store.activeChat.chunks.filter(
+ (c) => c.role === "assistant" && c.chunk.type === "text",
+ );
+ expect(assistantChunks1).toHaveLength(1);
+ expect((assistantChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe(
+ "response to first",
+ );
+
+ store.selectTab(convId2);
+ const assistantChunks2 = store.activeChat.chunks.filter(
+ (c) => c.role === "assistant" && c.chunk.type === "text",
+ );
+ expect(assistantChunks2).toEqual([]);
+
+ store.dispose();
+ });
+
+ it("closing a tab evicts its cache and drops the tab", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ expect(store.tabs).toHaveLength(1);
+
+ store.closeTab(convId);
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+
+ store.dispose();
+ });
+
+ it("closing a tab POSTs /conversations/:id/close (abort turn + stop warming)", async () => {
+ const calls: { url: string; method: string }[] = [];
+ 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" });
+ if (url.endsWith("/close")) {
+ return new Response(
+ JSON.stringify({ conversationId: url.split("/").at(-2), abortedTurn: false }),
+ { status: 200 },
+ );
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ store.closeTab(convId);
+ await Promise.resolve(); // flush the fire-and-forget fetch
+
+ const close = calls.find((c) => c.url.endsWith(`/conversations/${convId}/close`));
+ expect(close).toBeDefined();
+ expect(close?.method).toBe("POST");
+
+ store.dispose();
+ });
+
+ it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", 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("/reasoning-effort")) {
+ return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).toBe("xhigh");
+ });
+
+ store.dispose();
+ });
+
+ it("setReasoningEffort PUTs the level 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("/reasoning-effort") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { reasoningEffort: string };
+ return new Response(
+ JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }),
+ { status: 200 },
+ );
+ }
+ if (url.endsWith("/reasoning-effort")) {
+ return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setReasoningEffort("max");
+ expect(result).toEqual({ ok: true, reasoningEffort: "max" });
+ expect(store.reasoningEffort).toBe("max");
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort"));
+ expect(put).toBeDefined();
+ // The PUT targets the workspace conversation (draft id works too) and
+ // carries exactly the SetReasoningEffortRequest body.
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" });
+
+ store.dispose();
+ });
+
+ it("setReasoningEffort surfaces a 400 error and leaves state unchanged", 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("/reasoning-effort") && init?.method === "PUT") {
+ return new Response(JSON.stringify({ error: "bad level" }), { status: 400 });
+ }
+ if (url.endsWith("/reasoning-effort")) {
+ return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setReasoningEffort("max");
+ expect(result).toEqual({ ok: false, error: "bad level" });
+ expect(store.reasoningEffort).toBeNull();
+
+ store.dispose();
+ });
+
+ it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ ws.feedSurfaceMessage({
+ type: "catalog",
+ catalog: [
+ { id: "s-global", region: "side", title: "Global", scope: "global" },
+ { id: "s-conv", region: "side", title: "Scoped", scope: "conversation" },
+ ],
+ });
+
+ ws.sent.length = 0;
+ store.send("promote the draft"); // draft → real conversation: surfaces re-scope
+ const convId = activeConversationId(store);
+
+ const surfaceMsgs = parseSent(ws).filter(
+ (p): p is { type: string; surfaceId: string; conversationId?: string } =>
+ (p as { type: string }).type === "subscribe" ||
+ (p as { type: string }).type === "unsubscribe",
+ );
+ // The conversation-scoped surface re-scopes: unsubscribe old + subscribe new id.
+ expect(
+ surfaceMsgs.some(
+ (m) => m.type === "subscribe" && m.surfaceId === "s-conv" && m.conversationId === convId,
+ ),
+ ).toBe(true);
+ // The global surface is untouched — no redundant unsubscribe+subscribe round trip.
+ expect(surfaceMsgs.some((m) => m.surfaceId === "s-global")).toBe(false);
+
+ store.dispose();
+ });
+
+ it("tabs persist to the injected storage and restore on a new store", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ store.send("persist me");
+ const convId = store.tabs[0]?.conversationId;
+ const title = store.tabs[0]?.title;
+ expect(convId).toBeDefined();
+ expect(title).toBeDefined();
+
+ const raw = storage.getItem("dispatch.tabs");
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw as string);
+ expect(parsed.tabs).toHaveLength(1);
+ expect(parsed.tabs[0].conversationId).toBe(convId);
+ expect(parsed.tabs[0].title).toBe(title);
+
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws2.resolveOpen();
+
+ expect(store2.tabs).toHaveLength(1);
+ expect(store2.tabs[0]?.conversationId).toBe(convId);
+ expect(store2.tabs[0]?.title).toBe(title);
+ expect(store2.activeConversationId).toBe(convId);
+
+ store.dispose();
+ store2.dispose();
+ });
+
+ it("tabs persist to globalThis.localStorage when no storage is injected", () => {
+ const realLs = globalThis.localStorage;
+ const memLs = createFakeStorage();
+ globalThis.localStorage = memLs;
+ try {
+ const ws1 = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws1,
+ fetchImpl: fakeFetchImpl(),
+ });
+ ws1.resolveOpen();
+
+ store.send("persist via default");
+ const convId = store.tabs[0]?.conversationId;
+ const title = store.tabs[0]?.title;
+ expect(convId).toBeDefined();
+ expect(title).toBeDefined();
+
+ const raw = globalThis.localStorage.getItem("dispatch.tabs");
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw as string);
+ expect(parsed.tabs).toHaveLength(1);
+ expect(parsed.tabs[0].conversationId).toBe(convId);
+ expect(parsed.tabs[0].title).toBe(title);
+
+ store.dispose();
+
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ });
+ ws2.resolveOpen();
+
+ expect(store2.tabs).toHaveLength(1);
+ expect(store2.tabs[0]?.conversationId).toBe(convId);
+ expect(store2.tabs[0]?.title).toBe(title);
+ expect(store2.activeConversationId).toBe(convId);
+
+ store2.dispose();
+ } finally {
+ globalThis.localStorage = realLs;
+ }
+ });
+
+ it("newDraft resets to draft mode", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ expect(store.tabs).toHaveLength(1);
+
+ store.newDraft();
+ expect(store.activeConversationId).toBeNull();
+
+ store.dispose();
+ });
+
+ it("selectTab switches active tab", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId1 = activeConversationId(store);
+
+ store.newDraft();
+ store.send("second");
+ const convId2 = activeConversationId(store);
+
+ store.selectTab(convId1);
+ expect(store.activeConversationId).toBe(convId1);
+
+ store.selectTab(convId2);
+ expect(store.activeConversationId).toBe(convId2);
+
+ store.dispose();
+ });
+
+ it("subscribes to chat for each restored tab on page load", () => {
+ const storage = createFakeStorage();
+ // First session: create a tab, then dispose.
+ const ws1 = fakeSocket();
+ const store1 = createAppStore({
+ socketFactory: () => ws1,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws1.resolveOpen();
+ store1.send("persist me");
+ const convId = store1.tabs[0]?.conversationId as string;
+ expect(convId).toBeDefined();
+ store1.dispose();
+
+ // Second session: the restored tab must be re-subscribed for live turns.
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws2.resolveOpen(); // flush the queued chat.subscribe
+
+ const subscribed = parseSent(ws2)
+ .filter((p) => (p as { type: string }).type === "chat.subscribe")
+ .map((p) => (p as { conversationId: string }).conversationId);
+ expect(subscribed).toContain(convId);
+
+ store2.dispose();
+ });
+
+ it("unsubscribes from chat when a tab is closed", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+
+ ws.sent.length = 0;
+ store.closeTab(convId);
+
+ const unsubscribed = parseSent(ws)
+ .filter((p) => (p as { type: string }).type === "chat.unsubscribe")
+ .map((p) => (p as { conversationId: string }).conversationId);
+ expect(unsubscribed).toContain(convId);
+
+ store.dispose();
+ });
+
+ it("re-subscribes chat (and resyncs) for every open conversation on reconnect", async () => {
+ const fetchedUrls: string[] = [];
+ const fetchImpl: typeof fetch = async (input: string | URL | Request): 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"] }), {
+ status: 200,
+ });
+ }
+ return new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 });
+ };
+
+ const ws = reconnectableSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ httpUrl: "http://localhost:24203",
+ localStorage: createFakeStorage(),
+ });
+ ws.open();
+
+ store.send("hi");
+ const convId = activeConversationId(store);
+
+ // Drop the connection, wait past the reconnect backoff, then re-open.
+ ws.sent.length = 0;
+ fetchedUrls.length = 0;
+ ws.closeRemote();
+ await new Promise((r) => setTimeout(r, 800));
+ ws.open(); // reconnect → onReopen
+
+ const subscribed = parseSent(ws)
+ .filter((p) => (p as { type: string }).type === "chat.subscribe")
+ .map((p) => (p as { conversationId: string }).conversationId);
+ expect(subscribed).toContain(convId);
+
+ // resync() pulled the tail from history for the reconnected conversation.
+ await vi.waitFor(() => {
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+ });
+
+ store.dispose();
+ });
+
+ // ── Heartbeat (workspace-scoped config + runs + watch) ───────────────────────
+ //
+ // The heartbeat API is a plain REST surface (not a transport-contract type),
+ // so these tests fake the four endpoints + verify the store coerces the
+ // untyped JSON and routes live deltas to a watch store (the run-chat modal).
+
+ function heartbeatFetchImpl(opts?: {
+ config?: Record<string, unknown>;
+ runs?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const config = opts?.config ?? {
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ };
+ const runs = opts?.runs ?? {
+ runs: [
+ {
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ triggeredAt: "2026-06-25T10:00:00Z",
+ status: "running",
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs") && method === "GET") {
+ return new Response(JSON.stringify(runs), { status: 200 });
+ }
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "GET") {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ // Echo the patch merged onto the stored config so the round-trip is observable.
+ const patch = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ ...config, ...patch }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/heartbeat")) {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("heartbeatConfig loads + coerces the workspace config", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ });
+ store.dispose();
+ });
+
+ it("heartbeatConfig surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/heartbeat"))
+ return new Response(JSON.stringify({ error: "nope" }), { status: 500 });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("nope");
+ store.dispose();
+ });
+
+ it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 });
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 });
+ // The store normalizes the echoed response (interval clamped to the 1–1440 range).
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config.intervalMinutes).toBe(1440);
+ store.dispose();
+ });
+
+ it("heartbeatRuns loads + coerces the run list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatRuns();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.runs).toHaveLength(1);
+ expect(result.runs[0]).toMatchObject({
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ status: "running",
+ });
+ store.dispose();
+ });
+
+ it("stopHeartbeatRun POSTs the stop endpoint", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ calls.push({ url, method });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.stopHeartbeatRun("run-1");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop");
+ expect(calls[0]?.method).toBe("POST");
+ store.dispose();
+ });
+
+ it("watchConversation subscribes + routes live deltas to the watch store", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A heartbeat run's conversation that is NOT an open tab — watch it.
+ const watch = store.watchConversation("hb-conv-watch");
+ // A chat.subscribe was sent for the watched conversation.
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(subscribed).toBe(true);
+
+ // Feed a live delta for the watched conversation → the watch store folds it.
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: "hb-conv-watch",
+ turnId: "t1",
+ delta: "hello from heartbeat",
+ },
+ });
+
+ await vi.waitFor(() => {
+ const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text");
+ expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe(
+ "hello from heartbeat",
+ );
+ });
+ expect(watch.generating).toBe(true);
+
+ // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent).
+ ws.sent.length = 0;
+ store.unwatchConversation("hb-conv-watch");
+ const unsubscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(unsubscribed).toBe(true);
+
+ store.dispose();
+ });
+
+ it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ // The conversation is an open tab (already subscribed on send). Watching it
+ // must REUSE the tab's store + subscription — so no NEW chat.subscribe is
+ // sent (the watch path only subscribes when it creates an ephemeral store).
+ // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a
+ // reference-equality check is meaningless here — we assert behavior instead.)
+ ws.sent.length = 0;
+ store.watchConversation(convId);
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === convId,
+ );
+ expect(subscribed).toBe(false);
+
+ // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream).
+ ws.sent.length = 0;
+ store.unwatchConversation(convId);
+ const unsubscribed = parseSent(ws).some(
+ (p) => (p as { type: string }).type === "chat.unsubscribe",
+ );
+ expect(unsubscribed).toBe(false);
+
+ store.dispose();
+ });
});