summaryrefslogtreecommitdiffhomepage
path: root/packages/cli
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli')
-rw-r--r--packages/cli/src/args.test.ts141
-rw-r--r--packages/cli/src/args.ts125
-rw-r--r--packages/cli/src/http.test.ts230
-rw-r--r--packages/cli/src/http.ts142
-rw-r--r--packages/cli/src/index.ts18
-rw-r--r--packages/cli/src/main.ts93
-rw-r--r--packages/cli/src/render.test.ts105
-rw-r--r--packages/cli/src/render.ts49
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");
+}