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 | |
| 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.
| -rw-r--r-- | packages/cli/src/args.test.ts | 141 | ||||
| -rw-r--r-- | packages/cli/src/args.ts | 125 | ||||
| -rw-r--r-- | packages/cli/src/http.test.ts | 230 | ||||
| -rw-r--r-- | packages/cli/src/http.ts | 142 | ||||
| -rw-r--r-- | packages/cli/src/index.ts | 18 | ||||
| -rw-r--r-- | packages/cli/src/main.ts | 93 | ||||
| -rw-r--r-- | packages/cli/src/render.test.ts | 105 | ||||
| -rw-r--r-- | packages/cli/src/render.ts | 49 |
8 files changed, 893 insertions, 10 deletions
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts index ce278bb..392d560 100644 --- a/packages/cli/src/args.test.ts +++ b/packages/cli/src/args.test.ts @@ -180,4 +180,145 @@ describe("parseArgs", () => { expect(result.kind).toBe("error"); }); }); + + describe("list", () => { + it("parses 'list' with no query", () => { + expect(parseArgs(["list"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + }); + }); + + it("parses 'list' with a query prefix", () => { + expect(parseArgs(["list", "abc12345"], { defaultServer })).toEqual({ + kind: "list", + server: "http://localhost:24203", + query: "abc12345", + }); + }); + + it("parses 'list' with --server after the prefix", () => { + expect(parseArgs(["list", "abc", "--server", "http://s"], { defaultServer })).toEqual({ + kind: "list", + server: "http://s", + query: "abc", + }); + }); + + it("errors on a second positional argument", () => { + const result = parseArgs(["list", "abc", "def"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("Unexpected argument"); + }); + + it("errors on an unknown flag", () => { + const result = parseArgs(["list", "--bogus"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("Unknown flag"); + }); + }); + + describe("read", () => { + it("parses 'read' with a conversation id", () => { + expect(parseArgs(["read", "deadbeef"], { defaultServer })).toEqual({ + kind: "read", + server: "http://localhost:24203", + conversationId: "deadbeef", + }); + }); + + it("parses 'read' with --server", () => { + expect(parseArgs(["read", "deadbeef", "--server", "http://s"], { defaultServer })).toEqual({ + kind: "read", + server: "http://s", + conversationId: "deadbeef", + }); + }); + + it("errors when no conversation id is given", () => { + const result = parseArgs(["read"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("conversation id"); + }); + + it("errors on a second positional argument", () => { + const result = parseArgs(["read", "a", "b"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + }); + + describe("send", () => { + it("parses 'send' with --text", () => { + expect(parseArgs(["send", "deadbeef", "--text", "hi"], { defaultServer })).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + queue: false, + open: false, + }); + }); + + it("parses 'send' with --queue", () => { + const result = parseArgs(["send", "deadbeef", "--text", "hi", "--queue"], { + defaultServer, + }); + expect(result).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + queue: true, + open: false, + }); + }); + + it("parses 'send' with --open", () => { + const result = parseArgs(["send", "deadbeef", "--text", "hi", "--open"], { + defaultServer, + }); + expect(result).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + queue: false, + open: true, + }); + }); + + it("parses 'send' with --cwd and --effort", () => { + const result = parseArgs( + ["send", "deadbeef", "--text", "hi", "--cwd", "/tmp", "--effort", "xhigh"], + { defaultServer }, + ); + expect(result).toEqual({ + kind: "send", + server: "http://localhost:24203", + conversationId: "deadbeef", + text: "hi", + queue: false, + open: false, + cwd: "/tmp", + reasoningEffort: "xhigh", + }); + }); + + it("requires --text", () => { + const result = parseArgs(["send", "deadbeef"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("--text"); + }); + + it("requires a conversation id", () => { + const result = parseArgs(["send", "--text", "hi"], { defaultServer }); + expect(result.kind).toBe("error"); + if (result.kind === "error") expect(result.message).toContain("conversation id"); + }); + + it("errors when --text has no value", () => { + const result = parseArgs(["send", "deadbeef", "--text"], { defaultServer }); + expect(result.kind).toBe("error"); + }); + }); }); diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index b3fa1e5..b4478b0 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -26,6 +26,18 @@ export type ParsedCommand = readonly reasoningEffort?: ReasoningEffort | undefined; readonly showReasoning: boolean; } + | { readonly kind: "list"; readonly server: string; readonly query?: string } + | { readonly kind: "read"; readonly server: string; readonly conversationId: string } + | { + readonly kind: "send"; + readonly server: string; + readonly conversationId: string; + readonly text: string; + readonly queue: boolean; + readonly open: boolean; + readonly cwd?: string; + readonly reasoningEffort?: ReasoningEffort; + } | { readonly kind: "help" } | { readonly kind: "error"; readonly message: string }; @@ -56,6 +68,119 @@ export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedComma return { kind: "models", server }; } + if (first === "list") { + let server = opts.defaultServer; + let query: string | undefined; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (query !== undefined) { + return { kind: "error", message: `Unexpected argument for 'list': ${arg}` }; + } else { + query = arg; + } + } + return { kind: "list", server, ...(query !== undefined && { query }) }; + } + + if (first === "read") { + let server = opts.defaultServer; + let conversationId: string | undefined; + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + if (arg === "--server") { + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + } else if (arg.startsWith("--")) { + return { kind: "error", message: `Unknown flag: ${arg}` }; + } else if (conversationId !== undefined) { + return { kind: "error", message: `Unexpected argument for 'read': ${arg}` }; + } else { + conversationId = arg; + } + } + if (conversationId === undefined) { + return { kind: "error", message: "'read' requires a conversation id" }; + } + return { kind: "read", server, conversationId }; + } + + if (first === "send") { + let server = opts.defaultServer; + let conversationId: string | undefined; + let text: string | undefined; + let queue = false; + let open = false; + let cwd: string | undefined; + let reasoningEffort: ReasoningEffort | undefined; + + for (let i = 1; i < argv.length; i++) { + const arg = argv[i] as string; + switch (arg) { + case "--server": + if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" }; + server = argv[++i] as string; + break; + case "--text": + if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" }; + text = argv[++i]; + break; + case "--queue": + queue = true; + break; + case "--open": + open = true; + break; + case "--cwd": + if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" }; + cwd = argv[++i]; + break; + case "--effort": { + if (i + 1 >= argv.length) + return { + kind: "error", + message: `--effort requires a value (one of: ${VALID_EFFORTS.join(", ")})`, + }; + const val = argv[++i] as string; + if (!isValidEffort(val)) + return { + kind: "error", + message: `Invalid effort level "${val}". Must be one of: ${VALID_EFFORTS.join(", ")}`, + }; + reasoningEffort = val; + break; + } + default: + if (arg.startsWith("--")) return { kind: "error", message: `Unknown flag: ${arg}` }; + if (conversationId !== undefined) + return { kind: "error", message: `Unexpected argument for 'send': ${arg}` }; + conversationId = arg; + } + } + + if (conversationId === undefined) { + return { kind: "error", message: "'send' requires a conversation id" }; + } + if (text === undefined) { + return { kind: "error", message: "'send' requires --text" }; + } + + return { + kind: "send", + server, + conversationId, + text, + queue, + open, + ...(cwd !== undefined && { cwd }), + ...(reasoningEffort !== undefined && { reasoningEffort }), + }; + } + // Chat mode: first arg is the model name const modelName = first; let text: string | undefined; diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts index 180ea98..013492e 100644 --- a/packages/cli/src/http.test.ts +++ b/packages/cli/src/http.test.ts @@ -1,6 +1,18 @@ -import type { AgentEvent, ChatRequest } from "@dispatch/transport-contract"; +import type { + AgentEvent, + ChatRequest, + ConversationListResponse, +} from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; -import { fetchModels, streamChat } from "./http.js"; +import { + enqueueMessage, + fetchConversations, + fetchLastMessage, + fetchModels, + openConversation, + resolveConversationId, + streamChat, +} from "./http.js"; function ndjsonLines(...events: AgentEvent[]): string { return `${events.map((e) => JSON.stringify(e)).join("\n")}\n`; @@ -234,3 +246,217 @@ describe("fetchModels", () => { ).rejects.toThrow("GET /models failed with status 500"); }); }); + +describe("fetchConversations", () => { + it("requests GET /conversations with no query when query omitted", async () => { + let calledUrl: string | undefined; + const list: ConversationListResponse = { + conversations: [{ id: "abcdef1234567890", title: "first", createdAt: 1, lastActivityAt: 2 }], + }; + const fakeFetch = (async (url: string | URL | Request): Promise<Response> => { + calledUrl = String(url); + return new Response(JSON.stringify(list), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations"); + expect(result).toEqual(list); + }); + + it("appends ?q=<encoded> when a query is given", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise<Response> => { + calledUrl = String(url); + return new Response(JSON.stringify({ conversations: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + await fetchConversations( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", query: "abc def" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations?q=abc%20def"); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise<Response> => + new Response("boom", { status: 500 })) as unknown as typeof fetch; + await expect( + fetchConversations({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }), + ).rejects.toThrow("GET /conversations failed with status 500"); + }); +}); + +describe("fetchLastMessage", () => { + it("requests GET /conversations/:id/last and returns the body", async () => { + let calledUrl: string | undefined; + const fakeFetch = (async (url: string | URL | Request): Promise<Response> => { + calledUrl = String(url); + return new Response( + JSON.stringify({ conversationId: "c1", content: "hello back", turnId: "t1" }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + const result = await fetchLastMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations/c1/last"); + expect(result).toEqual({ conversationId: "c1", content: "hello back", turnId: "t1" }); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise<Response> => + new Response("nope", { status: 404 })) as unknown as typeof fetch; + await expect( + fetchLastMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ), + ).rejects.toThrow("GET /conversations/:id/last failed with status 404"); + }); +}); + +describe("enqueueMessage", () => { + it("POSTs to /conversations/:id/queue with { text } and returns the body", async () => { + let calledUrl: string | undefined; + let calledInit: RequestInit | undefined; + const fakeFetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + calledUrl = String(url); + calledInit = init; + return new Response(JSON.stringify({ conversationId: "c1", startedTurn: false, queue: [] }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const result = await enqueueMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1", text: "hi" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations/c1/queue"); + expect(calledInit?.method).toBe("POST"); + expect(JSON.parse(calledInit?.body as string)).toEqual({ text: "hi" }); + expect(result).toEqual({ conversationId: "c1", startedTurn: false, queue: [] }); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise<Response> => + new Response("bad", { status: 400 })) as unknown as typeof fetch; + await expect( + enqueueMessage( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1", text: "hi" }, + ), + ).rejects.toThrow("POST /conversations/:id/queue failed with status 400"); + }); +}); + +describe("openConversation", () => { + it("POSTs to /conversations/:id/open and returns the body", async () => { + let calledUrl: string | undefined; + let calledInit: RequestInit | undefined; + const fakeFetch = (async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise<Response> => { + calledUrl = String(url); + calledInit = init; + return new Response(JSON.stringify({ conversationId: "c1" }), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await openConversation( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ); + expect(calledUrl).toBe("http://localhost:24203/conversations/c1/open"); + expect(calledInit?.method).toBe("POST"); + expect(result).toEqual({ conversationId: "c1" }); + }); + + it("throws on non-OK status", async () => { + const fakeFetch = (async (): Promise<Response> => + new Response("bad", { status: 500 })) as unknown as typeof fetch; + await expect( + openConversation( + { fetchImpl: fakeFetch }, + { server: "http://localhost:24203", conversationId: "c1" }, + ), + ).rejects.toThrow("POST /conversations/:id/open failed with status 500"); + }); +}); + +describe("resolveConversationId", () => { + function listFetch(list: ConversationListResponse): typeof fetch { + return (async (): Promise<Response> => + new Response(JSON.stringify(list), { status: 200 })) as unknown as typeof fetch; + } + + it("returns the full id on a single match", async () => { + const fetchImpl = listFetch({ + conversations: [ + { id: "abcdef1234567890abcdef1234567890", title: "only", createdAt: 1, lastActivityAt: 2 }, + ], + }); + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: "abcdef" }, + ); + expect(result).toBe("abcdef1234567890abcdef1234567890"); + }); + + it("returns an error object on no match", async () => { + const fetchImpl = listFetch({ conversations: [] }); + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: "abcdef" }, + ); + expect(typeof result).toBe("object"); + if (typeof result !== "string") { + expect(result.error).toContain("No conversation matching"); + expect(result.error).toContain("abcdef"); + } + }); + + it("returns an error object listing matches on multiple matches", async () => { + const fetchImpl = listFetch({ + conversations: [ + { id: "abcdef1234567890aaaaaaaaaaaaaaaa", title: "first", createdAt: 1, lastActivityAt: 2 }, + { + id: "abcdef1234567890bbbbbbbbbbbbbbbb", + title: "second", + createdAt: 1, + lastActivityAt: 3, + }, + ], + }); + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: "abcdef" }, + ); + expect(typeof result).toBe("object"); + if (typeof result !== "string") { + expect(result.error).toContain("Multiple conversations matching"); + expect(result.error).toContain("abcdef12"); + expect(result.error).toContain("first"); + expect(result.error).toContain("second"); + } + }); + + it("passes through a full UUID (32+ chars) without calling fetch", async () => { + const fetchImpl = (async (): Promise<Response> => { + throw new Error("fetch must not be called for a full UUID"); + }) as unknown as typeof fetch; + const fullId = "abcdef1234567890abcdef1234567890"; + const result = await resolveConversationId( + { fetchImpl }, + { server: "http://localhost:24203", shortId: fullId }, + ); + expect(result).toBe(fullId); + }); +}); 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}`, + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 82e0b21..6f16547 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,7 +6,21 @@ export { type ParsedCommand, parseArgs } from "./args.js"; export { formatCatalog } from "./catalog.js"; -export { fetchModels, streamChat } from "./http.js"; +export { + type ConversationIdResolution, + enqueueMessage, + fetchConversations, + fetchLastMessage, + fetchModels, + openConversation, + resolveConversationId, + streamChat, +} from "./http.js"; export { buildChatRequest, composeMessage } from "./message.js"; export { type SplitResult, splitNdjsonLines } from "./ndjson.js"; -export { renderEvent } from "./render.js"; +export { + extractLastText, + formatConversationList, + formatRelativeTime, + renderEvent, +} from "./render.js"; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index bf4f603..dd8cfa8 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -8,12 +8,23 @@ import { readFile } from "node:fs/promises"; import { parseArgs } from "./args.js"; import { formatCatalog } from "./catalog.js"; -import { fetchModels, streamChat } from "./http.js"; +import { + enqueueMessage, + fetchConversations, + fetchLastMessage, + fetchModels, + openConversation, + resolveConversationId, + streamChat, +} from "./http.js"; import { buildChatRequest, composeMessage } from "./message.js"; -import { renderEvent } from "./render.js"; +import { extractLastText, formatConversationList, renderEvent } from "./render.js"; const USAGE = `Usage: dispatch models [--server <url>] + dispatch list [<prefix>] [--server <url>] + dispatch read <conversationId> [--server <url>] + dispatch send <conversationId> --text "..." [--queue] [--open] [--cwd <dir>] [--effort <level>] [--server <url>] dispatch <modelName> --text "..." [--file <path>] [--cwd <dir>] [--conversation <id>] [--effort <level>] [--server <url>] [--show-reasoning] dispatch --help @@ -37,6 +48,84 @@ async function main(): Promise<void> { process.stdout.write(`${formatCatalog(result)}\n`); break; } + case "list": { + const result = await fetchConversations( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, ...(parsed.query !== undefined && { query: parsed.query }) }, + ); + const table = formatConversationList(result.conversations, Date.now()); + if (table.length > 0) process.stdout.write(`${table}\n`); + break; + } + case "read": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + const last = await fetchLastMessage( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId: resolved }, + ); + if (last.content.length > 0) process.stdout.write(`${last.content}\n`); + break; + } + case "send": { + const resolved = await resolveConversationId( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, shortId: parsed.conversationId }, + ); + if (typeof resolved !== "string") { + process.stderr.write(`${resolved.error}\n`); + process.exit(1); + } + const conversationId = resolved; + + if (parsed.queue) { + const queued = await enqueueMessage( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId, text: parsed.text }, + ); + const line = queued.startedTurn + ? `Started turn for ${conversationId}` + : `Queued to ${conversationId}`; + process.stdout.write(`${line}\n`); + } else { + const request = { + conversationId, + message: parsed.text, + ...(parsed.cwd !== undefined && { cwd: parsed.cwd }), + ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }), + }; + const { events } = await streamChat( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, request }, + ); + const collected = []; + for await (const event of events) { + if (event.type === "error") { + process.stderr.write(`${event.message}\n`); + process.exit(1); + } + collected.push(event); + if (event.type === "done") break; + } + process.stdout.write(`${extractLastText(collected)}\n`); + process.stdout.write(`[conversation] ${conversationId}\n`); + } + + if (parsed.open) { + await openConversation( + { fetchImpl: globalThis.fetch }, + { server: parsed.server, conversationId }, + ); + process.stdout.write(`Signaled frontend to open ${conversationId}\n`); + } + break; + } case "chat": { let fileContent: string | undefined; if (parsed.file) { diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts index c638584..849d33a 100644 --- a/packages/cli/src/render.test.ts +++ b/packages/cli/src/render.test.ts @@ -1,7 +1,12 @@ -import type { AgentEvent } from "@dispatch/transport-contract"; +import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract"; import type { StepId } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { renderEvent } from "./render.js"; +import { + extractLastText, + formatConversationList, + formatRelativeTime, + renderEvent, +} from "./render.js"; describe("renderEvent", () => { const opts = { showReasoning: false }; @@ -149,3 +154,99 @@ describe("renderEvent", () => { expect(renderEvent(e, opts)).toBeUndefined(); }); }); + +describe("extractLastText", () => { + it("accumulates text deltas", () => { + const events: AgentEvent[] = [ + { type: "text-delta", conversationId: "c", turnId: "t", delta: "Hello" }, + { type: "text-delta", conversationId: "c", turnId: "t", delta: ", " }, + { type: "text-delta", conversationId: "c", turnId: "t", delta: "world" }, + { type: "done", conversationId: "c", turnId: "t", reason: "completed" }, + ]; + expect(extractLastText(events)).toBe("Hello, world"); + }); + + it("returns empty string when no text-delta events", () => { + const events: AgentEvent[] = [ + { type: "turn-start", conversationId: "c", turnId: "t" }, + { type: "done", conversationId: "c", turnId: "t", reason: "completed" }, + ]; + expect(extractLastText(events)).toBe(""); + }); + + it("ignores non-text-delta events but keeps deltas interleaved among them", () => { + const events: AgentEvent[] = [ + { type: "text-delta", conversationId: "c", turnId: "t", delta: "a" }, + { + type: "tool-call", + conversationId: "c", + turnId: "t", + stepId: "t1#0" as StepId, + toolCallId: "tc1", + toolName: "read_file", + input: {}, + }, + { type: "text-delta", conversationId: "c", turnId: "t", delta: "b" }, + ]; + expect(extractLastText(events)).toBe("ab"); + }); + + it("returns empty string for an empty event list", () => { + expect(extractLastText([])).toBe(""); + }); +}); + +describe("formatRelativeTime", () => { + const now = 1_000_000_000_000; // fixed clock + + it("returns 'just now' for under a minute", () => { + expect(formatRelativeTime(now - 30_000, now)).toBe("just now"); + }); + + it("returns minutes ago", () => { + expect(formatRelativeTime(now - 5 * 60_000, now)).toBe("5m ago"); + }); + + it("returns hours ago", () => { + expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3h ago"); + }); + + it("returns days ago", () => { + expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2d ago"); + }); + + it("returns a date past a week", () => { + // 2001-09-09T01:46:40.000Z + expect(formatRelativeTime(now - 8 * 86_400_000, now)).toBe("2001-09-01"); + }); +}); + +describe("formatConversationList", () => { + const now = 1_000_000_000_000; + + const conv = (id: string, title: string, ageMs: number): ConversationMeta => ({ + id, + title, + createdAt: now - ageMs - 1000, + lastActivityAt: now - ageMs, + }); + + it("returns empty string for an empty list", () => { + expect(formatConversationList([], now)).toBe(""); + }); + + it("formats one row with short id, title, relative time", () => { + const list = [conv("abcdef1234567890", "hello world", 5 * 60_000)]; + expect(formatConversationList(list, now)).toBe("abcdef12 | hello world | 5m ago"); + }); + + it("formats multiple rows one per line", () => { + const list = [ + conv("abcdef1234567890", "first", 5 * 60_000), + conv("0123456789abcdef", "second", 3 * 3_600_000), + ]; + expect(formatConversationList(list, now)).toBe( + "abcdef12 | first | 5m ago\n01234567 | second | 3h ago", + ); + }); +}); diff --git a/packages/cli/src/render.ts b/packages/cli/src/render.ts index 1853963..9f25dd3 100644 --- a/packages/cli/src/render.ts +++ b/packages/cli/src/render.ts @@ -5,7 +5,7 @@ * Consumers write these to process.stdout / process.stderr. */ -import type { AgentEvent } from "@dispatch/transport-contract"; +import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract"; interface RenderOpts { readonly showReasoning: boolean; @@ -43,3 +43,50 @@ export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | und return undefined; } } + +/** + * Accumulate ALL `text-delta` events' `delta` text into one string — the full + * assistant reply assembled from a turn's event stream. Pure: input → output, + * no I/O. Returns the empty string when the stream had no text deltas. + */ +export function extractLastText(events: readonly AgentEvent[]): string { + let text = ""; + for (const e of events) { + if (e.type === "text-delta") { + text += e.delta; + } + } + return text; +} + +/** + * Render an epoch-ms timestamp as a short relative phrase ("just now", "5m ago", + * "3h ago", "2d ago") or, past a week, the `YYYY-MM-DD` date. `now` is injected + * (clock is an outermost edge) so the function is pure and testable. + */ +export function formatRelativeTime(epochMs: number, now: number): string { + const delta = now - epochMs; + if (delta < 60_000) return "just now"; + const minutes = Math.floor(delta / 60_000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Date(epochMs).toISOString().slice(0, 10); +} + +/** + * Format the conversation list as a table — one row per conversation: + * `shortId | title | lastActivity (relative)`. Empty input yields the empty + * string. `now` is injected for `formatRelativeTime` (pure + testable). + */ +export function formatConversationList( + conversations: readonly ConversationMeta[], + now: number, +): string { + if (conversations.length === 0) return ""; + return conversations + .map((c) => `${c.id.slice(0, 8)} | ${c.title} | ${formatRelativeTime(c.lastActivityAt, now)}`) + .join("\n"); +} |
