diff options
Diffstat (limited to 'src/core/wire')
| -rw-r--r-- | src/core/wire/conformance.test.ts | 403 | ||||
| -rw-r--r-- | src/core/wire/conformance.ts | 162 | ||||
| -rw-r--r-- | src/core/wire/index.ts | 8 |
3 files changed, 347 insertions, 226 deletions
diff --git a/src/core/wire/conformance.test.ts b/src/core/wire/conformance.test.ts index c0f276f..0d955d5 100644 --- a/src/core/wire/conformance.test.ts +++ b/src/core/wire/conformance.test.ts @@ -1,181 +1,280 @@ import type { ChatSendMessage, ConversationHistoryResponse } from "@dispatch/transport-contract"; -import type { AgentEvent, StoredChunk } from "@dispatch/wire"; +import type { AgentEvent, StepId, StoredChunk } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; import { - assertAgentEventExhaustive, - assertChunkExhaustive, - assertWsClientMessageExhaustive, - assertWsServerMessageExhaustive, + 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"); - }); + 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" }, - ]; + const samples: AgentEvent[] = [ + { type: "status", conversationId: "c1", status: "idle" }, + { type: "turn-start", conversationId: "c1", turnId: "t1" }, + { type: "user-message", conversationId: "c1", turnId: "t1", text: "hi" }, + { 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: {}, + stepId: "t1#0" as StepId, + }, + { + type: "tool-result", + conversationId: "c1", + turnId: "t1", + toolCallId: "tc1", + toolName: "read", + content: "ok", + isError: false, + stepId: "t1#0" as StepId, + }, + { + 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: "step-complete", + conversationId: "c1", + turnId: "t1", + stepId: "t1#0" as StepId, + ttftMs: 300, + decodeMs: 700, + genTotalMs: 1000, + }, + { type: "error", conversationId: "c1", turnId: "t1", message: "oops" }, + { type: "done", conversationId: "c1", turnId: "t1", reason: "complete" }, + { type: "turn-sealed", conversationId: "c1", turnId: "t1" }, + { type: "steering", conversationId: "c1", turnId: "t1", text: "steer mid-turn" }, + { + type: "provider-retry", + conversationId: "c1", + turnId: "t1", + attempt: 0, + delayMs: 5000, + message: "HTTP 429: overloaded", + code: "429", + }, + ]; - 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("returns a stable label for every AgentEvent.type variant", () => { + const labels = samples.map(assertAgentEventExhaustive); + expect(labels).toEqual([ + "status", + "turn-start", + "user-message", + "text-delta", + "reasoning-delta", + "tool-call", + "tool-result", + "tool-output", + "usage", + "step-complete", + "error", + "done", + "turn-sealed", + "steering", + "provider-retry", + ]); + }); - it("covers all 11 AgentEvent variants", () => { - expect(samples).toHaveLength(11); - }); + it("covers all 15 AgentEvent variants", () => { + expect(samples).toHaveLength(15); + }); }); 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"]); - }); + 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" }, + { type: "image" as const, url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + ]; + const labels = chunks.map(assertChunkExhaustive); + expect(labels).toEqual([ + "text", + "thinking", + "tool-call", + "tool-result", + "error", + "system", + "image", + ]); + }); + + it("covers all 7 Chunk variants", () => { + // Keeps the exhaustive guard honest: a new Chunk.type variant must be added + // both here and to `assertChunkExhaustive` or the `satisfies never` errors. + expect([ + "text", + "thinking", + "tool-call", + "tool-result", + "error", + "system", + "image", + ] as const).toHaveLength(7); + }); }); 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"]); - }); + 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" }, + { type: "conversation.open" as const, conversationId: "c1", workspaceId: "w1" }, + { + type: "conversation.statusChanged" as const, + conversationId: "c1", + status: "active" as const, + workspaceId: "w1", + }, + { + type: "conversation.compacted" as const, + conversationId: "c1", + newConversationId: "c2", + messagesSummarized: 10, + messagesKept: 5, + }, + ]; + const labels = msgs.map(assertWsServerMessageExhaustive); + expect(labels).toEqual([ + "catalog", + "surface", + "update", + "error", + "chat.delta", + "chat.error", + "conversation.open", + "conversation.statusChanged", + "conversation.compacted", + ]); + }); }); 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"]); - }); + 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" }, + { type: "chat.subscribe" as const, conversationId: "c1" }, + { type: "chat.unsubscribe" as const, conversationId: "c1" }, + { type: "chat.queue" as const, conversationId: "c1", text: "steer" }, + ]; + const labels = msgs.map(assertWsClientMessageExhaustive); + expect(labels).toEqual([ + "subscribe", + "unsubscribe", + "invoke", + "chat.send", + "chat.subscribe", + "chat.unsubscribe", + "chat.queue", + ]); + }); }); 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 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"); + }); - 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"); - }); + it("constructs a ChatSendMessage with pasted images", () => { + const msg: ChatSendMessage = { + type: "chat.send", + conversationId: "c1", + message: "what's in this image?", + images: [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "https://example.com/cat.jpg" }, + ], + }; + expect(msg.images).toHaveLength(2); + expect(msg.images?.[0]?.mimeType).toBe("image/png"); + expect(msg.images?.[1]?.mimeType).toBeUndefined(); + }); }); 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 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); - }); + 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 index 5d75a60..bfa67dc 100644 --- a/src/core/wire/conformance.ts +++ b/src/core/wire/conformance.ts @@ -7,54 +7,64 @@ import type { AgentEvent, Chunk } from "@dispatch/wire"; * 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; - } + switch (event.type) { + case "status": + return "status"; + case "turn-start": + return "turn-start"; + case "user-message": + return "user-message"; + 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"; + case "step-complete": + return "step-complete"; + case "steering": + return "steering"; + case "provider-retry": + return "provider-retry"; + 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; - } + 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"; + case "image": + return "image"; + default: + return chunk satisfies never; + } } /** @@ -62,22 +72,28 @@ export function assertChunkExhaustive(chunk: Chunk): string { * 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; - } + 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"; + case "conversation.open": + return "conversation.open"; + case "conversation.statusChanged": + return "conversation.statusChanged"; + case "conversation.compacted": + return "conversation.compacted"; + default: + return msg satisfies never; + } } /** @@ -85,16 +101,22 @@ export function assertWsServerMessageExhaustive(msg: WsServerMessage): string { * 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; - } + switch (msg.type) { + case "subscribe": + return "subscribe"; + case "unsubscribe": + return "unsubscribe"; + case "invoke": + return "invoke"; + case "chat.send": + return "chat.send"; + case "chat.subscribe": + return "chat.subscribe"; + case "chat.unsubscribe": + return "chat.unsubscribe"; + case "chat.queue": + return "chat.queue"; + default: + return msg satisfies never; + } } diff --git a/src/core/wire/index.ts b/src/core/wire/index.ts index ae6b3e6..d4215cf 100644 --- a/src/core/wire/index.ts +++ b/src/core/wire/index.ts @@ -1,6 +1,6 @@ export { - assertAgentEventExhaustive, - assertChunkExhaustive, - assertWsClientMessageExhaustive, - assertWsServerMessageExhaustive, + assertAgentEventExhaustive, + assertChunkExhaustive, + assertWsClientMessageExhaustive, + assertWsServerMessageExhaustive, } from "./conformance"; |
