summaryrefslogtreecommitdiffhomepage
path: root/packages/cli/src/http.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-05 21:20:34 +0900
committerAdam Malczewski <[email protected]>2026-06-05 21:20:34 +0900
commit552c22d74e5df915088d9e9ff4a286c96c2a54d6 (patch)
tree7d9db1052bab91ef994446d80efc3bfc38026cad /packages/cli/src/http.test.ts
parent7fb3269c698ae583ea7997ce206c4ae252fd3218 (diff)
downloaddispatch-552c22d74e5df915088d9e9ff4a286c96c2a54d6.tar.gz
dispatch-552c22d74e5df915088d9e9ff4a286c96c2a54d6.zip
feat(cli): one-shot terminal client (models, chat, --text/--file/--cwd/--conversation)
HTTP client of transport-contract; pure-core arg/render/ndjson + injected fetch/fs shell. Docs: GLOSSARY (credential/key/model name/model catalog), tasks.md milestone, ORCHESTRATOR geography.
Diffstat (limited to 'packages/cli/src/http.test.ts')
-rw-r--r--packages/cli/src/http.test.ts164
1 files changed, 164 insertions, 0 deletions
diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts
new file mode 100644
index 0000000..becfdbb
--- /dev/null
+++ b/packages/cli/src/http.test.ts
@@ -0,0 +1,164 @@
+import type { AgentEvent } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import { fetchModels, streamChat } from "./http.js";
+
+function ndjsonLines(...events: AgentEvent[]): string {
+ return `${events.map((e) => JSON.stringify(e)).join("\n")}\n`;
+}
+
+function makeFakeFetch(responseBody: string, headers?: Record<string, string>) {
+ const fn = async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
+ const encoder = new TextEncoder();
+ const chunks = responseBody.split("|||");
+ let i = 0;
+ const stream = new ReadableStream<Uint8Array>({
+ pull(controller) {
+ if (i < chunks.length) {
+ const chunk = chunks[i];
+ if (chunk !== undefined) controller.enqueue(encoder.encode(chunk));
+ i++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+ return new Response(stream, {
+ status: 200,
+ headers: headers ?? {},
+ });
+ };
+ return fn as unknown as typeof fetch;
+}
+
+describe("streamChat", () => {
+ it("parses NDJSON events and returns conversationId", async () => {
+ const event1: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c1",
+ turnId: "t1",
+ delta: "Hello",
+ };
+ const event2: AgentEvent = {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "completed",
+ };
+
+ const body = ndjsonLines(event1, event2);
+ const fakeFetch = makeFakeFetch(body, { "X-Conversation-Id": "c1" });
+
+ const { conversationId, events } = await streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi", model: "openai/gpt-4" },
+ },
+ );
+
+ expect(conversationId).toBe("c1");
+
+ const collected: AgentEvent[] = [];
+ for await (const e of events) {
+ collected.push(e);
+ }
+
+ expect(collected).toEqual([event1, event2]);
+ });
+
+ it("handles NDJSON split across chunks", async () => {
+ const event1: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "Hi",
+ };
+ const event2: AgentEvent = {
+ type: "usage",
+ conversationId: "c",
+ turnId: "t",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ };
+
+ const fullNdjson = ndjsonLines(event1, event2);
+ // Split mid-line: after 20 chars
+ const mid = 20;
+ const chunk1 = fullNdjson.slice(0, mid);
+ const chunk2 = fullNdjson.slice(mid);
+
+ const fakeFetch = makeFakeFetch(`${chunk1}|||${chunk2}`, {
+ "X-Conversation-Id": "c",
+ });
+
+ const { events } = await streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ );
+
+ const collected: AgentEvent[] = [];
+ for await (const e of events) {
+ collected.push(e);
+ }
+
+ expect(collected).toEqual([event1, event2]);
+ });
+
+ it("throws on non-OK status", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response("not found", { status: 404 })) as unknown as typeof fetch;
+
+ await expect(
+ streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ ),
+ ).rejects.toThrow("POST /chat failed with status 404");
+ });
+
+ it("throws when response has no body", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response(null, { status: 200 })) as unknown as typeof fetch;
+
+ await expect(
+ streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ ),
+ ).rejects.toThrow("no body");
+ });
+});
+
+describe("fetchModels", () => {
+ it("returns ModelsResponse on success", async () => {
+ const models = { models: ["openai/gpt-4", "anthropic/claude-3"] };
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response(JSON.stringify(models), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })) as unknown as typeof fetch;
+
+ const result = await fetchModels(
+ { fetchImpl: fakeFetch },
+ { server: "http://localhost:24203" },
+ );
+ expect(result).toEqual(models);
+ });
+
+ it("throws on non-OK status", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response("server error", { status: 500 })) as unknown as typeof fetch;
+
+ await expect(
+ fetchModels({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }),
+ ).rejects.toThrow("GET /models failed with status 500");
+ });
+});