diff options
Diffstat (limited to 'src/adapters')
| -rw-r--r-- | src/adapters/history/index.test.ts | 98 | ||||
| -rw-r--r-- | src/adapters/history/index.ts | 93 | ||||
| -rw-r--r-- | src/adapters/idb/index.test.ts | 160 | ||||
| -rw-r--r-- | src/adapters/idb/index.ts | 292 | ||||
| -rw-r--r-- | src/adapters/local-storage/index.test.ts | 224 | ||||
| -rw-r--r-- | src/adapters/local-storage/index.ts | 86 | ||||
| -rw-r--r-- | src/adapters/portal.test.ts | 49 | ||||
| -rw-r--r-- | src/adapters/portal.ts | 28 | ||||
| -rw-r--r-- | src/adapters/ws/index.test.ts | 729 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 183 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 626 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 238 |
12 files changed, 1677 insertions, 1129 deletions
diff --git a/src/adapters/history/index.test.ts b/src/adapters/history/index.test.ts new file mode 100644 index 0000000..faab9d4 --- /dev/null +++ b/src/adapters/history/index.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { createHistoryAdapter, type HistoryWindow } from "./index"; + +/** + * A minimal in-memory `HistoryWindow` fake for deterministic tests. `pathname` + * is a closure variable reflected through the `location` getter; `back(url)` + * simulates an external navigation (back/forward): it sets the path + fires the + * popstate listeners. + */ +function fakeWindow(initial = "/"): HistoryWindow & { back(url: string): void } { + let pathname = initial; + const popstateListeners = new Set<() => void>(); + return { + get location() { + return { + get pathname() { + return pathname; + }, + }; + }, + history: { + pushState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + replaceState(_data, _unused, url) { + if (typeof url === "string") pathname = url; + }, + }, + addEventListener(type, listener) { + if (type === "popstate") popstateListeners.add(listener); + }, + removeEventListener(type, listener) { + if (type === "popstate") popstateListeners.delete(listener); + }, + back(url: string) { + pathname = url; + for (const l of popstateListeners) l(); + }, + }; +} + +describe("createHistoryAdapter", () => { + it("reads the current path", () => { + const w = fakeWindow("/my-ws"); + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/my-ws"); + }); + + it("navigate updates the path + notifies subscribers", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual(["/default"]); + }); + + it("replace updates the path WITHOUT notifying", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.replace("/default"); + expect(h.path).toBe("/default"); + expect(seen).toEqual([]); + }); + + it("fires subscribers on popstate (back/forward)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + w.back("/my-ws"); + expect(seen).toEqual(["/my-ws"]); + }); + + it("unsubscribe stops notifications (navigate + popstate)", () => { + const w = fakeWindow("/"); + const h = createHistoryAdapter({ window: w }); + const seen: string[] = []; + const unsub = h.subscribe((p) => seen.push(p)); + unsub(); + h.navigate("/default"); + w.back("/other"); + expect(seen).toEqual([]); + }); + + it("degrades to a no-op adapter when there is no location (SSR)", () => { + const w = { location: undefined } as unknown as HistoryWindow; + const h = createHistoryAdapter({ window: w }); + expect(h.path).toBe("/"); + const seen: string[] = []; + h.subscribe((p) => seen.push(p)); + h.navigate("/default"); + expect(seen).toEqual([]); + }); +}); diff --git a/src/adapters/history/index.ts b/src/adapters/history/index.ts new file mode 100644 index 0000000..2886053 --- /dev/null +++ b/src/adapters/history/index.ts @@ -0,0 +1,93 @@ +/** + * History adapter — the injected browser effect for client-side routing. + * + * A thin wrapper over `window.history` + the `popstate` event that exposes the + * current pathname, a `navigate` (pushState) that updates the URL without a + * reload, and a `subscribe` for path changes (back/forward buttons + programmatic + * navigation). The route SEMANTICS (path → workspace/home) live in the + * `workspaces` feature's pure `parsePath`; this adapter deals only in path + * strings, so it stays generic + reusable. + * + * The browser edge (`window`/`history`) is INJECTED (defaults to the global) so + * it is testable without the DOM and degrades to a no-op when absent (SSR / no + * `window`). + */ + +/** The minimal browser-history surface this adapter needs. */ +export interface HistoryWindow { + readonly location: { readonly pathname: string }; + readonly history: { + pushState(data: unknown, unused: string, url?: string | URL | null): void; + replaceState(data: unknown, unused: string, url?: string | URL | null): void; + }; + addEventListener(type: "popstate", listener: () => void): void; + removeEventListener(type: "popstate", listener: () => void): void; +} + +export interface HistoryAdapter { + /** The current pathname. */ + readonly path: string; + /** Push a new path (updates the URL without a reload) + notifies subscribers. */ + navigate(path: string): void; + /** Replace the current path without adding a history entry (no notify). */ + replace(path: string): void; + /** Subscribe to path changes (back/forward + navigate). Returns unsubscribe. */ + subscribe(cb: (path: string) => void): () => void; +} + +export interface CreateHistoryOptions { + /** The browser window; defaults to `globalThis`. Inject for tests. */ + readonly window?: HistoryWindow; +} + +function noop(): void {} + +/** A no-op adapter for when there is no `window` (SSR). */ +function createNoopHistory(): HistoryAdapter { + return { + get path() { + return "/"; + }, + navigate: noop, + replace: noop, + subscribe() { + return noop; + }, + }; +} + +export function createHistoryAdapter(opts?: CreateHistoryOptions): HistoryAdapter { + const w = opts?.window ?? (globalThis as unknown as HistoryWindow); + if (w === undefined || w === null || w.location === undefined) { + return createNoopHistory(); + } + + const listeners = new Set<(path: string) => void>(); + const current = (): string => w.location.pathname; + const emit = (): void => { + const p = current(); + for (const cb of listeners) cb(p); + }; + + return { + get path() { + return current(); + }, + navigate(path: string): void { + w.history.pushState(null, "", path); + emit(); + }, + replace(path: string): void { + w.history.replaceState(null, "", path); + }, + subscribe(cb: (path: string) => void): () => void { + listeners.add(cb); + const onPop = (): void => cb(current()); + w.addEventListener("popstate", onPop); + return () => { + listeners.delete(cb); + w.removeEventListener("popstate", onPop); + }; + }, + }; +} diff --git a/src/adapters/idb/index.test.ts b/src/adapters/idb/index.test.ts index 12bb5ad..c05c605 100644 --- a/src/adapters/idb/index.test.ts +++ b/src/adapters/idb/index.test.ts @@ -4,117 +4,117 @@ import { describe, expect, it } from "vitest"; import { createIdbChunkStore } from "./index"; function textChunk(text: string): StoredChunk["chunk"] { - return { type: "text", text }; + return { type: "text", text }; } function makeChunk( - seq: number, - text: string, - role: StoredChunk["role"] = "assistant", + seq: number, + text: string, + role: StoredChunk["role"] = "assistant", ): StoredChunk { - return { seq, role, chunk: textChunk(text) }; + return { seq, role, chunk: textChunk(text) }; } describe("createIdbChunkStore", () => { - it("append then load returns chunks seq-ordered", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]; + it("append then load returns chunks seq-ordered", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const chunks = [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]; - await store.append("conv1", chunks); - const loaded = await store.load("conv1"); + await store.append("conv1", chunks); + const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded[0]?.seq).toBe(1); - expect(loaded[1]?.seq).toBe(2); - expect(loaded[2]?.seq).toBe(3); - expect(loaded[0]?.chunk).toEqual(textChunk("a")); - }); + expect(loaded).toHaveLength(3); + expect(loaded[0]?.seq).toBe(1); + expect(loaded[1]?.seq).toBe(2); + expect(loaded[2]?.seq).toBe(3); + expect(loaded[0]?.chunk).toEqual(textChunk("a")); + }); - it("append out-of-order still loads seq-ordered", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; + it("append out-of-order still loads seq-ordered", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const chunks = [makeChunk(3, "c"), makeChunk(1, "a"), makeChunk(2, "b")]; - await store.append("conv1", chunks); - const loaded = await store.load("conv1"); + await store.append("conv1", chunks); + const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); - }); + expect(loaded).toHaveLength(3); + expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); + }); - it("append is idempotent on duplicate seq", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("append is idempotent on duplicate seq", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]); - await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]); + await store.append("conv1", [makeChunk(1, "first"), makeChunk(2, "b")]); + await store.append("conv1", [makeChunk(1, "first"), makeChunk(3, "c")]); - const loaded = await store.load("conv1"); - expect(loaded).toHaveLength(3); - expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); - expect(loaded[0]?.chunk).toEqual(textChunk("first")); - }); + const loaded = await store.load("conv1"); + expect(loaded).toHaveLength(3); + expect(loaded.map((c) => c.seq)).toEqual([1, 2, 3]); + expect(loaded[0]?.chunk).toEqual(textChunk("first")); + }); - it("load returns [] for an absent conversation", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("load returns [] for an absent conversation", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - const loaded = await store.load("nonexistent"); - expect(loaded).toEqual([]); - }); + const loaded = await store.load("nonexistent"); + expect(loaded).toEqual([]); + }); - it("delete removes a conversation", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("delete removes a conversation", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a")]); - await store.append("conv2", [makeChunk(1, "b")]); + await store.append("conv1", [makeChunk(1, "a")]); + await store.append("conv2", [makeChunk(1, "b")]); - await store.delete("conv1"); + await store.delete("conv1"); - expect(await store.load("conv1")).toEqual([]); - const conv2 = await store.load("conv2"); - expect(conv2).toHaveLength(1); - expect(conv2[0]?.chunk).toEqual(textChunk("b")); - }); + expect(await store.load("conv1")).toEqual([]); + const conv2 = await store.load("conv2"); + expect(conv2).toHaveLength(1); + expect(conv2[0]?.chunk).toEqual(textChunk("b")); + }); - it("index aggregates chunkCount and maxSeq", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("index aggregates chunkCount and maxSeq", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]); - await store.append("conv2", [makeChunk(1, "x")]); + await store.append("conv1", [makeChunk(1, "a"), makeChunk(2, "b"), makeChunk(3, "c")]); + await store.append("conv2", [makeChunk(1, "x")]); - const idx = await store.index(); - expect(idx).toHaveLength(2); + const idx = await store.index(); + expect(idx).toHaveLength(2); - const c1 = idx.find((e) => e.conversationId === "conv1"); - const c2 = idx.find((e) => e.conversationId === "conv2"); + const c1 = idx.find((e) => e.conversationId === "conv1"); + const c2 = idx.find((e) => e.conversationId === "conv2"); - expect(c1?.chunkCount).toBe(3); - expect(c1?.maxSeq).toBe(3); - expect(c2?.chunkCount).toBe(1); - expect(c2?.maxSeq).toBe(1); - }); + expect(c1?.chunkCount).toBe(3); + expect(c1?.maxSeq).toBe(3); + expect(c2?.chunkCount).toBe(1); + expect(c2?.maxSeq).toBe(1); + }); - it("index reports lastAccess after load", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("index reports lastAccess after load", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a")]); - const idx = await store.index(); + await store.append("conv1", [makeChunk(1, "a")]); + const idx = await store.index(); - const entry = idx.find((e) => e.conversationId === "conv1"); - expect(entry?.lastAccess).toBeTypeOf("number"); - expect(entry?.lastAccess).toBeGreaterThan(0); - }); + const entry = idx.find((e) => e.conversationId === "conv1"); + expect(entry?.lastAccess).toBeTypeOf("number"); + expect(entry?.lastAccess).toBeGreaterThan(0); + }); - it("separate conversations are isolated", async () => { - const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); + it("separate conversations are isolated", async () => { + const store = createIdbChunkStore({ indexedDB: new IDBFactory() }); - await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]); - await store.append("conv2", [makeChunk(1, "b1")]); + await store.append("conv1", [makeChunk(1, "a1"), makeChunk(2, "a2")]); + await store.append("conv2", [makeChunk(1, "b1")]); - const loaded1 = await store.load("conv1"); - const loaded2 = await store.load("conv2"); + const loaded1 = await store.load("conv1"); + const loaded2 = await store.load("conv2"); - expect(loaded1).toHaveLength(2); - expect(loaded2).toHaveLength(1); - expect(loaded1[0]?.chunk).toEqual(textChunk("a1")); - expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); - }); + expect(loaded1).toHaveLength(2); + expect(loaded2).toHaveLength(1); + expect(loaded1[0]?.chunk).toEqual(textChunk("a1")); + expect(loaded2[0]?.chunk).toEqual(textChunk("b1")); + }); }); diff --git a/src/adapters/idb/index.ts b/src/adapters/idb/index.ts index 302edb5..96b2cbc 100644 --- a/src/adapters/idb/index.ts +++ b/src/adapters/idb/index.ts @@ -1,7 +1,7 @@ import type { StoredChunk } from "@dispatch/wire"; import type { - ConversationCacheIndexEntry, - ConversationChunkStore, + ConversationCacheIndexEntry, + ConversationChunkStore, } from "../../features/conversation-cache"; const DEFAULT_DB_NAME = "dispatch-chunk-cache"; @@ -10,172 +10,172 @@ const CHUNKS_STORE = "chunks"; const META_STORE = "meta"; interface ChunkRecord { - conversationId: string; - seq: number; - role: StoredChunk["role"]; - chunk: StoredChunk["chunk"]; + conversationId: string; + seq: number; + role: StoredChunk["role"]; + chunk: StoredChunk["chunk"]; } interface MetaRecord { - conversationId: string; - lastAccess: number; + conversationId: string; + lastAccess: number; } export interface CreateIdbChunkStoreOptions { - indexedDB?: IDBFactory; - dbName?: string; + indexedDB?: IDBFactory; + dbName?: string; } function requestToPromise<T>(req: IDBRequest<T>): Promise<T> { - return new Promise<T>((resolve, reject) => { - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise<T>((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } function txComplete(tx: IDBTransaction): Promise<void> { - return new Promise<void>((resolve, reject) => { - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - tx.onabort = () => reject(tx.error); - }); + return new Promise<void>((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); } function openDb(idb: IDBFactory, dbName: string): Promise<IDBDatabase> { - return new Promise<IDBDatabase>((resolve, reject) => { - const req = idb.open(dbName, DB_VERSION); - - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(CHUNKS_STORE)) { - const store = db.createObjectStore(CHUNKS_STORE, { - keyPath: ["conversationId", "seq"], - }); - store.createIndex("byConversation", "conversationId"); - } - if (!db.objectStoreNames.contains(META_STORE)) { - db.createObjectStore(META_STORE, { keyPath: "conversationId" }); - } - }; - - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise<IDBDatabase>((resolve, reject) => { + const req = idb.open(dbName, DB_VERSION); + + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(CHUNKS_STORE)) { + const store = db.createObjectStore(CHUNKS_STORE, { + keyPath: ["conversationId", "seq"], + }); + store.createIndex("byConversation", "conversationId"); + } + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: "conversationId" }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } function keyRangeFor(conversationId: string): IDBKeyRange { - const lower: [string, number] = [conversationId, 0]; - const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY]; - return IDBKeyRange.bound(lower, upper); + const lower: [string, number] = [conversationId, 0]; + const upper: [string, number] = [conversationId, Number.POSITIVE_INFINITY]; + return IDBKeyRange.bound(lower, upper); } function chunksToStoredChunks(records: ChunkRecord[]): StoredChunk[] { - return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); + return records.map((r) => ({ seq: r.seq, role: r.role, chunk: r.chunk })); } export function createIdbChunkStore(opts?: CreateIdbChunkStoreOptions): ConversationChunkStore { - const idb = opts?.indexedDB ?? globalThis.indexedDB; - const dbName = opts?.dbName ?? DEFAULT_DB_NAME; - - let dbPromise: Promise<IDBDatabase> | null = null; - - function getDb(): Promise<IDBDatabase> { - if (dbPromise === null) { - dbPromise = openDb(idb, dbName); - } - return dbPromise; - } - - return { - async load(conversationId: string): Promise<readonly StoredChunk[]> { - const db = await getDb(); - const tx = db.transaction(CHUNKS_STORE, "readonly"); - const store = tx.objectStore(CHUNKS_STORE); - const range = keyRangeFor(conversationId); - const records = await requestToPromise<ChunkRecord[]>(store.getAll(range)); - await txComplete(tx); - - records.sort((a, b) => a.seq - b.seq); - return chunksToStoredChunks(records); - }, - - async append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void> { - if (chunks.length === 0) return; - - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - for (const c of chunks) { - chunkStore.put({ - conversationId, - seq: c.seq, - role: c.role, - chunk: c.chunk, - } satisfies ChunkRecord); - } - - metaStore.put({ - conversationId, - lastAccess: Date.now(), - } satisfies MetaRecord); - - await txComplete(tx); - }, - - async delete(conversationId: string): Promise<void> { - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - chunkStore.delete(keyRangeFor(conversationId)); - metaStore.delete(conversationId); - - await txComplete(tx); - }, - - async index(): Promise<readonly ConversationCacheIndexEntry[]> { - const db = await getDb(); - const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly"); - const chunkStore = tx.objectStore(CHUNKS_STORE); - const metaStore = tx.objectStore(META_STORE); - - const allChunks = await requestToPromise<ChunkRecord[]>(chunkStore.getAll()); - const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll()); - await txComplete(tx); - - const metaMap = new Map<string, number>(); - for (const m of allMeta) { - metaMap.set(m.conversationId, m.lastAccess); - } - - const grouped = new Map<string, { chunkCount: number; maxSeq: number }>(); - for (const r of allChunks) { - const existing = grouped.get(r.conversationId); - if (existing === undefined) { - grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq }); - } else { - existing.chunkCount++; - if (r.seq > existing.maxSeq) { - existing.maxSeq = r.seq; - } - } - } - - const result: ConversationCacheIndexEntry[] = []; - for (const [conversationId, stats] of grouped) { - const lastAccess = metaMap.get(conversationId); - result.push({ - conversationId, - chunkCount: stats.chunkCount, - maxSeq: stats.maxSeq, - ...(lastAccess !== undefined ? { lastAccess } : {}), - }); - } - - return result; - }, - }; + const idb = opts?.indexedDB ?? globalThis.indexedDB; + const dbName = opts?.dbName ?? DEFAULT_DB_NAME; + + let dbPromise: Promise<IDBDatabase> | null = null; + + function getDb(): Promise<IDBDatabase> { + if (dbPromise === null) { + dbPromise = openDb(idb, dbName); + } + return dbPromise; + } + + return { + async load(conversationId: string): Promise<readonly StoredChunk[]> { + const db = await getDb(); + const tx = db.transaction(CHUNKS_STORE, "readonly"); + const store = tx.objectStore(CHUNKS_STORE); + const range = keyRangeFor(conversationId); + const records = await requestToPromise<ChunkRecord[]>(store.getAll(range)); + await txComplete(tx); + + records.sort((a, b) => a.seq - b.seq); + return chunksToStoredChunks(records); + }, + + async append(conversationId: string, chunks: readonly StoredChunk[]): Promise<void> { + if (chunks.length === 0) return; + + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + for (const c of chunks) { + chunkStore.put({ + conversationId, + seq: c.seq, + role: c.role, + chunk: c.chunk, + } satisfies ChunkRecord); + } + + metaStore.put({ + conversationId, + lastAccess: Date.now(), + } satisfies MetaRecord); + + await txComplete(tx); + }, + + async delete(conversationId: string): Promise<void> { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readwrite"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + chunkStore.delete(keyRangeFor(conversationId)); + metaStore.delete(conversationId); + + await txComplete(tx); + }, + + async index(): Promise<readonly ConversationCacheIndexEntry[]> { + const db = await getDb(); + const tx = db.transaction([CHUNKS_STORE, META_STORE], "readonly"); + const chunkStore = tx.objectStore(CHUNKS_STORE); + const metaStore = tx.objectStore(META_STORE); + + const allChunks = await requestToPromise<ChunkRecord[]>(chunkStore.getAll()); + const allMeta = await requestToPromise<MetaRecord[]>(metaStore.getAll()); + await txComplete(tx); + + const metaMap = new Map<string, number>(); + for (const m of allMeta) { + metaMap.set(m.conversationId, m.lastAccess); + } + + const grouped = new Map<string, { chunkCount: number; maxSeq: number }>(); + for (const r of allChunks) { + const existing = grouped.get(r.conversationId); + if (existing === undefined) { + grouped.set(r.conversationId, { chunkCount: 1, maxSeq: r.seq }); + } else { + existing.chunkCount++; + if (r.seq > existing.maxSeq) { + existing.maxSeq = r.seq; + } + } + } + + const result: ConversationCacheIndexEntry[] = []; + for (const [conversationId, stats] of grouped) { + const lastAccess = metaMap.get(conversationId); + result.push({ + conversationId, + chunkCount: stats.chunkCount, + maxSeq: stats.maxSeq, + ...(lastAccess !== undefined ? { lastAccess } : {}), + }); + } + + return result; + }, + }; } diff --git a/src/adapters/local-storage/index.test.ts b/src/adapters/local-storage/index.test.ts index 57103dd..3370dd7 100644 --- a/src/adapters/local-storage/index.test.ts +++ b/src/adapters/local-storage/index.test.ts @@ -2,119 +2,119 @@ import { describe, expect, it } from "vitest"; import { createLocalStore } from "./index"; function createMemoryStorage(): Storage { - const map = new Map<string, string>(); - return { - get length() { - return map.size; - }, - clear() { - map.clear(); - }, - getItem(key: string) { - return map.get(key) ?? null; - }, - key(index: number) { - return [...map.keys()][index] ?? 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) { + return map.get(key) ?? null; + }, + key(index: number) { + return [...map.keys()][index] ?? null; + }, + removeItem(key: string) { + map.delete(key); + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + }; } describe("createLocalStore", () => { - it("save then load round-trips an object", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<{ name: string; count: number }>("test", { storage }); - - store.save({ name: "alice", count: 42 }); - const loaded = store.load(); - - expect(loaded).toEqual({ name: "alice", count: 42 }); - }); - - it("load returns null when key is absent", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<string>("missing", { storage }); - - expect(store.load()).toBeNull(); - }); - - it("load returns null on corrupt JSON", () => { - const storage = createMemoryStorage(); - storage.setItem("corrupt", "{not valid json!!!"); - const store = createLocalStore<object>("corrupt", { storage }); - - expect(store.load()).toBeNull(); - }); - - it("clear removes the value", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<string>("key", { storage }); - - store.save("hello"); - expect(store.load()).toBe("hello"); - - store.clear(); - expect(store.load()).toBeNull(); - }); - - it("save swallows a throwing setItem (quota) without throwing", () => { - const storage = createMemoryStorage(); - const originalSetItem = storage.setItem.bind(storage); - let callCount = 0; - storage.setItem = (_key: string, _value: string) => { - callCount++; - if (callCount > 1) { - throw new DOMException("QuotaExceededError", "QuotaExceededError"); - } - originalSetItem(_key, _value); - }; - - const store = createLocalStore<number[]>("quota", { storage }); - - // First save works - store.save([1, 2, 3]); - expect(store.load()).toEqual([1, 2, 3]); - - // Second save throws but is swallowed - expect(() => store.save([4, 5, 6])).not.toThrow(); - }); - - it("construction with undefined storage yields a safe no-op store", () => { - const store = createLocalStore<string>("noop", { storage: undefined }); - - // All operations are safe no-ops - expect(store.load()).toBeNull(); - expect(() => store.save("hello")).not.toThrow(); - expect(() => store.clear()).not.toThrow(); - }); - - it("round-trips arrays", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<number[]>("arr", { storage }); - - store.save([1, 2, 3]); - expect(store.load()).toEqual([1, 2, 3]); - }); - - it("round-trips nested objects", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); - - store.save({ a: { b: ["x", "y"] } }); - expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); - }); - - it("overwrites previous value on repeated save", () => { - const storage = createMemoryStorage(); - const store = createLocalStore<string>("key", { storage }); - - store.save("first"); - store.save("second"); - expect(store.load()).toBe("second"); - }); + it("save then load round-trips an object", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ name: string; count: number }>("test", { storage }); + + store.save({ name: "alice", count: 42 }); + const loaded = store.load(); + + expect(loaded).toEqual({ name: "alice", count: 42 }); + }); + + it("load returns null when key is absent", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("missing", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("load returns null on corrupt JSON", () => { + const storage = createMemoryStorage(); + storage.setItem("corrupt", "{not valid json!!!"); + const store = createLocalStore<object>("corrupt", { storage }); + + expect(store.load()).toBeNull(); + }); + + it("clear removes the value", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("key", { storage }); + + store.save("hello"); + expect(store.load()).toBe("hello"); + + store.clear(); + expect(store.load()).toBeNull(); + }); + + it("save swallows a throwing setItem (quota) without throwing", () => { + const storage = createMemoryStorage(); + const originalSetItem = storage.setItem.bind(storage); + let callCount = 0; + storage.setItem = (_key: string, _value: string) => { + callCount++; + if (callCount > 1) { + throw new DOMException("QuotaExceededError", "QuotaExceededError"); + } + originalSetItem(_key, _value); + }; + + const store = createLocalStore<number[]>("quota", { storage }); + + // First save works + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + + // Second save throws but is swallowed + expect(() => store.save([4, 5, 6])).not.toThrow(); + }); + + it("construction with undefined storage yields a safe no-op store", () => { + const store = createLocalStore<string>("noop", { storage: undefined }); + + // All operations are safe no-ops + expect(store.load()).toBeNull(); + expect(() => store.save("hello")).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + }); + + it("round-trips arrays", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<number[]>("arr", { storage }); + + store.save([1, 2, 3]); + expect(store.load()).toEqual([1, 2, 3]); + }); + + it("round-trips nested objects", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<{ a: { b: string[] } }>("nested", { storage }); + + store.save({ a: { b: ["x", "y"] } }); + expect(store.load()).toEqual({ a: { b: ["x", "y"] } }); + }); + + it("overwrites previous value on repeated save", () => { + const storage = createMemoryStorage(); + const store = createLocalStore<string>("key", { storage }); + + store.save("first"); + store.save("second"); + expect(store.load()).toBe("second"); + }); }); diff --git a/src/adapters/local-storage/index.ts b/src/adapters/local-storage/index.ts index 72135ce..9dd2ffd 100644 --- a/src/adapters/local-storage/index.ts +++ b/src/adapters/local-storage/index.ts @@ -1,58 +1,58 @@ export interface LocalStore<T> { - load(): T | null; - save(value: T): void; - clear(): void; + load(): T | null; + save(value: T): void; + clear(): void; } export interface CreateLocalStoreOptions { - storage?: Storage | undefined; + storage?: Storage | undefined; } function createNoopStore<T>(): LocalStore<T> { - return { - load() { - return null; - }, - save() {}, - clear() {}, - }; + return { + load() { + return null; + }, + save() {}, + clear() {}, + }; } export function createLocalStore<T>(key: string, opts?: CreateLocalStoreOptions): LocalStore<T> { - let storage: Storage | undefined; - if (opts !== undefined && "storage" in opts) { - storage = opts.storage; - } else { - storage = globalThis.localStorage; - } + let storage: Storage | undefined; + if (opts !== undefined && "storage" in opts) { + storage = opts.storage; + } else { + storage = globalThis.localStorage; + } - if (storage === undefined || storage === null) { - return createNoopStore<T>(); - } + if (storage === undefined || storage === null) { + return createNoopStore<T>(); + } - return { - load(): T | null { - try { - const raw = storage.getItem(key); - if (raw === null) { - return null; - } - return JSON.parse(raw) as T; - } catch { - return null; - } - }, + return { + load(): T | null { + try { + const raw = storage.getItem(key); + if (raw === null) { + return null; + } + return JSON.parse(raw) as T; + } catch { + return null; + } + }, - save(value: T): void { - try { - storage.setItem(key, JSON.stringify(value)); - } catch { - // Swallow quota / write errors — persistence is best-effort. - } - }, + save(value: T): void { + try { + storage.setItem(key, JSON.stringify(value)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + }, - clear(): void { - storage.removeItem(key); - }, - }; + clear(): void { + storage.removeItem(key); + }, + }; } diff --git a/src/adapters/portal.test.ts b/src/adapters/portal.test.ts new file mode 100644 index 0000000..a5624d5 --- /dev/null +++ b/src/adapters/portal.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { portal } from "./portal"; + +describe("portal action", () => { + afterEach(() => { + // Strip any leftover teleported nodes between tests. + document.querySelectorAll("body > :not(script)").forEach((n) => { + if (n instanceof HTMLElement) n.remove(); + }); + }); + + it("teleports the node to document.body (escaping an ancestor with transform)", () => { + // Simulate the sidebar: a transformed ancestor establishes a containing + // block for `position: fixed`. + const ancestor = document.createElement("div"); + ancestor.style.transform = "translateX(0)"; + document.body.appendChild(ancestor); + + const node = document.createElement("div"); + node.setAttribute("data-testid", "modal"); + ancestor.appendChild(node); + expect(node.parentNode).toBe(ancestor); + + const action = portal(node); + + // After the action, the node is a direct child of <body>, not the ancestor. + expect(node.parentNode).toBe(document.body); + expect(ancestor.contains(node)).toBe(false); + + action.destroy(); + + // On destroy the node is removed from <body>. + expect(document.body.contains(node)).toBe(false); + }); + + it("is a no-op (does not throw) when document is unavailable (SSR guard)", () => { + const originalDocument = globalThis.document; + // @ts-expect-error — deliberately undefined to exercise the SSR guard. + globalThis.document = undefined; + try { + const stub = {} as HTMLElement; + const action = portal(stub); + // Must not throw, and returns a destroy that is safe to call. + action.destroy(); + } finally { + globalThis.document = originalDocument; + } + }); +}); diff --git a/src/adapters/portal.ts b/src/adapters/portal.ts new file mode 100644 index 0000000..afe42e7 --- /dev/null +++ b/src/adapters/portal.ts @@ -0,0 +1,28 @@ +/** + * A Svelte `use:` action that teleports a node to `document.body`, escaping any + * ancestor that establishes a containing block for `position: fixed` (most + * commonly an ancestor with a `transform`, `filter`, `perspective`, or + * `will-change` — e.g. the sidebar's `transform: translateX(...)` container). + * + * Without this, a `position: fixed` modal rendered inside such an ancestor is + * positioned relative to the ANCESTOR, not the viewport (so it only covers the + * sidebar area instead of the full screen). Moving the node to `document.body` + * restores viewport-relative `fixed` positioning. Svelte still owns the node's + * lifecycle (children, bindings, events); we just relocate it + remove it on + * destroy as hygiene. + * + * No-op safely when there is no `document` (SSR / jsdom guards). + */ +export function portal(node: HTMLElement): { destroy(): void } { + if (typeof document === "undefined") { + return { destroy() {} }; + } + document.body.appendChild(node); + return { + destroy() { + if (node.parentNode === document.body) { + document.body.removeChild(node); + } + }, + }; +} diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 961f919..9d821e2 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -3,344 +3,405 @@ import type { WebSocketLike } from "./index"; import { createSurfaceSocket } from "./index"; interface FakeSocket extends WebSocketLike { - sent: string[]; - resolveOpen(): void; - invokeMessage(data: string): void; - invokeClose(): void; + sent: string[]; + resolveOpen(): void; + invokeMessage(data: string): void; + invokeClose(): void; } function fakeSocket(): FakeSocket { - 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[] = []; - - 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 onclose; - }, - set onclose(fn) { - onclose = fn; - }, - resolveOpen() { - onopen?.(); - }, - invokeMessage(data: string) { - onmessage?.({ data }); - }, - invokeClose() { - onclose?.({ code: 1000, reason: "" }); - }, - sent, - }; - return ws; + 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[] = []; + + 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 onclose; + }, + set onclose(fn) { + onclose = fn; + }, + resolveOpen() { + onopen?.(); + }, + invokeMessage(data: string) { + onmessage?.({ data }); + }, + invokeClose() { + onclose?.({ code: 1000, reason: "" }); + }, + sent, + }; + return ws; } describe("createSurfaceSocket", () => { - it("sends queued messages once socket opens", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - handle.send({ type: "subscribe", surfaceId: "s1" }); - handle.send({ type: "subscribe", surfaceId: "s2" }); - expect(ws.sent).toHaveLength(0); - - ws.resolveOpen(); - expect(ws.sent).toHaveLength(2); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); - expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); - }); - - it("sends immediately when socket is already open", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.sent.length = 0; - - handle.send({ type: "subscribe", surfaceId: "s1" }); - expect(ws.sent).toHaveLength(1); - }); - - it("routes inbound messages to onMessage via parseServerMessage", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); - expect(onMessage).toHaveBeenCalledOnce(); - expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); - }); - - it("drops malformed inbound messages silently", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage("not json"); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("auto-reconnects on close and fires onReopen after successful reconnect", () => { - vi.useFakeTimers(); - try { - const sockets: ReturnType<typeof fakeSocket>[] = []; - const onMessage = vi.fn(); - const onReopen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onReopen, - socketFactory: () => { - const ws = fakeSocket(); - sockets.push(ws); - return ws; - }, - }); - - expect(sockets).toHaveLength(1); - sockets[0]?.resolveOpen(); - - // Simulate close - sockets[0]?.invokeClose(); - - // Fast-forward past the backoff delay - vi.advanceTimersByTime(600); - - expect(sockets).toHaveLength(2); - // onReopen should NOT have fired yet (socket not open) - expect(onReopen).not.toHaveBeenCalled(); - - sockets[1]?.resolveOpen(); - expect(onReopen).toHaveBeenCalledOnce(); - } finally { - vi.useRealTimers(); - } - }); - - it("does not fire onReopen on initial connect", () => { - const ws = fakeSocket(); - const onReopen = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - onReopen, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - expect(onReopen).not.toHaveBeenCalled(); - }); - - it("close() prevents further reconnects", () => { - vi.useFakeTimers(); - try { - const sockets: ReturnType<typeof fakeSocket>[] = []; - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => { - const ws = fakeSocket(); - sockets.push(ws); - return ws; - }, - }); - - sockets[0]?.resolveOpen(); - sockets[0]?.invokeClose(); - handle.close(); - - vi.advanceTimersByTime(10_000); - expect(sockets).toHaveLength(1); - } finally { - vi.useRealTimers(); - } - }); - - it("close() prevents further sends", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.sent.length = 0; - handle.close(); - - handle.send({ type: "subscribe", surfaceId: "s1" }); - expect(ws.sent).toHaveLength(0); - }); - - it("queues multiple sends before open and flushes in order", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - handle.send({ type: "subscribe", surfaceId: "a" }); - handle.send({ type: "subscribe", surfaceId: "b" }); - handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); - ws.resolveOpen(); - - expect(ws.sent).toHaveLength(3); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); - expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); - expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ - type: "invoke", - surfaceId: "a", - actionId: "x", - payload: 1, - }); - }); - - it("routes chat.delta to onChat", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; - ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); - expect(onChat).toHaveBeenCalledOnce(); - expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("routes chat.error to onChat", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); - expect(onChat).toHaveBeenCalledOnce(); - expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("still routes surface catalog/surface to onMessage", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - const onChat = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - onChat, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); - expect(onMessage).toHaveBeenCalledOnce(); - expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); - expect(onChat).not.toHaveBeenCalled(); - - ws.invokeMessage( - JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), - ); - expect(onMessage).toHaveBeenCalledTimes(2); - }); - - it("send accepts and serializes a chat.send message", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - ws.resolveOpen(); - handle.send({ type: "chat.send", message: "hello" }); - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); - }); - - it("onChat absent is safe (surface-only usage does not throw)", () => { - const ws = fakeSocket(); - const onMessage = vi.fn(); - createSurfaceSocket({ - url: "ws://test", - onMessage, - socketFactory: () => ws, - }); - - ws.resolveOpen(); - expect(() => { - ws.invokeMessage( - JSON.stringify({ - type: "chat.delta", - event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, - }), - ); - ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); - }).not.toThrow(); - expect(onMessage).not.toHaveBeenCalled(); - }); - - it("chat send is queued until open then flushed", () => { - const ws = fakeSocket(); - const handle = createSurfaceSocket({ - url: "ws://test", - onMessage: vi.fn(), - socketFactory: () => ws, - }); - - handle.send({ type: "chat.send", message: "queued" }); - expect(ws.sent).toHaveLength(0); - - ws.resolveOpen(); - expect(ws.sent).toHaveLength(1); - expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); - }); + it("sends queued messages once socket opens", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + handle.send({ type: "subscribe", surfaceId: "s2" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(2); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "s1" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "s2" }); + }); + + it("sends immediately when socket is already open", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(1); + }); + + it("routes inbound messages to onMessage via parseServerMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + }); + + it("drops malformed inbound messages silently", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage("not json"); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("auto-reconnects on close and fires onReopen after successful reconnect", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType<typeof fakeSocket>[] = []; + const onMessage = vi.fn(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onReopen, + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + expect(sockets).toHaveLength(1); + sockets[0]?.resolveOpen(); + + // Simulate close + sockets[0]?.invokeClose(); + + // Fast-forward past the backoff delay + vi.advanceTimersByTime(600); + + expect(sockets).toHaveLength(2); + // onReopen should NOT have fired yet (socket not open) + expect(onReopen).not.toHaveBeenCalled(); + + sockets[1]?.resolveOpen(); + expect(onReopen).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not fire onReopen on initial connect", () => { + const ws = fakeSocket(); + const onReopen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + onReopen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(onReopen).not.toHaveBeenCalled(); + }); + + it("close() prevents further reconnects", () => { + vi.useFakeTimers(); + try { + const sockets: ReturnType<typeof fakeSocket>[] = []; + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => { + const ws = fakeSocket(); + sockets.push(ws); + return ws; + }, + }); + + sockets[0]?.resolveOpen(); + sockets[0]?.invokeClose(); + handle.close(); + + vi.advanceTimersByTime(10_000); + expect(sockets).toHaveLength(1); + } finally { + vi.useRealTimers(); + } + }); + + it("close() prevents further sends", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.sent.length = 0; + handle.close(); + + handle.send({ type: "subscribe", surfaceId: "s1" }); + expect(ws.sent).toHaveLength(0); + }); + + it("queues multiple sends before open and flushes in order", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "subscribe", surfaceId: "a" }); + handle.send({ type: "subscribe", surfaceId: "b" }); + handle.send({ type: "invoke", surfaceId: "a", actionId: "x", payload: 1 }); + ws.resolveOpen(); + + expect(ws.sent).toHaveLength(3); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "subscribe", surfaceId: "a" }); + expect(JSON.parse(ws.sent[1] ?? "")).toEqual({ type: "subscribe", surfaceId: "b" }); + expect(JSON.parse(ws.sent[2] ?? "")).toEqual({ + type: "invoke", + surfaceId: "a", + actionId: "x", + payload: 1, + }); + }); + + it("routes chat.delta to onChat", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" }; + ws.invokeMessage(JSON.stringify({ type: "chat.delta", event })); + expect(onChat).toHaveBeenCalledOnce(); + expect(onChat).toHaveBeenCalledWith({ type: "chat.delta", event }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("routes chat.error to onChat", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "bad request" })); + expect(onChat).toHaveBeenCalledOnce(); + expect(onChat).toHaveBeenCalledWith({ type: "chat.error", message: "bad request" }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("routes conversation.open to onConversationOpen", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + const onConversationOpen = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + onConversationOpen, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }), + ); + expect(onConversationOpen).toHaveBeenCalledOnce(); + expect(onConversationOpen).toHaveBeenCalledWith({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + expect(onMessage).not.toHaveBeenCalled(); + expect(onChat).not.toHaveBeenCalled(); + }); + + it("routes conversation.statusChanged to onConversationStatusChanged", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onConversationStatusChanged = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onConversationStatusChanged, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }), + ); + expect(onConversationStatusChanged).toHaveBeenCalledOnce(); + expect(onConversationStatusChanged).toHaveBeenCalledWith({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("still routes surface catalog/surface to onMessage", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + const onChat = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + onChat, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + ws.invokeMessage(JSON.stringify({ type: "catalog", catalog: [] })); + expect(onMessage).toHaveBeenCalledOnce(); + expect(onMessage).toHaveBeenCalledWith({ type: "catalog", catalog: [] }); + expect(onChat).not.toHaveBeenCalled(); + + ws.invokeMessage( + JSON.stringify({ type: "surface", spec: { id: "s1", region: "r", title: "S", fields: [] } }), + ); + expect(onMessage).toHaveBeenCalledTimes(2); + }); + + it("send accepts and serializes a chat.send message", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + ws.resolveOpen(); + handle.send({ type: "chat.send", message: "hello" }); + expect(ws.sent).toHaveLength(1); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "hello" }); + }); + + it("onChat absent is safe (surface-only usage does not throw)", () => { + const ws = fakeSocket(); + const onMessage = vi.fn(); + createSurfaceSocket({ + url: "ws://test", + onMessage, + socketFactory: () => ws, + }); + + ws.resolveOpen(); + expect(() => { + ws.invokeMessage( + JSON.stringify({ + type: "chat.delta", + event: { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "x" }, + }), + ); + ws.invokeMessage(JSON.stringify({ type: "chat.error", message: "boom" })); + }).not.toThrow(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("chat send is queued until open then flushed", () => { + const ws = fakeSocket(); + const handle = createSurfaceSocket({ + url: "ws://test", + onMessage: vi.fn(), + socketFactory: () => ws, + }); + + handle.send({ type: "chat.send", message: "queued" }); + expect(ws.sent).toHaveLength(0); + + ws.resolveOpen(); + expect(ws.sent).toHaveLength(1); + expect(JSON.parse(ws.sent[0] ?? "")).toEqual({ type: "chat.send", message: "queued" }); + }); }); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 54a501c..1309db0 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,108 +1,123 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - WsClientMessage, + ChatDeltaMessage, + ChatErrorMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + WsClientMessage, } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; export interface WebSocketLike { - send(data: string): void; - close(): void; - onopen: (() => void) | null; - onmessage: ((ev: { data: string }) => void) | null; - onclose: ((ev: { code: number; reason: string }) => void) | null; + send(data: string): void; + close(): void; + onopen: (() => void) | null; + onmessage: ((ev: { data: string }) => void) | null; + onclose: ((ev: { code: number; reason: string }) => void) | null; } export interface SurfaceSocketOptions { - url: string; - onMessage: (msg: SurfaceServerMessage) => void; - onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; - onReopen?: () => void; - socketFactory?: (url: string) => WebSocketLike; + url: string; + onMessage: (msg: SurfaceServerMessage) => void; + onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; + /** Broadcast when a conversation is "opened" (e.g. CLI `--open` flag). */ + onConversationOpen?: (msg: ConversationOpenMessage) => void; + /** Broadcast when a conversation's lifecycle status changes (active/idle/closed). */ + onConversationStatusChanged?: (msg: ConversationStatusChangedMessage) => void; + /** Broadcast when a conversation's history has been compacted (reload needed). */ + onConversationCompacted?: (msg: ConversationCompactedMessage) => void; + onReopen?: () => void; + socketFactory?: (url: string) => WebSocketLike; } export interface SurfaceSocketHandle { - send(msg: WsClientMessage): void; - close(): void; + send(msg: WsClientMessage): void; + close(): void; } export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHandle { - const factory = - opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); + const factory = + opts.socketFactory ?? ((url: string) => new WebSocket(url) as unknown as WebSocketLike); - let socket: WebSocketLike | null = null; - let disposed = false; - let reconnectAttempt = 0; - let reconnectTimer: ReturnType<typeof setTimeout> | null = null; - let isOpen = false; - const queue: string[] = []; + let socket: WebSocketLike | null = null; + let disposed = false; + let reconnectAttempt = 0; + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let isOpen = false; + const queue: string[] = []; - function connect(isReconnect: boolean): void { - socket = factory(opts.url); - isOpen = false; + function connect(isReconnect: boolean): void { + socket = factory(opts.url); + isOpen = false; - socket.onopen = () => { - if (disposed) return; - isOpen = true; - reconnectAttempt = 0; - for (const raw of queue.splice(0)) { - socket?.send(raw); - } - if (isReconnect) { - opts.onReopen?.(); - } - }; + socket.onopen = () => { + if (disposed) return; + isOpen = true; + reconnectAttempt = 0; + for (const raw of queue.splice(0)) { + socket?.send(raw); + } + if (isReconnect) { + opts.onReopen?.(); + } + }; - socket.onmessage = (ev) => { - if (disposed) return; - const msg = parseServerMessage(ev.data); - if (msg !== null) { - if (msg.type === "chat.delta" || msg.type === "chat.error") { - opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); - } else { - opts.onMessage(msg as SurfaceServerMessage); - } - } - }; + socket.onmessage = (ev) => { + if (disposed) return; + const msg = parseServerMessage(ev.data); + if (msg !== null) { + if (msg.type === "chat.delta" || msg.type === "chat.error") { + opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); + } else if (msg.type === "conversation.open") { + opts.onConversationOpen?.(msg as ConversationOpenMessage); + } else if (msg.type === "conversation.statusChanged") { + opts.onConversationStatusChanged?.(msg as ConversationStatusChangedMessage); + } else if (msg.type === "conversation.compacted") { + opts.onConversationCompacted?.(msg as ConversationCompactedMessage); + } else { + opts.onMessage(msg as SurfaceServerMessage); + } + } + }; - socket.onclose = () => { - if (disposed) return; - isOpen = false; - scheduleReconnect(); - }; - } + socket.onclose = () => { + if (disposed) return; + isOpen = false; + scheduleReconnect(); + }; + } - function scheduleReconnect(): void { - const delay = nextBackoffMs(reconnectAttempt); - reconnectAttempt++; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - if (disposed) return; - connect(true); - }, delay); - } + function scheduleReconnect(): void { + const delay = nextBackoffMs(reconnectAttempt); + reconnectAttempt++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (disposed) return; + connect(true); + }, delay); + } - connect(false); + connect(false); - return { - send(msg: WsClientMessage): void { - if (disposed) return; - const raw = serialize(msg); - if (isOpen) { - socket?.send(raw); - } else { - queue.push(raw); - } - }, - close(): void { - disposed = true; - if (reconnectTimer !== null) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - socket?.close(); - socket = null; - }, - }; + return { + send(msg: WsClientMessage): void { + if (disposed) return; + const raw = serialize(msg); + if (isOpen) { + socket?.send(raw); + } else { + queue.push(raw); + } + }, + close(): void { + disposed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket?.close(); + socket = null; + }, + }; } diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 546afe1..dd2b773 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -2,253 +2,399 @@ import { describe, expect, it } from "vitest"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; describe("serialize", () => { - it("serializes a subscribe message", () => { - const msg = { type: "subscribe" as const, surfaceId: "s1" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an unsubscribe message", () => { - const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an invoke message with payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes an invoke message without payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes a chat.send message", () => { - const msg = { type: "chat.send" as const, message: "hello" }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); - - it("serializes a chat.send message with all fields", () => { - const msg = { - type: "chat.send" as const, - conversationId: "c1", - message: "hello", - model: "openai/gpt-4", - cwd: "/tmp", - }; - expect(JSON.parse(serialize(msg))).toEqual(msg); - }); + it("serializes a subscribe message", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an unsubscribe message", () => { + const msg = { type: "unsubscribe" as const, surfaceId: "s1" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: true }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes an invoke message without payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "click" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes a chat.send message", () => { + const msg = { type: "chat.send" as const, message: "hello" }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); + + it("serializes a chat.send message with all fields", () => { + const msg = { + type: "chat.send" as const, + conversationId: "c1", + message: "hello", + model: "openai/gpt-4", + cwd: "/tmp", + }; + expect(JSON.parse(serialize(msg))).toEqual(msg); + }); }); describe("parseServerMessage", () => { - it("parses a catalog message", () => { - const data = JSON.stringify({ - type: "catalog", - catalog: [{ id: "s1", region: "r", title: "S1" }], - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "catalog", - catalog: [{ id: "s1", region: "r", title: "S1" }], - }); - }); - - it("parses a surface message", () => { - const data = JSON.stringify({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "surface", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }); - }); - - it("parses an update message", () => { - const data = JSON.stringify({ - type: "update", - update: { - surfaceId: "s1", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }, - }); - const result = parseServerMessage(data); - expect(result).toEqual({ - type: "update", - update: { - surfaceId: "s1", - spec: { id: "s1", region: "r", title: "S1", fields: [] }, - }, - }); - }); - - it("parses an error message with surfaceId", () => { - const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); - }); - - it("parses an error message without surfaceId", () => { - const data = JSON.stringify({ type: "error", message: "global boom" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "error", message: "global boom" }); - }); - - it("returns null for malformed JSON", () => { - expect(parseServerMessage("not json")).toBeNull(); - expect(parseServerMessage("{broken")).toBeNull(); - expect(parseServerMessage("")).toBeNull(); - }); - - it("returns null for non-object JSON", () => { - expect(parseServerMessage("42")).toBeNull(); - expect(parseServerMessage('"hello"')).toBeNull(); - expect(parseServerMessage("null")).toBeNull(); - expect(parseServerMessage("true")).toBeNull(); - expect(parseServerMessage("[1,2,3]")).toBeNull(); - }); - - it("returns null for unknown type", () => { - expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); - }); - - it("returns null when type is missing", () => { - expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); - }); - - it("returns null when type is not a string", () => { - expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); - }); - - it("returns null for catalog with non-array catalog field", () => { - expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); - }); - - it("returns null for surface with missing spec fields", () => { - expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); - }); - - it("returns null for surface with non-object spec", () => { - expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); - }); - - it("returns null for update with missing update field", () => { - expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); - }); - - it("returns null for update with invalid spec", () => { - expect( - parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), - ).toBeNull(); - }); - - it("returns null for error with non-string message", () => { - expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); - }); - - it("returns null for error with invalid surfaceId type", () => { - expect( - parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), - ).toBeNull(); - }); - - it("parses a chat.delta message", () => { - const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" }; - const data = JSON.stringify({ type: "chat.delta", event }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.delta", event }); - }); - - it("parses a chat.error message with conversationId", () => { - const data = JSON.stringify({ - type: "chat.error", - conversationId: "c1", - message: "bad request", - }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" }); - }); - - it("parses a chat.error message without conversationId", () => { - const data = JSON.stringify({ type: "chat.error", message: "no conversation" }); - const result = parseServerMessage(data); - expect(result).toEqual({ type: "chat.error", message: "no conversation" }); - }); - - it("returns null for chat.delta with non-object event", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull(); - }); - - it("returns null for chat.delta with missing event.type", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull(); - }); - - it("returns null for chat.error with non-string message", () => { - expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull(); - }); - - it("returns null for chat.error with invalid conversationId type", () => { - expect( - parseServerMessage( - JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }), - ), - ).toBeNull(); - }); + it("parses a catalog message", () => { + const data = JSON.stringify({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "catalog", + catalog: [{ id: "s1", region: "r", title: "S1" }], + }); + }); + + it("parses a surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }); + }); + + it("preserves the conversationId echo on a scoped surface message", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: "c1", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: "c1", + }); + }); + + it("rejects a surface message with a non-string conversationId", () => { + const data = JSON.stringify({ + type: "surface", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + conversationId: 42, + }); + expect(parseServerMessage(data)).toBeNull(); + }); + + it("parses an update message", () => { + const data = JSON.stringify({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "update", + update: { + surfaceId: "s1", + spec: { id: "s1", region: "r", title: "S1", fields: [] }, + }, + }); + }); + + it("parses an error message with surfaceId", () => { + const data = JSON.stringify({ type: "error", surfaceId: "s1", message: "boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", surfaceId: "s1", message: "boom" }); + }); + + it("parses an error message without surfaceId", () => { + const data = JSON.stringify({ type: "error", message: "global boom" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "error", message: "global boom" }); + }); + + it("returns null for malformed JSON", () => { + expect(parseServerMessage("not json")).toBeNull(); + expect(parseServerMessage("{broken")).toBeNull(); + expect(parseServerMessage("")).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + expect(parseServerMessage("42")).toBeNull(); + expect(parseServerMessage('"hello"')).toBeNull(); + expect(parseServerMessage("null")).toBeNull(); + expect(parseServerMessage("true")).toBeNull(); + expect(parseServerMessage("[1,2,3]")).toBeNull(); + }); + + it("returns null for unknown type", () => { + expect(parseServerMessage(JSON.stringify({ type: "unknown" }))).toBeNull(); + }); + + it("returns null when type is missing", () => { + expect(parseServerMessage(JSON.stringify({ foo: "bar" }))).toBeNull(); + }); + + it("returns null when type is not a string", () => { + expect(parseServerMessage(JSON.stringify({ type: 42 }))).toBeNull(); + }); + + it("returns null for catalog with non-array catalog field", () => { + expect(parseServerMessage(JSON.stringify({ type: "catalog", catalog: "nope" }))).toBeNull(); + }); + + it("returns null for surface with missing spec fields", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: { id: "s1" } }))).toBeNull(); + }); + + it("returns null for surface with non-object spec", () => { + expect(parseServerMessage(JSON.stringify({ type: "surface", spec: "nope" }))).toBeNull(); + }); + + it("returns null for update with missing update field", () => { + expect(parseServerMessage(JSON.stringify({ type: "update" }))).toBeNull(); + }); + + it("returns null for update with invalid spec", () => { + expect( + parseServerMessage(JSON.stringify({ type: "update", update: { surfaceId: "s1", spec: {} } })), + ).toBeNull(); + }); + + it("returns null for error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "error", message: 42 }))).toBeNull(); + }); + + it("returns null for error with invalid surfaceId type", () => { + expect( + parseServerMessage(JSON.stringify({ type: "error", surfaceId: 42, message: "boom" })), + ).toBeNull(); + }); + + it("parses a chat.delta message", () => { + const event = { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hello" }; + const data = JSON.stringify({ type: "chat.delta", event }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.delta", event }); + }); + + it("parses a chat.error message with conversationId", () => { + const data = JSON.stringify({ + type: "chat.error", + conversationId: "c1", + message: "bad request", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.error", conversationId: "c1", message: "bad request" }); + }); + + it("parses a chat.error message without conversationId", () => { + const data = JSON.stringify({ type: "chat.error", message: "no conversation" }); + const result = parseServerMessage(data); + expect(result).toEqual({ type: "chat.error", message: "no conversation" }); + }); + + it("returns null for chat.delta with non-object event", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: "nope" }))).toBeNull(); + }); + + it("returns null for chat.delta with missing event.type", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.delta", event: {} }))).toBeNull(); + }); + + it("returns null for chat.error with non-string message", () => { + expect(parseServerMessage(JSON.stringify({ type: "chat.error", message: 42 }))).toBeNull(); + }); + + it("returns null for chat.error with invalid conversationId type", () => { + expect( + parseServerMessage( + JSON.stringify({ type: "chat.error", conversationId: 42, message: "boom" }), + ), + ).toBeNull(); + }); + + it("parses a conversation.open message", () => { + const data = JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + const result = parseServerMessage(data); + expect(result).toEqual({ + type: "conversation.open", + conversationId: "c1", + workspaceId: "w1", + }); + }); + + it("returns null for conversation.open with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", workspaceId: "w1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string conversationId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: 42, + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.open with missing workspaceId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.open", conversationId: "c1" })), + ).toBeNull(); + }); + + it("returns null for conversation.open with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.open", + conversationId: "c1", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); + + it("parses a conversation.statusChanged message", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: "w1", + }); + }); + + it("accepts the `queued` status (CR-13 — waiting for a concurrency slot)", () => { + const data = JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + expect(parseServerMessage(data)).toEqual({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "queued", + workspaceId: "w1", + }); + }); + + it("returns null for conversation.statusChanged with missing workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with non-string workspaceId", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "active", + workspaceId: 42, + }), + ), + ).toBeNull(); + }); + it("returns null for conversation.statusChanged with invalid status", () => { + expect( + parseServerMessage( + JSON.stringify({ + type: "conversation.statusChanged", + conversationId: "c1", + status: "done", + workspaceId: "w1", + }), + ), + ).toBeNull(); + }); + + it("returns null for conversation.statusChanged with missing conversationId", () => { + expect( + parseServerMessage(JSON.stringify({ type: "conversation.statusChanged", status: "idle" })), + ).toBeNull(); + }); }); describe("round-trip: parseServerMessage(serialize(...))", () => { - it("round-trips a subscribe message through serialize only", () => { - const msg = { type: "subscribe" as const, surfaceId: "s1" }; - const wire = serialize(msg); - expect(JSON.parse(wire)).toEqual(msg); - }); - - it("round-trips an invoke message with payload", () => { - const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; - const wire = serialize(msg); - expect(JSON.parse(wire)).toEqual(msg); - }); + it("round-trips a subscribe message through serialize only", () => { + const msg = { type: "subscribe" as const, surfaceId: "s1" }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); + + it("round-trips an invoke message with payload", () => { + const msg = { type: "invoke" as const, surfaceId: "s1", actionId: "toggle", payload: false }; + const wire = serialize(msg); + expect(JSON.parse(wire)).toEqual(msg); + }); }); describe("nextBackoffMs", () => { - it("returns a positive number", () => { - expect(nextBackoffMs(0)).toBeGreaterThan(0); - }); - - it("is capped at 30s + jitter (at most ~36s)", () => { - for (let i = 0; i < 100; i++) { - expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); - } - }); - - it("starts around 500ms (±20% jitter)", () => { - for (let i = 0; i < 100; i++) { - const ms = nextBackoffMs(0); - expect(ms).toBeGreaterThanOrEqual(400); - expect(ms).toBeLessThanOrEqual(600); - } - }); - - it("grows exponentially with attempt", () => { - const averages = [0, 1, 2, 3].map((attempt) => { - let sum = 0; - for (let i = 0; i < 200; i++) { - sum += nextBackoffMs(attempt); - } - return sum / 200; - }); - for (let i = 1; i < averages.length; i++) { - const prev = averages[i - 1]; - if (prev === undefined) throw new Error("unreachable"); - expect(averages[i]).toBeGreaterThan(prev); - } - }); - - it("treats negative attempt as 0", () => { - for (let i = 0; i < 50; i++) { - const ms = nextBackoffMs(-5); - expect(ms).toBeGreaterThanOrEqual(400); - expect(ms).toBeLessThanOrEqual(600); - } - }); + it("returns a positive number", () => { + expect(nextBackoffMs(0)).toBeGreaterThan(0); + }); + + it("is capped at 30s + jitter (at most ~36s)", () => { + for (let i = 0; i < 100; i++) { + expect(nextBackoffMs(100)).toBeLessThanOrEqual(36_000); + } + }); + + it("starts around 500ms (±20% jitter)", () => { + for (let i = 0; i < 100; i++) { + const ms = nextBackoffMs(0); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); + + it("grows exponentially with attempt", () => { + const averages = [0, 1, 2, 3].map((attempt) => { + let sum = 0; + for (let i = 0; i < 200; i++) { + sum += nextBackoffMs(attempt); + } + return sum / 200; + }); + for (let i = 1; i < averages.length; i++) { + const prev = averages[i - 1]; + if (prev === undefined) throw new Error("unreachable"); + expect(averages[i]).toBeGreaterThan(prev); + } + }); + + it("treats negative attempt as 0", () => { + for (let i = 0; i < 50; i++) { + const ms = nextBackoffMs(-5); + expect(ms).toBeGreaterThanOrEqual(400); + expect(ms).toBeLessThanOrEqual(600); + } + }); }); diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 6592f1b..03ef763 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,32 +1,38 @@ import type { - ChatDeltaMessage, - ChatErrorMessage, - WsClientMessage, - WsServerMessage, + ChatDeltaMessage, + ChatErrorMessage, + ConversationCompactedMessage, + ConversationOpenMessage, + ConversationStatusChangedMessage, + WsClientMessage, + WsServerMessage, } from "@dispatch/transport-contract"; import type { - CatalogMessage, - SurfaceErrorMessage, - SurfaceMessage, - SurfaceUpdateMessage, + CatalogMessage, + SurfaceErrorMessage, + SurfaceMessage, + SurfaceUpdateMessage, } from "@dispatch/ui-contract"; const VALID_SERVER_TYPES = new Set([ - "catalog", - "surface", - "update", - "error", - "chat.delta", - "chat.error", + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + "conversation.statusChanged", + "conversation.compacted", ]); /** Serialize a client message to a JSON string for the wire. */ export function serialize(msg: WsClientMessage): string { - return JSON.stringify(msg); + return JSON.stringify(msg); } function isRecord(v: unknown): v is Record<string, unknown> { - return v !== null && typeof v === "object" && !Array.isArray(v); + return v !== null && typeof v === "object" && !Array.isArray(v); } /** @@ -34,74 +40,126 @@ function isRecord(v: unknown): v is Record<string, unknown> { * Returns null for malformed JSON or shapes that don't match the protocol. */ export function parseServerMessage(data: string): WsServerMessage | null { - let parsed: unknown; - try { - parsed = JSON.parse(data); - } catch { - return null; - } - if (!isRecord(parsed)) { - return null; - } - const t = parsed.type; - if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { - return null; - } - switch (t) { - case "catalog": { - if (!Array.isArray(parsed.catalog)) return null; - return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; - } - case "surface": { - const spec = parsed.spec; - if (!isRecord(spec)) return null; - if (typeof spec.id !== "string") return null; - if (typeof spec.region !== "string") return null; - if (typeof spec.title !== "string") return null; - if (!Array.isArray(spec.fields)) return null; - return { type: "surface", spec: spec as unknown as SurfaceMessage["spec"] }; - } - case "update": { - const update = parsed.update; - if (!isRecord(update)) return null; - if (typeof update.surfaceId !== "string") return null; - const spec = update.spec; - if (!isRecord(spec)) return null; - if (typeof spec.id !== "string") return null; - if (typeof spec.region !== "string") return null; - if (typeof spec.title !== "string") return null; - if (!Array.isArray(spec.fields)) return null; - return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; - } - case "error": { - if (typeof parsed.message !== "string") return null; - const surfaceId = parsed.surfaceId; - if (surfaceId !== undefined && typeof surfaceId !== "string") return null; - const msg: SurfaceErrorMessage = - surfaceId !== undefined - ? { type: "error", surfaceId, message: parsed.message } - : { type: "error", message: parsed.message }; - return msg; - } - case "chat.delta": { - const event = parsed.event; - if (!isRecord(event)) return null; - if (typeof event.type !== "string") return null; - return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] }; - } - case "chat.error": { - if (typeof parsed.message !== "string") return null; - const conversationId = parsed.conversationId; - if (conversationId !== undefined && typeof conversationId !== "string") return null; - const msg: ChatErrorMessage = - conversationId !== undefined - ? { type: "chat.error", conversationId, message: parsed.message } - : { type: "chat.error", message: parsed.message }; - return msg; - } - default: - return null; - } + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + return null; + } + if (!isRecord(parsed)) { + return null; + } + const t = parsed.type; + if (typeof t !== "string" || !VALID_SERVER_TYPES.has(t)) { + return null; + } + switch (t) { + case "catalog": { + if (!Array.isArray(parsed.catalog)) return null; + return { type: "catalog", catalog: parsed.catalog as CatalogMessage["catalog"] }; + } + case "surface": { + const spec = parsed.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + // Preserve the conversationId echo (a conversation-scoped surface's initial + // reply carries it) — dropping it would defeat the protocol reducer's + // stale-scope filtering on a fast conversation switch. + const conversationId = parsed.conversationId; + if (conversationId !== undefined && typeof conversationId !== "string") return null; + const surfaceSpec = spec as unknown as SurfaceMessage["spec"]; + return conversationId !== undefined + ? { type: "surface", spec: surfaceSpec, conversationId } + : { type: "surface", spec: surfaceSpec }; + } + case "update": { + const update = parsed.update; + if (!isRecord(update)) return null; + if (typeof update.surfaceId !== "string") return null; + const spec = update.spec; + if (!isRecord(spec)) return null; + if (typeof spec.id !== "string") return null; + if (typeof spec.region !== "string") return null; + if (typeof spec.title !== "string") return null; + if (!Array.isArray(spec.fields)) return null; + return { type: "update", update: update as unknown as SurfaceUpdateMessage["update"] }; + } + case "error": { + if (typeof parsed.message !== "string") return null; + const surfaceId = parsed.surfaceId; + if (surfaceId !== undefined && typeof surfaceId !== "string") return null; + const msg: SurfaceErrorMessage = + surfaceId !== undefined + ? { type: "error", surfaceId, message: parsed.message } + : { type: "error", message: parsed.message }; + return msg; + } + case "chat.delta": { + const event = parsed.event; + if (!isRecord(event)) return null; + if (typeof event.type !== "string") return null; + return { type: "chat.delta", event: event as unknown as ChatDeltaMessage["event"] }; + } + case "chat.error": { + if (typeof parsed.message !== "string") return null; + const conversationId = parsed.conversationId; + if (conversationId !== undefined && typeof conversationId !== "string") return null; + const msg: ChatErrorMessage = + conversationId !== undefined + ? { type: "chat.error", conversationId, message: parsed.message } + : { type: "chat.error", message: parsed.message }; + return msg; + } + case "conversation.open": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.workspaceId !== "string") return null; + const msg: ConversationOpenMessage = { + type: "conversation.open", + conversationId: parsed.conversationId, + workspaceId: parsed.workspaceId, + }; + return msg; + } + case "conversation.statusChanged": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.status !== "string") return null; + if ( + parsed.status !== "active" && + parsed.status !== "queued" && + parsed.status !== "idle" && + parsed.status !== "closed" + ) { + return null; + } + if (typeof parsed.workspaceId !== "string") return null; + const msg: ConversationStatusChangedMessage = { + type: "conversation.statusChanged", + conversationId: parsed.conversationId, + status: parsed.status, + workspaceId: parsed.workspaceId, + }; + return msg; + } + case "conversation.compacted": { + if (typeof parsed.conversationId !== "string") return null; + if (typeof parsed.newConversationId !== "string") return null; + if (typeof parsed.messagesSummarized !== "number") return null; + if (typeof parsed.messagesKept !== "number") return null; + const msg: ConversationCompactedMessage = { + type: "conversation.compacted", + conversationId: parsed.conversationId, + newConversationId: parsed.newConversationId, + messagesSummarized: parsed.messagesSummarized, + messagesKept: parsed.messagesKept, + }; + return msg; + } + default: + return null; + } } /** @@ -109,10 +167,10 @@ export function parseServerMessage(data: string): WsServerMessage | null { * Base: 500ms, doubles each attempt, caps at 30s, adds ±20% jitter. */ export function nextBackoffMs(attempt: number): number { - const base = 500; - const max = 30_000; - const exponential = base * 2 ** Math.max(0, attempt); - const capped = Math.min(exponential, max); - const jitter = 0.8 + Math.random() * 0.4; - return Math.round(capped * jitter); + const base = 500; + const max = 30_000; + const exponential = base * 2 ** Math.max(0, attempt); + const capped = Math.min(exponential, max); + const jitter = 0.8 + Math.random() * 0.4; + return Math.round(capped * jitter); } |
