summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 00:39:31 +0900
committerAdam Malczewski <[email protected]>2026-06-07 00:39:31 +0900
commitd96e504d197140e83c379a02527cf8148925ea67 (patch)
tree8e94ca913c93e279fb0a2b9fb8ef2d78220dd57f
parent979fd1aac559805e05b36369e0fb756a8ec517dd (diff)
downloaddispatch-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.svelte18
-rw-r--r--src/app/App.test.ts161
-rw-r--r--src/app/index.ts2
-rw-r--r--src/app/resolve-http-url.test.ts56
-rw-r--r--src/app/resolve-http-url.ts28
-rw-r--r--src/app/store.svelte.ts80
-rw-r--r--src/app/store.test.ts240
-rw-r--r--vitest-setup.ts1
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";