diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 21:20:34 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 21:20:34 +0900 |
| commit | 552c22d74e5df915088d9e9ff4a286c96c2a54d6 (patch) | |
| tree | 7d9db1052bab91ef994446d80efc3bfc38026cad /packages/cli/src/http.test.ts | |
| parent | 7fb3269c698ae583ea7997ce206c4ae252fd3218 (diff) | |
| download | dispatch-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.ts | 164 |
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"); + }); +}); |
