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.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.ts')
| -rw-r--r-- | packages/cli/src/http.ts | 86 |
1 files changed, 86 insertions, 0 deletions
diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts new file mode 100644 index 0000000..5e61afb --- /dev/null +++ b/packages/cli/src/http.ts @@ -0,0 +1,86 @@ +/** + * Shell — HTTP transport layer (effects injected at the edges). + * + * streamChat: POST /chat, returns an async iterable of AgentEvents. + * fetchModels: GET /models, returns the ModelsResponse. + * + * The fetchImpl dependency is injected (outermost edge mock allowed). + */ + +import type { AgentEvent, ChatRequest, ModelsResponse } from "@dispatch/transport-contract"; +import { splitNdjsonLines } from "./ndjson.js"; + +interface FetchDeps { + readonly fetchImpl: typeof fetch; +} + +interface StreamChatOpts { + readonly server: string; + readonly request: ChatRequest; +} + +export async function streamChat( + deps: FetchDeps, + opts: StreamChatOpts, +): Promise<{ conversationId: string | null; events: AsyncIterable<AgentEvent> }> { + const url = `${opts.server}/chat`; + const res = await deps.fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(opts.request), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /chat failed with status ${res.status}: ${body}`); + } + + const conversationId = res.headers.get("X-Conversation-Id"); + + if (!res.body) { + throw new Error("POST /chat returned no body"); + } + + const events = readNdjsonStream(res.body); + return { conversationId, events }; +} + +async function* readNdjsonStream(body: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const { lines, rest } = splitNdjsonLines(buffer); + buffer = rest; + for (const line of lines) { + yield JSON.parse(line) as AgentEvent; + } + } + if (buffer.length > 0) { + yield JSON.parse(buffer) as AgentEvent; + } + } finally { + reader.releaseLock(); + } +} + +interface FetchModelsOpts { + readonly server: string; +} + +export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promise<ModelsResponse> { + const url = `${opts.server}/models`; + const res = await deps.fetchImpl(url); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`GET /models failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as ModelsResponse; +} |
