summaryrefslogtreecommitdiffhomepage
path: root/packages/cli/src/http.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 19:20:10 +0900
committerAdam Malczewski <[email protected]>2026-06-21 19:20:10 +0900
commitc5e9fd6cd6565b55fab1bf2b9d8dacf8ba72a9f4 (patch)
tree809bd93eaa25646f237fb4b1ddd3719e25aaca90 /packages/cli/src/http.test.ts
parentea0e938eca3072649dc8707c999ec00cf87b986a (diff)
downloaddispatch-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.test.ts')
-rw-r--r--packages/cli/src/http.test.ts230
1 files changed, 228 insertions, 2 deletions
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);
+ });
+});