From 5d9ae1849337b64af1b0d47c23b8c4950a55f792 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 7 Jun 2026 00:02:32 +0900 Subject: Slice 2 wave 1: transcript reducer, wire conformance, ws chat, cache core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/core/wire/conformance.test.ts | 181 ++++++++++++++++++++++++++++++++++++++ src/core/wire/conformance.ts | 100 +++++++++++++++++++++ src/core/wire/index.ts | 6 ++ 3 files changed, 287 insertions(+) create mode 100644 src/core/wire/conformance.test.ts create mode 100644 src/core/wire/conformance.ts create mode 100644 src/core/wire/index.ts (limited to 'src/core/wire') 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"; -- cgit v1.2.3