diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 19:20:10 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 19:20:10 +0900 |
| commit | c5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4 (patch) | |
| tree | 809bd93eaa25646f237fb4b1ddd3719e25aaca90 /packages/cli/src/http.ts | |
| parent | ea0e938eca3072649dc8707c999ec00cf87b986a (diff) | |
| download | dispatch-c5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4.tar.gz dispatch-c5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4.zip | |
feat(cli): list, read, send commands (Wave 3)
CLI gains three new sub-commands:
- dispatch list [--server] — list conversations (short ID + title + activity)
- dispatch read <id> [--server] — block until turn settles, print last AI message
- dispatch send <id> --text [--queue] [--open] [--cwd] [--effort] [--server]
- Default: blocking (consumes NDJSON stream, prints accumulated text + conv ID)
- --queue: non-blocking (POST /conversations/:id/queue, exit immediately)
- --open: signals frontend to open the conversation tab (POST /conversations/:id/open)
Short-ID resolution: 4+ char prefix → GET /conversations?q= → resolve to full ID.
32+ char input is treated as a full UUID (no resolution). Errors on 0 or >1 matches.
48 new tests (108 total in cli). Pure arg parser + HTTP client functions, zero vi.mock.
Diffstat (limited to 'packages/cli/src/http.ts')
| -rw-r--r-- | packages/cli/src/http.ts | 142 |
1 files changed, 141 insertions, 1 deletions
diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts index 5e61afb..8434519 100644 --- a/packages/cli/src/http.ts +++ b/packages/cli/src/http.ts @@ -7,7 +7,15 @@ * The fetchImpl dependency is injected (outermost edge mock allowed). */ -import type { AgentEvent, ChatRequest, ModelsResponse } from "@dispatch/transport-contract"; +import type { + AgentEvent, + ChatRequest, + ConversationListResponse, + LastMessageResponse, + ModelsResponse, + OpenConversationResponse, + QueueResponse, +} from "@dispatch/transport-contract"; import { splitNdjsonLines } from "./ndjson.js"; interface FetchDeps { @@ -84,3 +92,135 @@ export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promi return (await res.json()) as ModelsResponse; } + +interface FetchConversationsOpts { + readonly server: string; + readonly query?: string; +} + +export async function fetchConversations( + deps: FetchDeps, + opts: FetchConversationsOpts, +): Promise<ConversationListResponse> { + const url = + opts.query !== undefined + ? `${opts.server}/conversations?q=${encodeURIComponent(opts.query)}` + : `${opts.server}/conversations`; + const res = await deps.fetchImpl(url); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`GET /conversations failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as ConversationListResponse; +} + +interface FetchLastMessageOpts { + readonly server: string; + readonly conversationId: string; +} + +export async function fetchLastMessage( + deps: FetchDeps, + opts: FetchLastMessageOpts, +): Promise<LastMessageResponse> { + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/last`; + const res = await deps.fetchImpl(url); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`GET /conversations/:id/last failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as LastMessageResponse; +} + +interface EnqueueMessageOpts { + readonly server: string; + readonly conversationId: string; + readonly text: string; +} + +export async function enqueueMessage( + deps: FetchDeps, + opts: EnqueueMessageOpts, +): Promise<QueueResponse> { + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/queue`; + const res = await deps.fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: opts.text }), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /conversations/:id/queue failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as QueueResponse; +} + +interface OpenConversationOpts { + readonly server: string; + readonly conversationId: string; +} + +export async function openConversation( + deps: FetchDeps, + opts: OpenConversationOpts, +): Promise<OpenConversationResponse> { + const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/open`; + const res = await deps.fetchImpl(url, { method: "POST" }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`POST /conversations/:id/open failed with status ${res.status}: ${body}`); + } + + return (await res.json()) as OpenConversationResponse; +} + +/** + * The outcome of short-ID resolution: either the full conversation id to use, + * or a human-readable error describing why resolution failed. + */ +export type ConversationIdResolution = string | { readonly error: string }; + +interface ResolveConversationIdOpts { + readonly server: string; + readonly shortId: string; +} + +/** + * Resolve a user-typed conversation prefix to a full id. A 32+ char input is + * assumed to be a full UUID and returned untouched. Otherwise the conversation + * list is filtered by the prefix: 1 match → its id; 0 → error; >1 → error with + * the candidate short ids + titles so the user can disambiguate. + */ +export async function resolveConversationId( + deps: FetchDeps, + opts: ResolveConversationIdOpts, +): Promise<ConversationIdResolution> { + if (opts.shortId.length >= 32) { + return opts.shortId; + } + + const list = await fetchConversations(deps, { server: opts.server, query: opts.shortId }); + const matches = list.conversations; + + if (matches.length === 0) { + return { error: `No conversation matching "${opts.shortId}"` }; + } + + if (matches.length === 1) { + const only = matches[0]; + if (only === undefined) return { error: `No conversation matching "${opts.shortId}"` }; + return only.id; + } + + const lines = matches.map((m) => `${m.id.slice(0, 8)} ${m.title}`).join("\n"); + return { + error: `Multiple conversations matching "${opts.shortId}"\n${lines}`, + }; +} |
