diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 00:39:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 00:39:31 +0900 |
| commit | d96e504d197140e83c379a02527cf8148925ea67 (patch) | |
| tree | 8e94ca913c93e279fb0a2b9fb8ef2d78220dd57f | |
| parent | 979fd1aac559805e05b36369e0fb756a8ec517dd (diff) | |
| download | dispatch-web-d96e504d197140e83c379a02527cf8148925ea67.tar.gz dispatch-web-d96e504d197140e83c379a02527cf8148925ea67.zip | |
Slice 2 wave 3: wire chat end-to-end at the composition root
- app/store.svelte.ts: one WebSocket carries surfaces AND chat (onChat ->
chatStore.handleDelta); build the conversation cache over the IndexedDB
adapter; createChatStore wired to transport (socket.send), injected HTTP
historySync, and the cache; load() on construct
- app/resolve-http-url.ts: host-relative HTTP base (port 24203), mirrors
resolve-ws-url; injected fetch
- App.svelte: render ChatView + Composer alongside the surface picker
- createAppStore gains optional injection points (httpUrl/fetchImpl/indexedDB/
conversationId) for tests
- vitest-setup.ts: fake-indexeddb/auto for jsdom IndexedDB (orchestrator-owned
config; agent change adopted)
Verified green (x2, stable): svelte-check 0/0, vitest 218, biome clean, build ok.
Slice 2 (conversation transcript: cache + delta streaming) feature-complete.
| -rw-r--r-- | src/app/App.svelte | 18 | ||||
| -rw-r--r-- | src/app/App.test.ts | 161 | ||||
| -rw-r--r-- | src/app/index.ts | 2 | ||||
| -rw-r--r-- | src/app/resolve-http-url.test.ts | 56 | ||||
| -rw-r--r-- | src/app/resolve-http-url.ts | 28 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 80 | ||||
| -rw-r--r-- | src/app/store.test.ts | 240 | ||||
| -rw-r--r-- | vitest-setup.ts | 1 |
8 files changed, 546 insertions, 40 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 2619a39..92939c2 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import type { InvokeMessage } from "@dispatch/ui-contract"; import { SurfaceView } from "../features/surface-host"; + import { ChatView, Composer } from "../features/chat"; import type { AppStore } from "./store.svelte"; let { store }: { store: AppStore } = $props(); @@ -12,6 +13,10 @@ function handleInvoke(msg: InvokeMessage) { store.invoke(msg.surfaceId, msg.actionId, msg.payload); } + + function handleSend(text: string) { + store.chat.send(text); + } </script> <main> @@ -24,6 +29,19 @@ </div> {/if} + {#if store.chat.error} + <div role="alert"> + <strong>Chat error:</strong> + {store.chat.error} + </div> + {/if} + + <section> + <h2>Chat</h2> + <ChatView chunks={store.chat.chunks} /> + <Composer onSend={handleSend} /> + </section> + <section> <h2>Surfaces</h2> {#if store.catalog.length === 0} diff --git a/src/app/App.test.ts b/src/app/App.test.ts index ce37586..b21b39f 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -1,5 +1,6 @@ +import type { WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; -import { render, screen } from "@testing-library/svelte"; +import { render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; @@ -9,7 +10,8 @@ import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { sent: string[]; resolveOpen(): void; - feedMessage(data: SurfaceServerMessage): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { @@ -41,7 +43,10 @@ function fakeSocket(): FakeSocket { resolveOpen() { onopen?.(); }, - feedMessage(msg: SurfaceServerMessage) { + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { onmessage?.({ data: JSON.stringify(msg) }); }, sent, @@ -49,6 +54,11 @@ function fakeSocket(): FakeSocket { return ws; } +function fakeFetchImpl(): typeof fetch { + return async (): Promise<Response> => + new Response(JSON.stringify({ chunks: [], latestSeq: 0 }), { status: 200 }); +} + function sentMessages(ws: FakeSocket) { return ws.sent.map((s) => JSON.parse(s)); } @@ -56,7 +66,11 @@ function sentMessages(ws: FakeSocket) { describe("App component interaction tests", () => { it("renders empty state when catalog is empty", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); render(App, { props: { store } }); @@ -68,10 +82,14 @@ describe("App component interaction tests", () => { it("renders a catalog button per entry after a catalog message", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -81,7 +99,9 @@ describe("App component interaction tests", () => { render(App, { props: { store } }); - const buttons = screen.getAllByRole("button"); + const surfacesSection = screen.getByRole("heading", { name: "Surfaces" }).closest("section"); + if (surfacesSection === null) throw new Error("Surfaces section not found"); + const buttons = within(surfacesSection).getAllByRole("button"); expect(buttons).toHaveLength(2); expect(buttons[0]).toHaveTextContent("Surface One"); expect(buttons[1]).toHaveTextContent("Surface Two"); @@ -91,10 +111,14 @@ describe("App component interaction tests", () => { it("clicking a catalog entry subscribes and renders its surface", async () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); @@ -112,7 +136,7 @@ describe("App component interaction tests", () => { ); expect(subscribe).toBeTruthy(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "surface", spec: { id: "s1", @@ -131,10 +155,14 @@ describe("App component interaction tests", () => { it("clicking a different entry unsubscribes the previous then subscribes the new", async () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -162,10 +190,14 @@ describe("App component interaction tests", () => { it("selected catalog button reflects aria-current", async () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -192,10 +224,14 @@ describe("App component interaction tests", () => { it("an error message renders the alert banner", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "error", message: "Something went wrong", }); @@ -210,10 +246,14 @@ describe("App component interaction tests", () => { it("invoking a field action sends an invoke", async () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); @@ -223,7 +263,7 @@ describe("App component interaction tests", () => { const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: /Surface One/ })); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "surface", spec: { id: "s1", @@ -256,4 +296,87 @@ describe("App component interaction tests", () => { store.dispose(); }); + + it("renders the chat section with composer", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + expect(screen.getByRole("heading", { name: "Chat" })).toBeInTheDocument(); + expect(screen.getByRole("log")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Message input" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + + store.dispose(); + }); + + it("typing and sending a message posts chat.send on the socket", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + const user = userEvent.setup(); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "hello from UI"); + + ws.sent.length = 0; + const sendBtn = screen.getByRole("button", { name: "Send" }); + await user.click(sendBtn); + + const msgs = sentMessages(ws); + const chatSend = msgs.find((m: { type: string }) => m.type === "chat.send") as + | { type: string; conversationId: string; message: string } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.message).toBe("hello from UI"); + + store.dispose(); + }); + + it("incoming chat.delta renders text in the chat transcript", async () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + render(App, { props: { store } }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "test-conv", + turnId: "turn-1", + delta: "Hi there!", + }, + }); + + expect(await screen.findByText("Hi there!")).toBeInTheDocument(); + + store.dispose(); + }); }); diff --git a/src/app/index.ts b/src/app/index.ts index f94b554..f4f47e9 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -1,3 +1,3 @@ export { default as App } from "./App.svelte"; -export type { AppStore } from "./store.svelte"; +export type { AppStore, CreateAppStoreOptions } from "./store.svelte"; export { createAppStore } from "./store.svelte"; diff --git a/src/app/resolve-http-url.test.ts b/src/app/resolve-http-url.test.ts new file mode 100644 index 0000000..90edcbb --- /dev/null +++ b/src/app/resolve-http-url.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolveHttpUrl } from "./resolve-http-url"; + +describe("resolveHttpUrl", () => { + it("explicit url wins over everything", () => { + const result = resolveHttpUrl( + { VITE_HTTP_URL: "https://env.example.com:9999" }, + { protocol: "https:", hostname: "page.example.com" }, + ); + expect(result).toBe("https://env.example.com:9999"); + }); + + it("VITE_HTTP_URL wins over derivation", () => { + const result = resolveHttpUrl( + { VITE_HTTP_URL: "https://env.example.com:8888" }, + { protocol: "http:", hostname: "page.example.com" }, + ); + expect(result).toBe("https://env.example.com:8888"); + }); + + it("derives http://<hostname>:24203 from http location", () => { + const result = resolveHttpUrl({}, { protocol: "http:", hostname: "100.126.75.103" }); + expect(result).toBe("http://100.126.75.103:24203"); + }); + + it("derives https://<hostname>:24203 from https location", () => { + const result = resolveHttpUrl({}, { protocol: "https:", hostname: "arch-razer" }); + expect(result).toBe("https://arch-razer:24203"); + }); + + it("uses VITE_HTTP_PORT when set", () => { + const result = resolveHttpUrl( + { VITE_HTTP_PORT: "3000" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("http://localhost:3000"); + }); + + it("falls back to http://localhost:24203 when location is missing", () => { + const result = resolveHttpUrl({}); + expect(result).toBe("http://localhost:24203"); + }); + + it("VITE_HTTP_URL empty string treated as unset", () => { + const result = resolveHttpUrl({ VITE_HTTP_URL: "" }, { protocol: "http:", hostname: "myhost" }); + expect(result).toBe("http://myhost:24203"); + }); + + it("VITE_HTTP_PORT empty string falls back to default", () => { + const result = resolveHttpUrl( + { VITE_HTTP_PORT: "" }, + { protocol: "http:", hostname: "localhost" }, + ); + expect(result).toBe("http://localhost:24203"); + }); +}); diff --git a/src/app/resolve-http-url.ts b/src/app/resolve-http-url.ts new file mode 100644 index 0000000..357d2fc --- /dev/null +++ b/src/app/resolve-http-url.ts @@ -0,0 +1,28 @@ +export interface HttpUrlEnv { + readonly VITE_HTTP_URL?: string; + readonly VITE_HTTP_PORT?: string; +} + +export interface HttpUrlLocation { + readonly protocol: string; + readonly hostname: string; +} + +const DEFAULT_PORT = "24203"; +const DEFAULT_FALLBACK = "http://localhost:24203"; + +export function resolveHttpUrl(env: HttpUrlEnv, location?: HttpUrlLocation): string { + if (env.VITE_HTTP_URL !== undefined && env.VITE_HTTP_URL !== "") { + return env.VITE_HTTP_URL; + } + + if (location === undefined) { + return DEFAULT_FALLBACK; + } + + const port = + env.VITE_HTTP_PORT !== undefined && env.VITE_HTTP_PORT !== "" + ? env.VITE_HTTP_PORT + : DEFAULT_PORT; + return `${location.protocol}//${location.hostname}:${port}`; +} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 6b7a910..914e682 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -1,4 +1,6 @@ +import type { ConversationHistoryResponse } from "@dispatch/transport-contract"; import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract"; +import { createIdbChunkStore } from "../adapters/idb"; import type { WebSocketLike } from "../adapters/ws"; import { createSurfaceSocket, type SurfaceSocketOptions } from "../adapters/ws"; import { @@ -9,6 +11,11 @@ import { subscribe as protocolSubscribe, unsubscribe as protocolUnsubscribe, } from "../core/protocol"; +import type { ChatStore } from "../features/chat"; +import { createChatStore } from "../features/chat"; +import type { ConversationCache } from "../features/conversation-cache"; +import { createConversationCache } from "../features/conversation-cache"; +import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; export interface AppStore { @@ -16,15 +23,36 @@ export interface AppStore { readonly selectedId: string | null; readonly selectedSpec: SurfaceSpec | null; readonly lastError: ProtocolState["lastError"]; + readonly chat: ChatStore; select(surfaceId: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; dispose(): void; } -export function createAppStore(opts?: { +export interface CreateAppStoreOptions { url?: string; + httpUrl?: string; socketFactory?: (url: string) => WebSocketLike; -}): AppStore { + fetchImpl?: typeof fetch; + indexedDB?: IDBFactory; + conversationId?: string; +} + +function createHistorySync( + httpBase: string, + fetchImpl: typeof fetch, +): (conversationId: string, sinceSeq: number) => Promise<ConversationHistoryResponse> { + return async (conversationId: string, sinceSeq: number) => { + const url = `${httpBase}/conversations/${encodeURIComponent(conversationId)}?sinceSeq=${sinceSeq}`; + const res = await fetchImpl(url); + if (!res.ok) { + throw new Error(`History sync failed: ${res.status}`); + } + return (await res.json()) as ConversationHistoryResponse; + }; +} + +export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let protocol = $state<ProtocolState>(initialState()); let selectedId = $state<string | null>(null); @@ -35,15 +63,53 @@ export function createAppStore(opts?: { } const wsLocation = typeof location !== "undefined" ? location : undefined; - const url = + const wsUrl = opts?.url ?? resolveWsUrl( { VITE_WS_URL: import.meta.env.VITE_WS_URL, VITE_WS_PORT: import.meta.env.VITE_WS_PORT }, wsLocation, ); + + const httpLocation = typeof location !== "undefined" ? location : undefined; + const httpBase = + opts?.httpUrl ?? + resolveHttpUrl( + { + VITE_HTTP_URL: import.meta.env.VITE_HTTP_URL, + VITE_HTTP_PORT: import.meta.env.VITE_HTTP_PORT, + }, + httpLocation, + ); + + const fetchImpl = opts?.fetchImpl ?? globalThis.fetch.bind(globalThis); + const indexedDBFactory = opts?.indexedDB ?? globalThis.indexedDB; + const conversationId = opts?.conversationId ?? crypto.randomUUID(); + + const cache: ConversationCache = createConversationCache( + createIdbChunkStore({ indexedDB: indexedDBFactory }), + ); + + const historySync = createHistorySync(httpBase, fetchImpl); + + const chatStore = createChatStore({ + conversationId, + transport: { + send(msg) { + socket?.send(msg); + }, + }, + historySync, + cache, + }); + + let chat = $state<ChatStore>(chatStore as ChatStore); + const socketOpts: SurfaceSocketOptions = { - url, + url: wsUrl, onMessage: handleServerMessage, + onChat(msg) { + chatStore.handleDelta(msg); + }, onReopen() { if (selectedId !== null) { const result = protocolSubscribe(protocol, selectedId); @@ -59,6 +125,8 @@ export function createAppStore(opts?: { } socket = createSurfaceSocket(socketOpts); + void chatStore.load(); + return { get catalog() { return protocol.catalog; @@ -73,6 +141,9 @@ export function createAppStore(opts?: { get lastError() { return protocol.lastError; }, + get chat() { + return chat; + }, select(surfaceId: string): void { if (selectedId !== null && selectedId !== surfaceId) { const unsub = protocolUnsubscribe(protocol, selectedId); @@ -96,6 +167,7 @@ export function createAppStore(opts?: { } }, dispose(): void { + chatStore.dispose(); socket?.close(); socket = null; }, diff --git a/src/app/store.test.ts b/src/app/store.test.ts index b521975..7b00d42 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1,3 +1,4 @@ +import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; @@ -6,7 +7,8 @@ import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { sent: string[]; resolveOpen(): void; - feedMessage(data: SurfaceServerMessage): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { @@ -38,7 +40,10 @@ function fakeSocket(): FakeSocket { resolveOpen() { onopen?.(); }, - feedMessage(msg: SurfaceServerMessage) { + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { onmessage?.({ data: JSON.stringify(msg) }); }, sent, @@ -46,10 +51,27 @@ function fakeSocket(): FakeSocket { return ws; } +function fakeFetchImpl(responses: Record<string, unknown> = {}): typeof fetch { + return async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const body = + responses[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); + return new Response(JSON.stringify(body), { status: 200 }); + }; +} + +function parseSent(ws: FakeSocket): unknown[] { + return ws.sent.map((s) => JSON.parse(s)); +} + describe("createAppStore", () => { it("starts with empty catalog and no selection", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); expect(store.catalog).toEqual([]); @@ -62,10 +84,14 @@ describe("createAppStore", () => { it("updates catalog when catalog message arrives", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -82,10 +108,14 @@ describe("createAppStore", () => { it("select sends subscribe and sets selectedId", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); @@ -105,10 +135,14 @@ describe("createAppStore", () => { it("selecting a different surface unsubscribes from previous", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -137,17 +171,21 @@ describe("createAppStore", () => { it("surface message updates selectedSpec", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); store.select("s1"); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "surface", spec: { id: "s1", @@ -166,7 +204,11 @@ describe("createAppStore", () => { it("invoke sends an invoke message", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); ws.sent.length = 0; @@ -188,10 +230,14 @@ describe("createAppStore", () => { it("error message updates lastError", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "error", message: "Something went wrong", }); @@ -211,10 +257,172 @@ describe("createAppStore", () => { origClose(); }; - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); store.dispose(); expect(closeSpy.called).toBe(true); }); + + it("exposes chat store with empty initial messages", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + expect(store.chat).toBeDefined(); + expect(store.chat.messages).toEqual([]); + expect(store.chat.chunks).toEqual([]); + expect(store.chat.error).toBeNull(); + + store.dispose(); + }); + + it("sending a message posts a chat.send on the socket", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.chat.send("hello world"); + + 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?.conversationId).toBe("test-conv"); + 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(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "test-conv", + turnId: "turn-1", + delta: "Hello ", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "test-conv", + turnId: "turn-1", + delta: "world", + }, + }); + + expect(store.chat.chunks.length).toBeGreaterThan(0); + const textChunks = store.chat.chunks.filter((c) => c.chunk.type === "text"); + expect(textChunks).toHaveLength(1); + expect((textChunks[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(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + ws.feedServerMessage({ + type: "chat.error", + message: "bad request", + }); + + expect(store.chat.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); + return new Response(JSON.stringify(historyResponse), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + conversationId: "test-conv", + httpUrl: "http://localhost:24203", + }); + ws.resolveOpen(); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-sealed", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchedUrls.some((u) => u.includes("/conversations/test-conv?sinceSeq="))).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.chat.chunks.length).toBeGreaterThan(0); + + store.dispose(); + }); }); diff --git a/vitest-setup.ts b/vitest-setup.ts index f149f27..2c29eea 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -1 +1,2 @@ import "@testing-library/jest-dom/vitest"; +import "fake-indexeddb/auto"; |
