diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 00:02:32 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 00:02:32 +0900 |
| commit | 5d9ae1849337b64af1b0d47c23b8c4950a55f792 (patch) | |
| tree | dd5fccaff7535bf1216457a986b8f95bd14fd61e /src/adapters | |
| parent | fac44794432928d0341728642fd70eef87837da4 (diff) | |
| download | dispatch-web-5d9ae1849337b64af1b0d47c23b8c4950a55f792.tar.gz dispatch-web-5d9ae1849337b64af1b0d47c23b8c4950a55f792.zip | |
Slice 2 wave 1: transcript reducer, wire conformance, ws chat, cache core
- core/chunks: the one pure transcript reducer (foldEvent live deltas +
applyHistory seq-keyed reconcile + selectChunks/selectMessages); 27 tests
- core/wire: FE-side contract-conformance exhaustiveness guards + drift smoke
tests over wire/transport-contract unions (ยง2.9 drift signal); 10 tests
- adapters/ws: additively multiplex chat.send/chat.delta/chat.error on the
existing surface socket (onChat + widened send); surface API unchanged
- features/conversation-cache: pure reconcileCache/nextSinceSeq/selectEvictions
+ ConversationChunkStore port + injected createConversationCache; 26 tests
Verified green: svelte-check 0/0, vitest 169, biome clean, build ok.
Diffstat (limited to 'src/adapters')
| -rw-r--r-- | src/adapters/ws/index.test.ts | 112 | ||||
| -rw-r--r-- | src/adapters/ws/index.ts | 18 | ||||
| -rw-r--r-- | src/adapters/ws/logic.test.ts | 59 | ||||
| -rw-r--r-- | src/adapters/ws/logic.ts | 39 |
4 files changed, 218 insertions, 10 deletions
diff --git a/src/adapters/ws/index.test.ts b/src/adapters/ws/index.test.ts index 92b8753..961f919 100644 --- a/src/adapters/ws/index.test.ts +++ b/src/adapters/ws/index.test.ts @@ -231,4 +231,116 @@ describe("createSurfaceSocket", () => { 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" }); + }); }); diff --git a/src/adapters/ws/index.ts b/src/adapters/ws/index.ts index 40eda2b..54a501c 100644 --- a/src/adapters/ws/index.ts +++ b/src/adapters/ws/index.ts @@ -1,4 +1,9 @@ -import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract"; +import type { + ChatDeltaMessage, + ChatErrorMessage, + WsClientMessage, +} from "@dispatch/transport-contract"; +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { nextBackoffMs, parseServerMessage, serialize } from "./logic"; export interface WebSocketLike { @@ -12,12 +17,13 @@ export interface WebSocketLike { export interface SurfaceSocketOptions { url: string; onMessage: (msg: SurfaceServerMessage) => void; + onChat?: (msg: ChatDeltaMessage | ChatErrorMessage) => void; onReopen?: () => void; socketFactory?: (url: string) => WebSocketLike; } export interface SurfaceSocketHandle { - send(msg: SurfaceClientMessage): void; + send(msg: WsClientMessage): void; close(): void; } @@ -52,7 +58,11 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa if (disposed) return; const msg = parseServerMessage(ev.data); if (msg !== null) { - opts.onMessage(msg); + if (msg.type === "chat.delta" || msg.type === "chat.error") { + opts.onChat?.(msg as ChatDeltaMessage | ChatErrorMessage); + } else { + opts.onMessage(msg as SurfaceServerMessage); + } } }; @@ -76,7 +86,7 @@ export function createSurfaceSocket(opts: SurfaceSocketOptions): SurfaceSocketHa connect(false); return { - send(msg: SurfaceClientMessage): void { + send(msg: WsClientMessage): void { if (disposed) return; const raw = serialize(msg); if (isOpen) { diff --git a/src/adapters/ws/logic.test.ts b/src/adapters/ws/logic.test.ts index 62ae6a0..546afe1 100644 --- a/src/adapters/ws/logic.test.ts +++ b/src/adapters/ws/logic.test.ts @@ -21,6 +21,22 @@ describe("serialize", () => { 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", () => { @@ -135,6 +151,49 @@ describe("parseServerMessage", () => { 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(); + }); }); describe("round-trip: parseServerMessage(serialize(...))", () => { diff --git a/src/adapters/ws/logic.ts b/src/adapters/ws/logic.ts index 83a5802..6592f1b 100644 --- a/src/adapters/ws/logic.ts +++ b/src/adapters/ws/logic.ts @@ -1,16 +1,27 @@ import type { + ChatDeltaMessage, + ChatErrorMessage, + WsClientMessage, + WsServerMessage, +} from "@dispatch/transport-contract"; +import type { CatalogMessage, - SurfaceClientMessage, SurfaceErrorMessage, SurfaceMessage, - SurfaceServerMessage, SurfaceUpdateMessage, } from "@dispatch/ui-contract"; -const VALID_SERVER_TYPES = new Set(["catalog", "surface", "update", "error"]); +const VALID_SERVER_TYPES = new Set([ + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", +]); /** Serialize a client message to a JSON string for the wire. */ -export function serialize(msg: SurfaceClientMessage): string { +export function serialize(msg: WsClientMessage): string { return JSON.stringify(msg); } @@ -19,10 +30,10 @@ function isRecord(v: unknown): v is Record<string, unknown> { } /** - * Parse a raw server message string into a typed SurfaceServerMessage. + * Parse a raw server message string into a typed WsServerMessage. * Returns null for malformed JSON or shapes that don't match the protocol. */ -export function parseServerMessage(data: string): SurfaceServerMessage | null { +export function parseServerMessage(data: string): WsServerMessage | null { let parsed: unknown; try { parsed = JSON.parse(data); @@ -72,6 +83,22 @@ export function parseServerMessage(data: string): SurfaceServerMessage | null { : { 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; } |
