summaryrefslogtreecommitdiffhomepage
path: root/src/adapters
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 00:02:32 +0900
committerAdam Malczewski <[email protected]>2026-06-07 00:02:32 +0900
commit5d9ae1849337b64af1b0d47c23b8c4950a55f792 (patch)
treedd5fccaff7535bf1216457a986b8f95bd14fd61e /src/adapters
parentfac44794432928d0341728642fd70eef87837da4 (diff)
downloaddispatch-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.ts112
-rw-r--r--src/adapters/ws/index.ts18
-rw-r--r--src/adapters/ws/logic.test.ts59
-rw-r--r--src/adapters/ws/logic.ts39
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;
}