summaryrefslogtreecommitdiffhomepage
path: root/src/core/wire
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/core/wire
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/core/wire')
-rw-r--r--src/core/wire/conformance.test.ts181
-rw-r--r--src/core/wire/conformance.ts100
-rw-r--r--src/core/wire/index.ts6
3 files changed, 287 insertions, 0 deletions
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts
new file mode 100644
index 0000000..c0f276f
--- /dev/null
+++ b/src/core/wire/conformance.test.ts
@@ -0,0 +1,181 @@
+import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract";
+import type { AgentEvent, StoredChunk } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import {
+ assertAgentEventExhaustive,
+ assertChunkExhaustive,
+ assertWsClientMessageExhaustive,
+ assertWsServerMessageExhaustive,
+} from "./conformance";
+
+describe("StoredChunk round-trips JSON", () => {
+ it("preserves shape through JSON serialize/deserialize", () => {
+ const original: StoredChunk = {
+ seq: 42,
+ role: "assistant",
+ chunk: { type: "text", text: "hello" },
+ };
+ const roundTripped: StoredChunk = JSON.parse(JSON.stringify(original)) as StoredChunk;
+ expect(roundTripped).toEqual(original);
+ expect(roundTripped.seq).toBe(42);
+ expect(roundTripped.role).toBe("assistant");
+ expect(roundTripped.chunk.type).toBe("text");
+ });
+});
+
+describe("classifies every AgentEvent type", () => {
+ const samples: AgentEvent[] = [
+ { type: "status", conversationId: "c1", status: "idle" },
+ { type: "turn-start", conversationId: "c1", turnId: "t1" },
+ { type: "text-delta", conversationId: "c1", turnId: "t1", delta: "hi" },
+ { type: "reasoning-delta", conversationId: "c1", turnId: "t1", delta: "thinking" },
+ {
+ type: "tool-call",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read",
+ input: {},
+ },
+ {
+ type: "tool-result",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "tc1",
+ toolName: "read",
+ content: "ok",
+ isError: false,
+ },
+ {
+ type: "tool-output",
+ conversationId: "c1",
+ turnId: "t1",
+ toolCallId: "tc1",
+ data: "out",
+ stream: "stdout",
+ },
+ {
+ type: "usage",
+ conversationId: "c1",
+ turnId: "t1",
+ usage: { inputTokens: 10, outputTokens: 20 },
+ },
+ { type: "error", conversationId: "c1", turnId: "t1", message: "oops" },
+ { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" },
+ { type: "turn-sealed", conversationId: "c1", turnId: "t1" },
+ ];
+
+ it("returns a stable label for every AgentEvent.type variant", () => {
+ const labels = samples.map(assertAgentEventExhaustive);
+ expect(labels).toEqual([
+ "status",
+ "turn-start",
+ "text-delta",
+ "reasoning-delta",
+ "tool-call",
+ "tool-result",
+ "tool-output",
+ "usage",
+ "error",
+ "done",
+ "turn-sealed",
+ ]);
+ });
+
+ it("covers all 11 AgentEvent variants", () => {
+ expect(samples).toHaveLength(11);
+ });
+});
+
+describe("classifies every Chunk type", () => {
+ it("returns a stable label for each Chunk.type variant", () => {
+ const chunks = [
+ { type: "text" as const, text: "a" },
+ { type: "thinking" as const, text: "b" },
+ { type: "tool-call" as const, toolCallId: "tc", toolName: "n", input: null },
+ {
+ type: "tool-result" as const,
+ toolCallId: "tc",
+ toolName: "n",
+ content: "c",
+ isError: false,
+ },
+ { type: "error" as const, message: "e" },
+ { type: "system" as const, text: "s" },
+ ];
+ const labels = chunks.map(assertChunkExhaustive);
+ expect(labels).toEqual(["text", "thinking", "tool-call", "tool-result", "error", "system"]);
+ });
+});
+
+describe("classifies every WsServerMessage type", () => {
+ it("returns a stable label for each variant", () => {
+ const msgs = [
+ { type: "catalog" as const, catalog: [] },
+ { type: "surface" as const, spec: { id: "s", region: "r", title: "S", fields: [] } },
+ {
+ type: "update" as const,
+ update: { surfaceId: "s", spec: { id: "s", region: "r", title: "S", fields: [] } },
+ },
+ { type: "error" as const, message: "e" },
+ {
+ type: "chat.delta" as const,
+ event: { type: "done" as const, conversationId: "c", turnId: "t", reason: "r" },
+ },
+ { type: "chat.error" as const, message: "e" },
+ ];
+ const labels = msgs.map(assertWsServerMessageExhaustive);
+ expect(labels).toEqual(["catalog", "surface", "update", "error", "chat.delta", "chat.error"]);
+ });
+});
+
+describe("classifies every WsClientMessage type", () => {
+ it("returns a stable label for each variant", () => {
+ const msgs = [
+ { type: "subscribe" as const, surfaceId: "s" },
+ { type: "unsubscribe" as const, surfaceId: "s" },
+ { type: "invoke" as const, surfaceId: "s", actionId: "a" },
+ { type: "chat.send" as const, message: "hi" },
+ ];
+ const labels = msgs.map(assertWsClientMessageExhaustive);
+ expect(labels).toEqual(["subscribe", "unsubscribe", "invoke", "chat.send"]);
+ });
+});
+
+describe("ChatSendMessage shape is constructible", () => {
+ it("constructs a minimal ChatSendMessage", () => {
+ const msg: ChatSendMessage = { type: "chat.send", message: "hello" };
+ expect(msg.type).toBe("chat.send");
+ expect(msg.message).toBe("hello");
+ });
+
+ it("constructs a full ChatSendMessage", () => {
+ const msg: ChatSendMessage = {
+ type: "chat.send",
+ conversationId: "c1",
+ message: "hello",
+ model: "default/gpt-4",
+ cwd: "/tmp",
+ };
+ expect(msg.conversationId).toBe("c1");
+ expect(msg.model).toBe("default/gpt-4");
+ expect(msg.cwd).toBe("/tmp");
+ });
+});
+
+describe("ConversationHistoryResponse shape is constructible", () => {
+ it("constructs a response with chunks", () => {
+ const resp: ConversationHistoryResponse = {
+ chunks: [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }],
+ latestSeq: 1,
+ };
+ expect(resp.chunks).toHaveLength(1);
+ expect(resp.latestSeq).toBe(1);
+ });
+
+ it("constructs an empty (caught-up) response", () => {
+ const resp: ConversationHistoryResponse = { chunks: [], latestSeq: 5 };
+ expect(resp.chunks).toHaveLength(0);
+ expect(resp.latestSeq).toBe(5);
+ });
+});
diff --git a/src/core/wire/conformance.ts b/src/core/wire/conformance.ts
new file mode 100644
index 0000000..5d75a60
--- /dev/null
+++ b/src/core/wire/conformance.ts
@@ -0,0 +1,100 @@
+import type { WsClientMessage, WsServerMessage } from "@dispatch/transport-contract";
+import type { AgentEvent, Chunk } from "@dispatch/wire";
+
+/**
+ * Compile-time exhaustiveness guard for `AgentEvent.type`.
+ * If a variant is added/removed/renamed in `@dispatch/wire`, this function's
+ * default branch becomes reachable → TypeScript error at build time.
+ */
+export function assertAgentEventExhaustive(event: AgentEvent): string {
+ switch (event.type) {
+ case "status":
+ return "status";
+ case "turn-start":
+ return "turn-start";
+ case "text-delta":
+ return "text-delta";
+ case "reasoning-delta":
+ return "reasoning-delta";
+ case "tool-call":
+ return "tool-call";
+ case "tool-result":
+ return "tool-result";
+ case "tool-output":
+ return "tool-output";
+ case "usage":
+ return "usage";
+ case "error":
+ return "error";
+ case "done":
+ return "done";
+ case "turn-sealed":
+ return "turn-sealed";
+ default:
+ return event satisfies never;
+ }
+}
+
+/**
+ * Compile-time exhaustiveness guard for `Chunk.type`.
+ */
+export function assertChunkExhaustive(chunk: Chunk): string {
+ switch (chunk.type) {
+ case "text":
+ return "text";
+ case "thinking":
+ return "thinking";
+ case "tool-call":
+ return "tool-call";
+ case "tool-result":
+ return "tool-result";
+ case "error":
+ return "error";
+ case "system":
+ return "system";
+ default:
+ return chunk satisfies never;
+ }
+}
+
+/**
+ * Compile-time exhaustiveness guard for `WsServerMessage.type`.
+ * Covers both surface ops and chat ops.
+ */
+export function assertWsServerMessageExhaustive(msg: WsServerMessage): string {
+ switch (msg.type) {
+ case "catalog":
+ return "catalog";
+ case "surface":
+ return "surface";
+ case "update":
+ return "update";
+ case "error":
+ return "error";
+ case "chat.delta":
+ return "chat.delta";
+ case "chat.error":
+ return "chat.error";
+ default:
+ return msg satisfies never;
+ }
+}
+
+/**
+ * Compile-time exhaustiveness guard for `WsClientMessage.type`.
+ * Covers both surface ops and chat ops.
+ */
+export function assertWsClientMessageExhaustive(msg: WsClientMessage): string {
+ switch (msg.type) {
+ case "subscribe":
+ return "subscribe";
+ case "unsubscribe":
+ return "unsubscribe";
+ case "invoke":
+ return "invoke";
+ case "chat.send":
+ return "chat.send";
+ default:
+ return msg satisfies never;
+ }
+}
diff --git a/src/core/wire/index.ts b/src/core/wire/index.ts
new file mode 100644
index 0000000..ae6b3e6
--- /dev/null
+++ b/src/core/wire/index.ts
@@ -0,0 +1,6 @@
+export {
+ assertAgentEventExhaustive,
+ assertChunkExhaustive,
+ assertWsClientMessageExhaustive,
+ assertWsServerMessageExhaustive,
+} from "./conformance";