summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-http/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 23:50:34 +0900
committerAdam Malczewski <[email protected]>2026-06-04 23:50:34 +0900
commit357ad3567480b5483220e2cea266a4f1417d174d (patch)
treef9eba8ebad94d7396546e9e953631109cac43f84 /packages/transport-http/src
parent3390f5ed73674ba12f08ee801869ffa2d5b9b38d (diff)
downloaddispatch-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.ts154
-rw-r--r--packages/transport-http/src/app.ts70
-rw-r--r--packages/transport-http/src/extension.ts30
-rw-r--r--packages/transport-http/src/index.ts7
-rw-r--r--packages/transport-http/src/logic.test.ts102
-rw-r--r--packages/transport-http/src/logic.ts40
-rw-r--r--packages/transport-http/src/seam.ts2
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";