From c5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 21 Jun 2026 19:20:10 +0900 Subject: feat(cli): list, read, send commands (Wave 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI gains three new sub-commands: - dispatch list [--server] — list conversations (short ID + title + activity) - dispatch read [--server] — block until turn settles, print last AI message - dispatch send --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. --- packages/cli/src/http.ts | 142 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) (limited to 'packages/cli/src/http.ts') 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 { + 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 { + 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 { + 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 { + 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 { + 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}`, + }; +} -- cgit v1.2.3