diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 23:50:34 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 23:50:34 +0900 |
| commit | 357ad3567480b5483220e2cea266a4f1417d174d (patch) | |
| tree | f9eba8ebad94d7396546e9e953631109cac43f84 /packages/transport-http/src | |
| parent | 3390f5ed73674ba12f08ee801869ffa2d5b9b38d (diff) | |
| download | dispatch-357ad3567480b5483220e2cea266a4f1417d174d.tar.gz dispatch-357ad3567480b5483220e2cea266a4f1417d174d.zip | |
feat(core-ext): session-orchestrator + transport-http (parallel); wire into build graph (164 tests)
Diffstat (limited to 'packages/transport-http/src')
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 154 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 70 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 30 | ||||
| -rw-r--r-- | packages/transport-http/src/index.ts | 7 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.test.ts | 102 | ||||
| -rw-r--r-- | packages/transport-http/src/logic.ts | 40 | ||||
| -rw-r--r-- | packages/transport-http/src/seam.ts | 2 |
7 files changed, 405 insertions, 0 deletions
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts new file mode 100644 index 0000000..0125952 --- /dev/null +++ b/packages/transport-http/src/app.test.ts @@ -0,0 +1,154 @@ +import type { AgentEvent } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { createApp } from "./app.js"; +import type { SessionOrchestrator } from "./seam.js"; + +function createFakeOrchestrator(events: AgentEvent[]): SessionOrchestrator { + return { + async handleMessage(input) { + for (const event of events) { + input.onEvent(event); + } + }, + }; +} + +function createThrowingOrchestrator(error: Error): SessionOrchestrator { + return { + async handleMessage() { + throw error; + }, + }; +} + +describe("GET /health", () => { + it("returns ok", async () => { + const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const res = await app.request("/health"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true }); + }); +}); + +describe("POST /chat", () => { + it("returns 400 for invalid JSON", async () => { + const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing message", async () => { + const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ conversationId: "c1" }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("message"); + }); + + it("returns 400 for empty message", async () => { + const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "" }), + }); + expect(res.status).toBe(400); + }); + + it("streams events as NDJSON", async () => { + const events: AgentEvent[] = [ + { type: "turn-start", tabId: "tab1", turnId: "turn1" }, + { type: "text-delta", tabId: "tab1", turnId: "turn1", delta: "Hello" }, + { type: "text-delta", tabId: "tab1", turnId: "turn1", delta: " world" }, + { type: "done", tabId: "tab1", turnId: "turn1", reason: "stop" }, + ]; + const app = createApp({ orchestrator: createFakeOrchestrator(events) }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/x-ndjson"); + expect(res.headers.get("X-Conversation-Id")).toBe("conv1"); + + const text = await res.text(); + const lines = text.trim().split("\n"); + expect(lines).toHaveLength(4); + + const parsed = lines.map((line) => JSON.parse(line) as AgentEvent); + expect(parsed[0]?.type).toBe("turn-start"); + expect(parsed[1]?.type).toBe("text-delta"); + expect((parsed[1] as { delta: string }).delta).toBe("Hello"); + expect(parsed[2]?.type).toBe("text-delta"); + expect(parsed[3]?.type).toBe("done"); + }); + + it("generates conversationId when not provided", async () => { + const app = createApp({ + orchestrator: createFakeOrchestrator([ + { type: "done", tabId: "tab1", turnId: "turn1", reason: "stop" }, + ]), + generateId: () => "generated-uuid", + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Conversation-Id")).toBe("generated-uuid"); + }); + + it("emits error event when orchestrator throws", async () => { + const app = createApp({ + orchestrator: createThrowingOrchestrator(new Error("provider unavailable")), + }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi", conversationId: "conv1" }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + const lines = text.trim().split("\n"); + expect(lines.length).toBeGreaterThanOrEqual(1); + + const lastLine = lines[lines.length - 1]; + if (!lastLine) throw new Error("expected at least one line"); + const lastEvent = JSON.parse(lastLine) as AgentEvent; + expect(lastEvent.type).toBe("error"); + if (lastEvent.type === "error") { + expect(lastEvent.message).toContain("provider unavailable"); + } + }); + + it("handles empty event list", async () => { + const app = createApp({ orchestrator: createFakeOrchestrator([]) }); + + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toBe(""); + }); +}); diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts new file mode 100644 index 0000000..92553b7 --- /dev/null +++ b/packages/transport-http/src/app.ts @@ -0,0 +1,70 @@ +import type { AgentEvent } from "@dispatch/kernel"; +import { Hono } from "hono"; +import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; +import type { SessionOrchestrator } from "./seam.js"; + +export interface CreateServerOptions { + readonly orchestrator: SessionOrchestrator; + readonly generateId?: () => string; +} + +export function createApp(opts: CreateServerOptions): Hono { + const app = new Hono(); + const generateId = opts.generateId ?? (() => crypto.randomUUID()); + + app.get("/health", (c) => c.json({ ok: true })); + + app.post("/chat", async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const result = parseChatBody(body, generateId); + if (isParseError(result)) { + return c.json({ error: result.error }, 400); + } + + const { conversationId, message } = result; + const events: AgentEvent[] = []; + let resolveStream: () => void; + const streamReady = new Promise<void>((resolve) => { + resolveStream = resolve; + }); + + const orchestratorPromise = opts.orchestrator + .handleMessage({ + conversationId, + text: message, + onEvent: (event) => { + events.push(event); + }, + }) + .then(() => { + resolveStream(); + }) + .catch((err) => { + events.push({ + type: "error", + tabId: conversationId, + turnId: "", + message: err instanceof Error ? err.message : String(err), + }); + resolveStream(); + }); + + await streamReady; + await orchestratorPromise.catch(() => {}); + + const ndjson = events.map(serializeEventLine).join(""); + + return c.text(ndjson, 200, { + "Content-Type": "application/x-ndjson", + "X-Conversation-Id": conversationId, + }); + }); + + return app; +} diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts new file mode 100644 index 0000000..3ed98a8 --- /dev/null +++ b/packages/transport-http/src/extension.ts @@ -0,0 +1,30 @@ +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import type { Hono } from "hono"; +import { createApp } from "./app.js"; +import { sessionOrchestratorHandle } from "./seam.js"; + +export const manifest: Manifest = { + id: "transport-http", + name: "Transport HTTP", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + dependsOn: ["session-orchestrator"], + capabilities: { network: true }, + contributes: { routes: ["/chat", "/health"] }, + activation: "eager", +}; + +export interface CreateServerOptions { + readonly port?: number; +} + +export function createServer(host: HostAPI, _opts?: CreateServerOptions): Hono { + const orchestrator = host.getService(sessionOrchestratorHandle); + return createApp({ orchestrator }); +} + +export const extension: Extension = { + manifest, + activate: (_host: HostAPI) => {}, +}; diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts new file mode 100644 index 0000000..39a80ac --- /dev/null +++ b/packages/transport-http/src/index.ts @@ -0,0 +1,7 @@ +export type { CreateServerOptions } from "./app.js"; +export { createApp } from "./app.js"; +export { createServer, extension, manifest } from "./extension.js"; +export type { ChatCommand, ParseError, ParseResult } from "./logic.js"; +export { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; +export type { SessionOrchestrator } from "./seam.js"; +export { sessionOrchestratorHandle } from "./seam.js"; diff --git a/packages/transport-http/src/logic.test.ts b/packages/transport-http/src/logic.test.ts new file mode 100644 index 0000000..4a77643 --- /dev/null +++ b/packages/transport-http/src/logic.test.ts @@ -0,0 +1,102 @@ +import type { AgentEvent } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { isParseError, parseChatBody, serializeEventLine } from "./logic.js"; + +describe("parseChatBody", () => { + const fakeId = () => "test-uuid"; + + it("returns error for null body", () => { + const result = parseChatBody(null, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("JSON object"); + } + }); + + it("returns error for non-object body", () => { + const result = parseChatBody("hello", fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is missing", () => { + const result = parseChatBody({ conversationId: "c1" }, fakeId); + expect(isParseError(result)).toBe(true); + if (isParseError(result)) { + expect(result.error).toContain("message"); + } + }); + + it("returns error when message is empty string", () => { + const result = parseChatBody({ message: "" }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is whitespace only", () => { + const result = parseChatBody({ message: " " }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("returns error when message is not a string", () => { + const result = parseChatBody({ message: 42 }, fakeId); + expect(isParseError(result)).toBe(true); + }); + + it("generates conversationId when absent", () => { + const result = parseChatBody({ message: "hello" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("test-uuid"); + expect(result.message).toBe("hello"); + } + }); + + it("generates conversationId when empty string", () => { + const result = parseChatBody({ message: "hello", conversationId: "" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("test-uuid"); + } + }); + + it("uses provided conversationId", () => { + const result = parseChatBody({ message: "hello", conversationId: "my-conv" }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.conversationId).toBe("my-conv"); + } + }); + + it("trims message whitespace", () => { + const result = parseChatBody({ message: " hello world " }, fakeId); + expect(isParseError(result)).toBe(false); + if (!isParseError(result)) { + expect(result.message).toBe("hello world"); + } + }); +}); + +describe("serializeEventLine", () => { + it("serializes an event as JSON followed by newline", () => { + const event: AgentEvent = { + type: "text-delta", + tabId: "tab1", + turnId: "turn1", + delta: "hello", + }; + const line = serializeEventLine(event); + expect(line).toBe(`${JSON.stringify(event)}\n`); + }); + + it("serializes a done event", () => { + const event: AgentEvent = { + type: "done", + tabId: "tab1", + turnId: "turn1", + reason: "stop", + }; + const line = serializeEventLine(event); + const parsed = JSON.parse(line.trim()); + expect(parsed.type).toBe("done"); + expect(parsed.reason).toBe("stop"); + }); +}); diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts new file mode 100644 index 0000000..a1a1638 --- /dev/null +++ b/packages/transport-http/src/logic.ts @@ -0,0 +1,40 @@ +import type { AgentEvent } from "@dispatch/kernel"; + +export interface ChatCommand { + readonly conversationId: string; + readonly message: string; +} + +export interface ParseError { + readonly error: string; +} + +export type ParseResult = ChatCommand | ParseError; + +export function parseChatBody(body: unknown, generateId: () => string): ParseResult { + if (body === null || typeof body !== "object") { + return { error: "Request body must be a JSON object" }; + } + + const obj = body as Record<string, unknown>; + + const message = obj.message; + if (typeof message !== "string" || message.trim().length === 0) { + return { error: "Field 'message' is required and must be a non-empty string" }; + } + + const conversationId = + typeof obj.conversationId === "string" && obj.conversationId.length > 0 + ? obj.conversationId + : generateId(); + + return { conversationId, message: message.trim() }; +} + +export function isParseError(result: ParseResult): result is ParseError { + return "error" in result; +} + +export function serializeEventLine(event: AgentEvent): string { + return `${JSON.stringify(event)}\n`; +} diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts new file mode 100644 index 0000000..c6ce04f --- /dev/null +++ b/packages/transport-http/src/seam.ts @@ -0,0 +1,2 @@ +export type { SessionOrchestrator } from "@dispatch/session-orchestrator"; +export { sessionOrchestratorHandle } from "@dispatch/session-orchestrator"; |
