summaryrefslogtreecommitdiffhomepage
path: root/packages/cli
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/cli
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/cli')
-rw-r--r--packages/cli/package.json20
-rw-r--r--packages/cli/src/args.test.ts956
-rw-r--r--packages/cli/src/args.ts700
-rw-r--r--packages/cli/src/catalog.test.ts22
-rw-r--r--packages/cli/src/catalog.ts2
-rw-r--r--packages/cli/src/http.test.ts970
-rw-r--r--packages/cli/src/http.ts338
-rw-r--r--packages/cli/src/index.ts24
-rw-r--r--packages/cli/src/main.ts414
-rw-r--r--packages/cli/src/message.test.ts270
-rw-r--r--packages/cli/src/message.ts66
-rw-r--r--packages/cli/src/ndjson.test.ts86
-rw-r--r--packages/cli/src/ndjson.ts12
-rw-r--r--packages/cli/src/render.test.ts464
-rw-r--r--packages/cli/src/render.ts100
-rw-r--r--packages/cli/tsconfig.json8
16 files changed, 2226 insertions, 2226 deletions
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 3d99629..2a406cf 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,12 +1,12 @@
{
- "name": "@dispatch/cli",
- "version": "0.0.0",
- "type": "module",
- "private": true,
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
- "dependencies": {
- "@dispatch/transport-contract": "workspace:*",
- "@dispatch/wire": "workspace:*"
- }
+ "name": "@dispatch/cli",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/transport-contract": "workspace:*",
+ "@dispatch/wire": "workspace:*"
+ }
}
diff --git a/packages/cli/src/args.test.ts b/packages/cli/src/args.test.ts
index e613f31..e6b43cf 100644
--- a/packages/cli/src/args.test.ts
+++ b/packages/cli/src/args.test.ts
@@ -4,482 +4,482 @@ import { parseArgs } from "./args.js";
const defaultServer = "http://localhost:24203";
describe("parseArgs", () => {
- it("returns help for empty argv", () => {
- expect(parseArgs([], { defaultServer })).toEqual({ kind: "help" });
- });
-
- it("returns help for --help", () => {
- expect(parseArgs(["--help"], { defaultServer })).toEqual({ kind: "help" });
- });
-
- it("returns help for -h", () => {
- expect(parseArgs(["-h"], { defaultServer })).toEqual({ kind: "help" });
- });
-
- describe("models", () => {
- it("parses 'models' with default server", () => {
- expect(parseArgs(["models"], { defaultServer })).toEqual({
- kind: "models",
- server: "http://localhost:24203",
- });
- });
-
- it("parses 'models --server <url>'", () => {
- expect(parseArgs(["models", "--server", "http://example.com"], { defaultServer })).toEqual({
- kind: "models",
- server: "http://example.com",
- });
- });
-
- it("errors on unknown argument for models", () => {
- const result = parseArgs(["models", "--foo"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("Unknown argument");
- });
- });
-
- describe("chat", () => {
- it("parses a chat with --text", () => {
- const result = parseArgs(["my-model", "--text", "hello"], { defaultServer });
- expect(result).toEqual({
- kind: "chat",
- server: "http://localhost:24203",
- modelName: "my-model",
- text: "hello",
- file: undefined,
- cwd: undefined,
- conversationId: undefined,
- reasoningEffort: undefined,
- showReasoning: false,
- open: false,
- workspaceId: undefined,
- });
- });
-
- it("parses a chat with --file", () => {
- const result = parseArgs(["my-model", "--file", "foo.txt"], { defaultServer });
- expect(result).toEqual({
- kind: "chat",
- server: "http://localhost:24203",
- modelName: "my-model",
- text: undefined,
- file: "foo.txt",
- cwd: undefined,
- conversationId: undefined,
- reasoningEffort: undefined,
- showReasoning: false,
- open: false,
- workspaceId: undefined,
- });
- });
-
- it("parses a chat with both --text and --file", () => {
- const result = parseArgs(["m", "--text", "hi", "--file", "f.txt"], { defaultServer });
- expect(result).toMatchObject({ kind: "chat", text: "hi", file: "f.txt" });
- });
-
- it("parses --cwd, --conversation, --server, --show-reasoning", () => {
- const result = parseArgs(
- [
- "m",
- "--text",
- "x",
- "--cwd",
- "/tmp",
- "--conversation",
- "abc",
- "--server",
- "http://s",
- "--show-reasoning",
- ],
- { defaultServer },
- );
- expect(result).toEqual({
- kind: "chat",
- server: "http://s",
- modelName: "m",
- text: "x",
- file: undefined,
- cwd: "/tmp",
- conversationId: "abc",
- reasoningEffort: undefined,
- showReasoning: true,
- open: false,
- workspaceId: undefined,
- });
- });
-
- it("parses --effort high", () => {
- const result = parseArgs(["m", "--text", "x", "--effort", "high"], { defaultServer });
- expect(result).toEqual({
- kind: "chat",
- server: "http://localhost:24203",
- modelName: "m",
- text: "x",
- file: undefined,
- cwd: undefined,
- conversationId: undefined,
- reasoningEffort: "high",
- showReasoning: false,
- open: false,
- workspaceId: undefined,
- });
- });
-
- it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts --effort %s", (level) => {
- const result = parseArgs(["m", "--text", "x", "--effort", level], { defaultServer });
- expect(result.kind).toBe("chat");
- if (result.kind === "chat") expect(result.reasoningEffort).toBe(level);
- });
-
- it("errors when --effort has no value", () => {
- const result = parseArgs(["m", "--text", "x", "--effort"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("--effort requires a value");
- });
-
- it("errors on invalid effort level", () => {
- const result = parseArgs(["m", "--text", "x", "--effort", "banana"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") {
- expect(result.message).toContain("banana");
- expect(result.message).toContain("low");
- expect(result.message).toContain("medium");
- expect(result.message).toContain("high");
- expect(result.message).toContain("xhigh");
- expect(result.message).toContain("max");
- }
- });
-
- it("errors when text and file are both missing", () => {
- const result = parseArgs(["my-model"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("--text or --file");
- });
-
- it("errors on unknown flag", () => {
- const result = parseArgs(["my-model", "--text", "hi", "--bogus"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("Unknown flag");
- });
-
- it("errors when --text has no value", () => {
- const result = parseArgs(["m", "--text"], { defaultServer });
- expect(result.kind).toBe("error");
- });
-
- it("errors when --file has no value", () => {
- const result = parseArgs(["m", "--file"], { defaultServer });
- expect(result.kind).toBe("error");
- });
-
- it("errors when --server has no value", () => {
- const result = parseArgs(["models", "--server"], { defaultServer });
- expect(result.kind).toBe("error");
- });
-
- it("errors when --cwd has no value", () => {
- const result = parseArgs(["m", "--text", "x", "--cwd"], { defaultServer });
- expect(result.kind).toBe("error");
- });
-
- it("errors when --conversation has no value", () => {
- const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer });
- expect(result.kind).toBe("error");
- });
-
- it("parses --workspace flag", () => {
- const result = parseArgs(["m", "--text", "x", "--workspace", "my-work"], { defaultServer });
- expect(result).toEqual({
- kind: "chat",
- server: "http://localhost:24203",
- modelName: "m",
- text: "x",
- file: undefined,
- cwd: undefined,
- conversationId: undefined,
- reasoningEffort: undefined,
- showReasoning: false,
- open: false,
- workspaceId: "my-work",
- });
- });
-
- it("parses -w shorthand", () => {
- const result = parseArgs(["m", "--text", "x", "-w", "ws"], { defaultServer });
- expect(result.kind).toBe("chat");
- if (result.kind === "chat") expect(result.workspaceId).toBe("ws");
- });
-
- it("errors when --workspace has no value", () => {
- const result = parseArgs(["m", "--text", "x", "--workspace"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("--workspace requires a value");
- });
- });
-
- describe("list", () => {
- it("parses 'list' with no query", () => {
- expect(parseArgs(["list"], { defaultServer })).toEqual({
- kind: "list",
- server: "http://localhost:24203",
- all: false,
- });
- });
-
- it("parses 'list' with a query prefix", () => {
- expect(parseArgs(["list", "abc12345"], { defaultServer })).toEqual({
- kind: "list",
- server: "http://localhost:24203",
- query: "abc12345",
- all: false,
- });
- });
-
- it("parses 'list' with --server after the prefix", () => {
- expect(parseArgs(["list", "abc", "--server", "http://s"], { defaultServer })).toEqual({
- kind: "list",
- server: "http://s",
- query: "abc",
- all: false,
- });
- });
-
- it("parses 'list' with --status", () => {
- expect(parseArgs(["list", "--status", "closed"], { defaultServer })).toEqual({
- kind: "list",
- server: "http://localhost:24203",
- status: "closed",
- all: false,
- });
- });
-
- it("parses 'list' with --workspace", () => {
- expect(parseArgs(["list", "--workspace", "proj"], { defaultServer })).toEqual({
- kind: "list",
- server: "http://localhost:24203",
- workspaceId: "proj",
- all: false,
- });
- });
-
- it("parses 'list' with -w shorthand", () => {
- const result = parseArgs(["list", "-w", "ws"], { defaultServer });
- expect(result.kind).toBe("list");
- if (result.kind === "list") expect(result.workspaceId).toBe("ws");
- });
-
- it("parses 'list' with --workspace, --status, and a prefix together", () => {
- const result = parseArgs(["list", "abc", "--status", "active", "--workspace", "proj"], {
- defaultServer,
- });
- expect(result).toEqual({
- kind: "list",
- server: "http://localhost:24203",
- query: "abc",
- status: "active",
- workspaceId: "proj",
- all: false,
- });
- });
-
- it("errors when --workspace has no value (list)", () => {
- const result = parseArgs(["list", "--workspace"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("--workspace requires a value");
- });
-
- it("parses 'list' with --all", () => {
- expect(parseArgs(["list", "--all"], { defaultServer })).toEqual({
- kind: "list",
- server: "http://localhost:24203",
- all: true,
- });
- });
-
- it("parses 'list' with --status and --all ( --all takes precedence)", () => {
- const result = parseArgs(["list", "--status", "active", "--all"], { defaultServer });
- expect(result.kind).toBe("list");
- if (result.kind === "list") {
- expect(result.all).toBe(true);
- expect(result.status).toBe("active");
- }
- });
-
- 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",
- file: undefined,
- queue: false,
- open: false,
- });
- });
-
- it("parses 'send' with --file", () => {
- expect(parseArgs(["send", "deadbeef", "--file", "foo.txt"], { defaultServer })).toEqual({
- kind: "send",
- server: "http://localhost:24203",
- conversationId: "deadbeef",
- text: undefined,
- file: "foo.txt",
- queue: false,
- open: false,
- });
- });
-
- it("parses 'send' with both --text and --file", () => {
- const result = parseArgs(["send", "deadbeef", "--text", "hi", "--file", "f.txt"], {
- defaultServer,
- });
- expect(result).toMatchObject({ kind: "send", text: "hi", file: "f.txt" });
- });
-
- 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",
- file: undefined,
- 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",
- file: undefined,
- 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",
- file: undefined,
- queue: false,
- open: false,
- cwd: "/tmp",
- reasoningEffort: "xhigh",
- });
- });
-
- it("errors when --text and --file are both missing", () => {
- const result = parseArgs(["send", "deadbeef"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("--text or --file");
- });
-
- 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");
- });
-
- it("errors when --file has no value", () => {
- const result = parseArgs(["send", "deadbeef", "--file"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("--file requires a value");
- });
- });
-
- describe("open", () => {
- it("parses 'open' with conversation id", () => {
- expect(parseArgs(["open", "deadbeef"], { defaultServer })).toEqual({
- kind: "open",
- server: "http://localhost:24203",
- conversationId: "deadbeef",
- });
- });
-
- it("parses 'open' with --server", () => {
- expect(
- parseArgs(["open", "deadbeef", "--server", "http://example.com"], { defaultServer }),
- ).toEqual({
- kind: "open",
- server: "http://example.com",
- conversationId: "deadbeef",
- });
- });
-
- it("requires a conversation id", () => {
- const result = parseArgs(["open"], { defaultServer });
- expect(result.kind).toBe("error");
- if (result.kind === "error") expect(result.message).toContain("conversation id");
- });
-
- it("rejects unknown flags", () => {
- const result = parseArgs(["open", "deadbeef", "--bogus"], { defaultServer });
- expect(result.kind).toBe("error");
- });
- });
+ it("returns help for empty argv", () => {
+ expect(parseArgs([], { defaultServer })).toEqual({ kind: "help" });
+ });
+
+ it("returns help for --help", () => {
+ expect(parseArgs(["--help"], { defaultServer })).toEqual({ kind: "help" });
+ });
+
+ it("returns help for -h", () => {
+ expect(parseArgs(["-h"], { defaultServer })).toEqual({ kind: "help" });
+ });
+
+ describe("models", () => {
+ it("parses 'models' with default server", () => {
+ expect(parseArgs(["models"], { defaultServer })).toEqual({
+ kind: "models",
+ server: "http://localhost:24203",
+ });
+ });
+
+ it("parses 'models --server <url>'", () => {
+ expect(parseArgs(["models", "--server", "http://example.com"], { defaultServer })).toEqual({
+ kind: "models",
+ server: "http://example.com",
+ });
+ });
+
+ it("errors on unknown argument for models", () => {
+ const result = parseArgs(["models", "--foo"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("Unknown argument");
+ });
+ });
+
+ describe("chat", () => {
+ it("parses a chat with --text", () => {
+ const result = parseArgs(["my-model", "--text", "hello"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "my-model",
+ text: "hello",
+ file: undefined,
+ cwd: undefined,
+ conversationId: undefined,
+ reasoningEffort: undefined,
+ showReasoning: false,
+ open: false,
+ workspaceId: undefined,
+ });
+ });
+
+ it("parses a chat with --file", () => {
+ const result = parseArgs(["my-model", "--file", "foo.txt"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "my-model",
+ text: undefined,
+ file: "foo.txt",
+ cwd: undefined,
+ conversationId: undefined,
+ reasoningEffort: undefined,
+ showReasoning: false,
+ open: false,
+ workspaceId: undefined,
+ });
+ });
+
+ it("parses a chat with both --text and --file", () => {
+ const result = parseArgs(["m", "--text", "hi", "--file", "f.txt"], { defaultServer });
+ expect(result).toMatchObject({ kind: "chat", text: "hi", file: "f.txt" });
+ });
+
+ it("parses --cwd, --conversation, --server, --show-reasoning", () => {
+ const result = parseArgs(
+ [
+ "m",
+ "--text",
+ "x",
+ "--cwd",
+ "/tmp",
+ "--conversation",
+ "abc",
+ "--server",
+ "http://s",
+ "--show-reasoning",
+ ],
+ { defaultServer },
+ );
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://s",
+ modelName: "m",
+ text: "x",
+ file: undefined,
+ cwd: "/tmp",
+ conversationId: "abc",
+ reasoningEffort: undefined,
+ showReasoning: true,
+ open: false,
+ workspaceId: undefined,
+ });
+ });
+
+ it("parses --effort high", () => {
+ const result = parseArgs(["m", "--text", "x", "--effort", "high"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "m",
+ text: "x",
+ file: undefined,
+ cwd: undefined,
+ conversationId: undefined,
+ reasoningEffort: "high",
+ showReasoning: false,
+ open: false,
+ workspaceId: undefined,
+ });
+ });
+
+ it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts --effort %s", (level) => {
+ const result = parseArgs(["m", "--text", "x", "--effort", level], { defaultServer });
+ expect(result.kind).toBe("chat");
+ if (result.kind === "chat") expect(result.reasoningEffort).toBe(level);
+ });
+
+ it("errors when --effort has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--effort"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--effort requires a value");
+ });
+
+ it("errors on invalid effort level", () => {
+ const result = parseArgs(["m", "--text", "x", "--effort", "banana"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") {
+ expect(result.message).toContain("banana");
+ expect(result.message).toContain("low");
+ expect(result.message).toContain("medium");
+ expect(result.message).toContain("high");
+ expect(result.message).toContain("xhigh");
+ expect(result.message).toContain("max");
+ }
+ });
+
+ it("errors when text and file are both missing", () => {
+ const result = parseArgs(["my-model"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--text or --file");
+ });
+
+ it("errors on unknown flag", () => {
+ const result = parseArgs(["my-model", "--text", "hi", "--bogus"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("Unknown flag");
+ });
+
+ it("errors when --text has no value", () => {
+ const result = parseArgs(["m", "--text"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --file has no value", () => {
+ const result = parseArgs(["m", "--file"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --server has no value", () => {
+ const result = parseArgs(["models", "--server"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --cwd has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--cwd"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("errors when --conversation has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--conversation"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+
+ it("parses --workspace flag", () => {
+ const result = parseArgs(["m", "--text", "x", "--workspace", "my-work"], { defaultServer });
+ expect(result).toEqual({
+ kind: "chat",
+ server: "http://localhost:24203",
+ modelName: "m",
+ text: "x",
+ file: undefined,
+ cwd: undefined,
+ conversationId: undefined,
+ reasoningEffort: undefined,
+ showReasoning: false,
+ open: false,
+ workspaceId: "my-work",
+ });
+ });
+
+ it("parses -w shorthand", () => {
+ const result = parseArgs(["m", "--text", "x", "-w", "ws"], { defaultServer });
+ expect(result.kind).toBe("chat");
+ if (result.kind === "chat") expect(result.workspaceId).toBe("ws");
+ });
+
+ it("errors when --workspace has no value", () => {
+ const result = parseArgs(["m", "--text", "x", "--workspace"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--workspace requires a value");
+ });
+ });
+
+ describe("list", () => {
+ it("parses 'list' with no query", () => {
+ expect(parseArgs(["list"], { defaultServer })).toEqual({
+ kind: "list",
+ server: "http://localhost:24203",
+ all: false,
+ });
+ });
+
+ it("parses 'list' with a query prefix", () => {
+ expect(parseArgs(["list", "abc12345"], { defaultServer })).toEqual({
+ kind: "list",
+ server: "http://localhost:24203",
+ query: "abc12345",
+ all: false,
+ });
+ });
+
+ it("parses 'list' with --server after the prefix", () => {
+ expect(parseArgs(["list", "abc", "--server", "http://s"], { defaultServer })).toEqual({
+ kind: "list",
+ server: "http://s",
+ query: "abc",
+ all: false,
+ });
+ });
+
+ it("parses 'list' with --status", () => {
+ expect(parseArgs(["list", "--status", "closed"], { defaultServer })).toEqual({
+ kind: "list",
+ server: "http://localhost:24203",
+ status: "closed",
+ all: false,
+ });
+ });
+
+ it("parses 'list' with --workspace", () => {
+ expect(parseArgs(["list", "--workspace", "proj"], { defaultServer })).toEqual({
+ kind: "list",
+ server: "http://localhost:24203",
+ workspaceId: "proj",
+ all: false,
+ });
+ });
+
+ it("parses 'list' with -w shorthand", () => {
+ const result = parseArgs(["list", "-w", "ws"], { defaultServer });
+ expect(result.kind).toBe("list");
+ if (result.kind === "list") expect(result.workspaceId).toBe("ws");
+ });
+
+ it("parses 'list' with --workspace, --status, and a prefix together", () => {
+ const result = parseArgs(["list", "abc", "--status", "active", "--workspace", "proj"], {
+ defaultServer,
+ });
+ expect(result).toEqual({
+ kind: "list",
+ server: "http://localhost:24203",
+ query: "abc",
+ status: "active",
+ workspaceId: "proj",
+ all: false,
+ });
+ });
+
+ it("errors when --workspace has no value (list)", () => {
+ const result = parseArgs(["list", "--workspace"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--workspace requires a value");
+ });
+
+ it("parses 'list' with --all", () => {
+ expect(parseArgs(["list", "--all"], { defaultServer })).toEqual({
+ kind: "list",
+ server: "http://localhost:24203",
+ all: true,
+ });
+ });
+
+ it("parses 'list' with --status and --all ( --all takes precedence)", () => {
+ const result = parseArgs(["list", "--status", "active", "--all"], { defaultServer });
+ expect(result.kind).toBe("list");
+ if (result.kind === "list") {
+ expect(result.all).toBe(true);
+ expect(result.status).toBe("active");
+ }
+ });
+
+ 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",
+ file: undefined,
+ queue: false,
+ open: false,
+ });
+ });
+
+ it("parses 'send' with --file", () => {
+ expect(parseArgs(["send", "deadbeef", "--file", "foo.txt"], { defaultServer })).toEqual({
+ kind: "send",
+ server: "http://localhost:24203",
+ conversationId: "deadbeef",
+ text: undefined,
+ file: "foo.txt",
+ queue: false,
+ open: false,
+ });
+ });
+
+ it("parses 'send' with both --text and --file", () => {
+ const result = parseArgs(["send", "deadbeef", "--text", "hi", "--file", "f.txt"], {
+ defaultServer,
+ });
+ expect(result).toMatchObject({ kind: "send", text: "hi", file: "f.txt" });
+ });
+
+ 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",
+ file: undefined,
+ 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",
+ file: undefined,
+ 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",
+ file: undefined,
+ queue: false,
+ open: false,
+ cwd: "/tmp",
+ reasoningEffort: "xhigh",
+ });
+ });
+
+ it("errors when --text and --file are both missing", () => {
+ const result = parseArgs(["send", "deadbeef"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--text or --file");
+ });
+
+ 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");
+ });
+
+ it("errors when --file has no value", () => {
+ const result = parseArgs(["send", "deadbeef", "--file"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("--file requires a value");
+ });
+ });
+
+ describe("open", () => {
+ it("parses 'open' with conversation id", () => {
+ expect(parseArgs(["open", "deadbeef"], { defaultServer })).toEqual({
+ kind: "open",
+ server: "http://localhost:24203",
+ conversationId: "deadbeef",
+ });
+ });
+
+ it("parses 'open' with --server", () => {
+ expect(
+ parseArgs(["open", "deadbeef", "--server", "http://example.com"], { defaultServer }),
+ ).toEqual({
+ kind: "open",
+ server: "http://example.com",
+ conversationId: "deadbeef",
+ });
+ });
+
+ it("requires a conversation id", () => {
+ const result = parseArgs(["open"], { defaultServer });
+ expect(result.kind).toBe("error");
+ if (result.kind === "error") expect(result.message).toContain("conversation id");
+ });
+
+ it("rejects unknown flags", () => {
+ const result = parseArgs(["open", "deadbeef", "--bogus"], { defaultServer });
+ expect(result.kind).toBe("error");
+ });
+ });
});
diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts
index 74cc56a..52f1fba 100644
--- a/packages/cli/src/args.ts
+++ b/packages/cli/src/args.ts
@@ -10,378 +10,378 @@ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
const VALID_EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
export function isValidEffort(value: string): value is ReasoningEffort {
- return (VALID_EFFORTS as readonly string[]).includes(value);
+ return (VALID_EFFORTS as readonly string[]).includes(value);
}
export type ParsedCommand =
- | { readonly kind: "models"; readonly server: string }
- | {
- readonly kind: "chat";
- readonly server: string;
- readonly modelName: string;
- readonly text?: string | undefined;
- readonly file?: string | undefined;
- readonly cwd?: string | undefined;
- readonly conversationId?: string | undefined;
- readonly reasoningEffort?: ReasoningEffort | undefined;
- readonly showReasoning: boolean;
- readonly open: boolean;
- readonly workspaceId?: string | undefined;
- }
- | {
- readonly kind: "list";
- readonly server: string;
- readonly query?: string;
- readonly status?: string;
- readonly workspaceId?: string;
- readonly all: boolean;
- }
- | { readonly kind: "compact"; readonly server: string; readonly conversationId: string }
- | { readonly kind: "open"; readonly server: string; readonly conversationId: string }
- | { readonly kind: "read"; readonly server: string; readonly conversationId: string }
- | {
- readonly kind: "send";
- readonly server: string;
- readonly conversationId: string;
- readonly text?: string | undefined;
- readonly file?: string | undefined;
- readonly queue: boolean;
- readonly open: boolean;
- readonly cwd?: string;
- readonly reasoningEffort?: ReasoningEffort;
- readonly workspaceId?: string;
- }
- | { readonly kind: "stop"; readonly server: string; readonly conversationId: string }
- | { readonly kind: "help" }
- | { readonly kind: "error"; readonly message: string };
+ | { readonly kind: "models"; readonly server: string }
+ | {
+ readonly kind: "chat";
+ readonly server: string;
+ readonly modelName: string;
+ readonly text?: string | undefined;
+ readonly file?: string | undefined;
+ readonly cwd?: string | undefined;
+ readonly conversationId?: string | undefined;
+ readonly reasoningEffort?: ReasoningEffort | undefined;
+ readonly showReasoning: boolean;
+ readonly open: boolean;
+ readonly workspaceId?: string | undefined;
+ }
+ | {
+ readonly kind: "list";
+ readonly server: string;
+ readonly query?: string;
+ readonly status?: string;
+ readonly workspaceId?: string;
+ readonly all: boolean;
+ }
+ | { readonly kind: "compact"; readonly server: string; readonly conversationId: string }
+ | { readonly kind: "open"; readonly server: string; readonly conversationId: string }
+ | { readonly kind: "read"; readonly server: string; readonly conversationId: string }
+ | {
+ readonly kind: "send";
+ readonly server: string;
+ readonly conversationId: string;
+ readonly text?: string | undefined;
+ readonly file?: string | undefined;
+ readonly queue: boolean;
+ readonly open: boolean;
+ readonly cwd?: string;
+ readonly reasoningEffort?: ReasoningEffort;
+ readonly workspaceId?: string;
+ }
+ | { readonly kind: "stop"; readonly server: string; readonly conversationId: string }
+ | { readonly kind: "help" }
+ | { readonly kind: "error"; readonly message: string };
interface ParseOpts {
- readonly defaultServer: string;
+ readonly defaultServer: string;
}
export function parseArgs(argv: readonly string[], opts: ParseOpts): ParsedCommand {
- if (argv.length === 0) {
- return { kind: "help" };
- }
+ if (argv.length === 0) {
+ return { kind: "help" };
+ }
- const first = argv[0] as string;
+ const first = argv[0] as string;
- if (first === "--help" || first === "-h") {
- return { kind: "help" };
- }
+ if (first === "--help" || first === "-h") {
+ return { kind: "help" };
+ }
- if (first === "models") {
- let server = opts.defaultServer;
- for (let i = 1; i < argv.length; i++) {
- if (argv[i] === "--server" && i + 1 < argv.length) {
- server = argv[++i] as string;
- } else {
- return { kind: "error", message: `Unknown argument for 'models': ${argv[i]}` };
- }
- }
- return { kind: "models", server };
- }
+ if (first === "models") {
+ let server = opts.defaultServer;
+ for (let i = 1; i < argv.length; i++) {
+ if (argv[i] === "--server" && i + 1 < argv.length) {
+ server = argv[++i] as string;
+ } else {
+ return { kind: "error", message: `Unknown argument for 'models': ${argv[i]}` };
+ }
+ }
+ return { kind: "models", server };
+ }
- if (first === "list") {
- let server = opts.defaultServer;
- let query: string | undefined;
- let status: string | undefined;
- let workspaceId: string | undefined;
- let all = false;
- 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 === "--status") {
- if (i + 1 >= argv.length) return { kind: "error", message: "--status requires a value" };
- status = argv[++i];
- } else if (arg === "--workspace" || arg === "-w") {
- if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" };
- workspaceId = argv[++i];
- } else if (arg === "--all") {
- all = true;
- } 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 }),
- ...(status !== undefined && { status }),
- ...(workspaceId !== undefined && { workspaceId }),
- all,
- };
- }
+ if (first === "list") {
+ let server = opts.defaultServer;
+ let query: string | undefined;
+ let status: string | undefined;
+ let workspaceId: string | undefined;
+ let all = false;
+ 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 === "--status") {
+ if (i + 1 >= argv.length) return { kind: "error", message: "--status requires a value" };
+ status = argv[++i];
+ } else if (arg === "--workspace" || arg === "-w") {
+ if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" };
+ workspaceId = argv[++i];
+ } else if (arg === "--all") {
+ all = true;
+ } 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 }),
+ ...(status !== undefined && { status }),
+ ...(workspaceId !== undefined && { workspaceId }),
+ all,
+ };
+ }
- if (first === "compact") {
- 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 'compact': ${arg}` };
- } else {
- conversationId = arg;
- }
- }
- if (conversationId === undefined) {
- return { kind: "error", message: "'compact' requires a conversation id" };
- }
- return { kind: "compact", server, conversationId };
- }
+ if (first === "compact") {
+ 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 'compact': ${arg}` };
+ } else {
+ conversationId = arg;
+ }
+ }
+ if (conversationId === undefined) {
+ return { kind: "error", message: "'compact' requires a conversation id" };
+ }
+ return { kind: "compact", server, conversationId };
+ }
- if (first === "stop") {
- 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 'stop': ${arg}` };
- } else {
- conversationId = arg;
- }
- }
- if (conversationId === undefined) {
- return { kind: "error", message: "'stop' requires a conversation id" };
- }
- return { kind: "stop", server, conversationId };
- }
+ if (first === "stop") {
+ 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 'stop': ${arg}` };
+ } else {
+ conversationId = arg;
+ }
+ }
+ if (conversationId === undefined) {
+ return { kind: "error", message: "'stop' requires a conversation id" };
+ }
+ return { kind: "stop", server, conversationId };
+ }
- 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 === "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 === "open") {
- 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 'open': ${arg}` };
- } else {
- conversationId = arg;
- }
- }
- if (conversationId === undefined) {
- return { kind: "error", message: "'open' requires a conversation id" };
- }
- return { kind: "open", server, conversationId };
- }
+ if (first === "open") {
+ 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 'open': ${arg}` };
+ } else {
+ conversationId = arg;
+ }
+ }
+ if (conversationId === undefined) {
+ return { kind: "error", message: "'open' requires a conversation id" };
+ }
+ return { kind: "open", server, conversationId };
+ }
- if (first === "send") {
- let server = opts.defaultServer;
- let conversationId: string | undefined;
- let text: string | undefined;
- let file: string | undefined;
- let queue = false;
- let open = false;
- let cwd: string | undefined;
- let reasoningEffort: ReasoningEffort | undefined;
- let workspaceId: string | undefined;
+ if (first === "send") {
+ let server = opts.defaultServer;
+ let conversationId: string | undefined;
+ let text: string | undefined;
+ let file: string | undefined;
+ let queue = false;
+ let open = false;
+ let cwd: string | undefined;
+ let reasoningEffort: ReasoningEffort | undefined;
+ let workspaceId: string | 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 "--file":
- if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" };
- file = 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;
- }
- case "--workspace":
- case "-w":
- if (i + 1 >= argv.length)
- return { kind: "error", message: "--workspace requires a value" };
- workspaceId = argv[++i];
- 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;
- }
- }
+ 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 "--file":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" };
+ file = 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;
+ }
+ case "--workspace":
+ case "-w":
+ if (i + 1 >= argv.length)
+ return { kind: "error", message: "--workspace requires a value" };
+ workspaceId = argv[++i];
+ 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 && !file) {
- return {
- kind: "error",
- message: "At least one of --text or --file is required for 'send'",
- };
- }
+ if (conversationId === undefined) {
+ return { kind: "error", message: "'send' requires a conversation id" };
+ }
+ if (!text && !file) {
+ return {
+ kind: "error",
+ message: "At least one of --text or --file is required for 'send'",
+ };
+ }
- return {
- kind: "send",
- server,
- conversationId,
- text,
- file,
- queue,
- open,
- ...(cwd !== undefined && { cwd }),
- ...(reasoningEffort !== undefined && { reasoningEffort }),
- ...(workspaceId !== undefined && { workspaceId }),
- };
- }
+ return {
+ kind: "send",
+ server,
+ conversationId,
+ text,
+ file,
+ queue,
+ open,
+ ...(cwd !== undefined && { cwd }),
+ ...(reasoningEffort !== undefined && { reasoningEffort }),
+ ...(workspaceId !== undefined && { workspaceId }),
+ };
+ }
- // Chat mode: first arg is the model name
- const modelName = first;
- let text: string | undefined;
- let file: string | undefined;
- let cwd: string | undefined;
- let conversationId: string | undefined;
- let reasoningEffort: ReasoningEffort | undefined;
- let showReasoning = false;
- let open = false;
- let server = opts.defaultServer;
- let workspaceId: string | undefined;
+ // Chat mode: first arg is the model name
+ const modelName = first;
+ let text: string | undefined;
+ let file: string | undefined;
+ let cwd: string | undefined;
+ let conversationId: string | undefined;
+ let reasoningEffort: ReasoningEffort | undefined;
+ let showReasoning = false;
+ let open = false;
+ let server = opts.defaultServer;
+ let workspaceId: string | undefined;
- for (let i = 1; i < argv.length; i++) {
- const arg = argv[i] as string;
- switch (arg) {
- case "--text":
- if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" };
- text = argv[++i];
- break;
- case "--file":
- if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" };
- file = argv[++i];
- break;
- case "--cwd":
- if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" };
- cwd = argv[++i];
- break;
- case "--conversation":
- if (i + 1 >= argv.length)
- return { kind: "error", message: "--conversation requires a value" };
- conversationId = argv[++i];
- break;
- case "--server":
- if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" };
- server = argv[++i] as string;
- break;
- case "--show-reasoning":
- showReasoning = true;
- break;
- case "--open":
- open = true;
- 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;
- case "--workspace":
- case "-w":
- if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" };
- workspaceId = argv[++i];
- break;
- default:
- return { kind: "error", message: `Unknown flag: ${arg}` };
- }
- }
+ for (let i = 1; i < argv.length; i++) {
+ const arg = argv[i] as string;
+ switch (arg) {
+ case "--text":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--text requires a value" };
+ text = argv[++i];
+ break;
+ case "--file":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--file requires a value" };
+ file = argv[++i];
+ break;
+ case "--cwd":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--cwd requires a value" };
+ cwd = argv[++i];
+ break;
+ case "--conversation":
+ if (i + 1 >= argv.length)
+ return { kind: "error", message: "--conversation requires a value" };
+ conversationId = argv[++i];
+ break;
+ case "--server":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--server requires a value" };
+ server = argv[++i] as string;
+ break;
+ case "--show-reasoning":
+ showReasoning = true;
+ break;
+ case "--open":
+ open = true;
+ 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;
+ case "--workspace":
+ case "-w":
+ if (i + 1 >= argv.length) return { kind: "error", message: "--workspace requires a value" };
+ workspaceId = argv[++i];
+ break;
+ default:
+ return { kind: "error", message: `Unknown flag: ${arg}` };
+ }
+ }
- if (!text && !file) {
- return {
- kind: "error",
- message: "At least one of --text or --file is required for a chat command",
- };
- }
+ if (!text && !file) {
+ return {
+ kind: "error",
+ message: "At least one of --text or --file is required for a chat command",
+ };
+ }
- return {
- kind: "chat",
- server,
- modelName,
- text,
- file,
- cwd,
- conversationId,
- reasoningEffort,
- showReasoning,
- open,
- ...(workspaceId !== undefined && { workspaceId }),
- };
+ return {
+ kind: "chat",
+ server,
+ modelName,
+ text,
+ file,
+ cwd,
+ conversationId,
+ reasoningEffort,
+ showReasoning,
+ open,
+ ...(workspaceId !== undefined && { workspaceId }),
+ };
}
diff --git a/packages/cli/src/catalog.test.ts b/packages/cli/src/catalog.test.ts
index 3b32efc..a6d27f2 100644
--- a/packages/cli/src/catalog.test.ts
+++ b/packages/cli/src/catalog.test.ts
@@ -2,17 +2,17 @@ import { describe, expect, it } from "vitest";
import { formatCatalog } from "./catalog.js";
describe("formatCatalog", () => {
- it("formats a single model", () => {
- expect(formatCatalog({ models: ["openai/gpt-4"] })).toBe("openai/gpt-4");
- });
+ it("formats a single model", () => {
+ expect(formatCatalog({ models: ["openai/gpt-4"] })).toBe("openai/gpt-4");
+ });
- it("formats multiple models one per line", () => {
- expect(formatCatalog({ models: ["openai/gpt-4", "anthropic/claude-3", "local/llama"] })).toBe(
- "openai/gpt-4\nanthropic/claude-3\nlocal/llama",
- );
- });
+ it("formats multiple models one per line", () => {
+ expect(formatCatalog({ models: ["openai/gpt-4", "anthropic/claude-3", "local/llama"] })).toBe(
+ "openai/gpt-4\nanthropic/claude-3\nlocal/llama",
+ );
+ });
- it("returns empty string for empty models", () => {
- expect(formatCatalog({ models: [] })).toBe("");
- });
+ it("returns empty string for empty models", () => {
+ expect(formatCatalog({ models: [] })).toBe("");
+ });
});
diff --git a/packages/cli/src/catalog.ts b/packages/cli/src/catalog.ts
index a2b0780..51717d0 100644
--- a/packages/cli/src/catalog.ts
+++ b/packages/cli/src/catalog.ts
@@ -7,5 +7,5 @@
import type { ModelsResponse } from "@dispatch/transport-contract";
export function formatCatalog(r: ModelsResponse): string {
- return r.models.join("\n");
+ return r.models.join("\n");
}
diff --git a/packages/cli/src/http.test.ts b/packages/cli/src/http.test.ts
index ab39813..18de728 100644
--- a/packages/cli/src/http.test.ts
+++ b/packages/cli/src/http.test.ts
@@ -1,517 +1,517 @@
import type {
- AgentEvent,
- ChatRequest,
- ConversationListResponse,
+ AgentEvent,
+ ChatRequest,
+ ConversationListResponse,
} from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import {
- enqueueMessage,
- fetchConversations,
- fetchLastMessage,
- fetchModels,
- openConversation,
- resolveConversationId,
- streamChat,
+ 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`;
+ return `${events.map((e) => JSON.stringify(e)).join("\n")}\n`;
}
function makeFakeFetch(responseBody: string, headers?: Record<string, string>) {
- const fn = async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
- const encoder = new TextEncoder();
- const chunks = responseBody.split("|||");
- let i = 0;
- const stream = new ReadableStream<Uint8Array>({
- pull(controller) {
- if (i < chunks.length) {
- const chunk = chunks[i];
- if (chunk !== undefined) controller.enqueue(encoder.encode(chunk));
- i++;
- } else {
- controller.close();
- }
- },
- });
- return new Response(stream, {
- status: 200,
- headers: headers ?? {},
- });
- };
- return fn as unknown as typeof fetch;
+ const fn = async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
+ const encoder = new TextEncoder();
+ const chunks = responseBody.split("|||");
+ let i = 0;
+ const stream = new ReadableStream<Uint8Array>({
+ pull(controller) {
+ if (i < chunks.length) {
+ const chunk = chunks[i];
+ if (chunk !== undefined) controller.enqueue(encoder.encode(chunk));
+ i++;
+ } else {
+ controller.close();
+ }
+ },
+ });
+ return new Response(stream, {
+ status: 200,
+ headers: headers ?? {},
+ });
+ };
+ return fn as unknown as typeof fetch;
}
describe("streamChat", () => {
- it("parses NDJSON events and returns conversationId", async () => {
- const event1: AgentEvent = {
- type: "text-delta",
- conversationId: "c1",
- turnId: "t1",
- delta: "Hello",
- };
- const event2: AgentEvent = {
- type: "done",
- conversationId: "c1",
- turnId: "t1",
- reason: "completed",
- };
-
- const body = ndjsonLines(event1, event2);
- const fakeFetch = makeFakeFetch(body, { "X-Conversation-Id": "c1" });
-
- const { conversationId, events } = await streamChat(
- { fetchImpl: fakeFetch },
- {
- server: "http://localhost:24203",
- request: { message: "hi", model: "openai/gpt-4" },
- },
- );
-
- expect(conversationId).toBe("c1");
-
- const collected: AgentEvent[] = [];
- for await (const e of events) {
- collected.push(e);
- }
-
- expect(collected).toEqual([event1, event2]);
- });
-
- it("handles NDJSON split across chunks", async () => {
- const event1: AgentEvent = {
- type: "text-delta",
- conversationId: "c",
- turnId: "t",
- delta: "Hi",
- };
- const event2: AgentEvent = {
- type: "usage",
- conversationId: "c",
- turnId: "t",
- usage: { inputTokens: 10, outputTokens: 5 },
- };
-
- const fullNdjson = ndjsonLines(event1, event2);
- // Split mid-line: after 20 chars
- const mid = 20;
- const chunk1 = fullNdjson.slice(0, mid);
- const chunk2 = fullNdjson.slice(mid);
-
- const fakeFetch = makeFakeFetch(`${chunk1}|||${chunk2}`, {
- "X-Conversation-Id": "c",
- });
-
- const { events } = await streamChat(
- { fetchImpl: fakeFetch },
- {
- server: "http://localhost:24203",
- request: { message: "hi" },
- },
- );
-
- const collected: AgentEvent[] = [];
- for await (const e of events) {
- collected.push(e);
- }
-
- expect(collected).toEqual([event1, event2]);
- });
-
- it("throws on non-OK status", async () => {
- const fakeFetch = (async (): Promise<Response> =>
- new Response("not found", { status: 404 })) as unknown as typeof fetch;
-
- await expect(
- streamChat(
- { fetchImpl: fakeFetch },
- {
- server: "http://localhost:24203",
- request: { message: "hi" },
- },
- ),
- ).rejects.toThrow("POST /chat failed with status 404");
- });
-
- it("throws when response has no body", async () => {
- const fakeFetch = (async (): Promise<Response> =>
- new Response(null, { status: 200 })) as unknown as typeof fetch;
-
- await expect(
- streamChat(
- { fetchImpl: fakeFetch },
- {
- server: "http://localhost:24203",
- request: { message: "hi" },
- },
- ),
- ).rejects.toThrow("no body");
- });
-
- it("includes reasoningEffort in request body when set", async () => {
- let capturedBody: string | undefined;
- const doneEvent: AgentEvent = {
- type: "done",
- conversationId: "c",
- turnId: "t",
- reason: "completed",
- };
- const fakeFetch = async (
- _url: string | URL | Request,
- init?: RequestInit,
- ): Promise<Response> => {
- capturedBody = init?.body as string;
- const encoder = new TextEncoder();
- const stream = new ReadableStream<Uint8Array>({
- pull(controller) {
- controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`));
- controller.close();
- },
- });
- return new Response(stream, { status: 200 });
- };
-
- await streamChat(
- { fetchImpl: fakeFetch as unknown as typeof fetch },
- {
- server: "http://localhost:24203",
- request: { message: "hi", reasoningEffort: "xhigh" },
- },
- );
-
- expect(capturedBody).toBeDefined();
- const parsed = JSON.parse(capturedBody as string) as ChatRequest;
- expect(parsed.reasoningEffort).toBe("xhigh");
- });
-
- it("omits reasoningEffort from request body when not set", async () => {
- let capturedBody: string | undefined;
- const doneEvent: AgentEvent = {
- type: "done",
- conversationId: "c",
- turnId: "t",
- reason: "completed",
- };
- const fakeFetch = async (
- _url: string | URL | Request,
- init?: RequestInit,
- ): Promise<Response> => {
- capturedBody = init?.body as string;
- const encoder = new TextEncoder();
- const stream = new ReadableStream<Uint8Array>({
- pull(controller) {
- controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`));
- controller.close();
- },
- });
- return new Response(stream, { status: 200 });
- };
-
- await streamChat(
- { fetchImpl: fakeFetch as unknown as typeof fetch },
- {
- server: "http://localhost:24203",
- request: { message: "hi" },
- },
- );
-
- expect(capturedBody).toBeDefined();
- const parsed = JSON.parse(capturedBody as string) as ChatRequest;
- expect(parsed).not.toHaveProperty("reasoningEffort");
- });
+ it("parses NDJSON events and returns conversationId", async () => {
+ const event1: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c1",
+ turnId: "t1",
+ delta: "Hello",
+ };
+ const event2: AgentEvent = {
+ type: "done",
+ conversationId: "c1",
+ turnId: "t1",
+ reason: "completed",
+ };
+
+ const body = ndjsonLines(event1, event2);
+ const fakeFetch = makeFakeFetch(body, { "X-Conversation-Id": "c1" });
+
+ const { conversationId, events } = await streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi", model: "openai/gpt-4" },
+ },
+ );
+
+ expect(conversationId).toBe("c1");
+
+ const collected: AgentEvent[] = [];
+ for await (const e of events) {
+ collected.push(e);
+ }
+
+ expect(collected).toEqual([event1, event2]);
+ });
+
+ it("handles NDJSON split across chunks", async () => {
+ const event1: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "Hi",
+ };
+ const event2: AgentEvent = {
+ type: "usage",
+ conversationId: "c",
+ turnId: "t",
+ usage: { inputTokens: 10, outputTokens: 5 },
+ };
+
+ const fullNdjson = ndjsonLines(event1, event2);
+ // Split mid-line: after 20 chars
+ const mid = 20;
+ const chunk1 = fullNdjson.slice(0, mid);
+ const chunk2 = fullNdjson.slice(mid);
+
+ const fakeFetch = makeFakeFetch(`${chunk1}|||${chunk2}`, {
+ "X-Conversation-Id": "c",
+ });
+
+ const { events } = await streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ );
+
+ const collected: AgentEvent[] = [];
+ for await (const e of events) {
+ collected.push(e);
+ }
+
+ expect(collected).toEqual([event1, event2]);
+ });
+
+ it("throws on non-OK status", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response("not found", { status: 404 })) as unknown as typeof fetch;
+
+ await expect(
+ streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ ),
+ ).rejects.toThrow("POST /chat failed with status 404");
+ });
+
+ it("throws when response has no body", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response(null, { status: 200 })) as unknown as typeof fetch;
+
+ await expect(
+ streamChat(
+ { fetchImpl: fakeFetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ ),
+ ).rejects.toThrow("no body");
+ });
+
+ it("includes reasoningEffort in request body when set", async () => {
+ let capturedBody: string | undefined;
+ const doneEvent: AgentEvent = {
+ type: "done",
+ conversationId: "c",
+ turnId: "t",
+ reason: "completed",
+ };
+ const fakeFetch = async (
+ _url: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ capturedBody = init?.body as string;
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream<Uint8Array>({
+ pull(controller) {
+ controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`));
+ controller.close();
+ },
+ });
+ return new Response(stream, { status: 200 });
+ };
+
+ await streamChat(
+ { fetchImpl: fakeFetch as unknown as typeof fetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi", reasoningEffort: "xhigh" },
+ },
+ );
+
+ expect(capturedBody).toBeDefined();
+ const parsed = JSON.parse(capturedBody as string) as ChatRequest;
+ expect(parsed.reasoningEffort).toBe("xhigh");
+ });
+
+ it("omits reasoningEffort from request body when not set", async () => {
+ let capturedBody: string | undefined;
+ const doneEvent: AgentEvent = {
+ type: "done",
+ conversationId: "c",
+ turnId: "t",
+ reason: "completed",
+ };
+ const fakeFetch = async (
+ _url: string | URL | Request,
+ init?: RequestInit,
+ ): Promise<Response> => {
+ capturedBody = init?.body as string;
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream<Uint8Array>({
+ pull(controller) {
+ controller.enqueue(encoder.encode(`${JSON.stringify(doneEvent)}\n`));
+ controller.close();
+ },
+ });
+ return new Response(stream, { status: 200 });
+ };
+
+ await streamChat(
+ { fetchImpl: fakeFetch as unknown as typeof fetch },
+ {
+ server: "http://localhost:24203",
+ request: { message: "hi" },
+ },
+ );
+
+ expect(capturedBody).toBeDefined();
+ const parsed = JSON.parse(capturedBody as string) as ChatRequest;
+ expect(parsed).not.toHaveProperty("reasoningEffort");
+ });
});
describe("fetchModels", () => {
- it("returns ModelsResponse on success", async () => {
- const models = { models: ["openai/gpt-4", "anthropic/claude-3"] };
- const fakeFetch = (async (): Promise<Response> =>
- new Response(JSON.stringify(models), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- })) as unknown as typeof fetch;
-
- const result = await fetchModels(
- { fetchImpl: fakeFetch },
- { server: "http://localhost:24203" },
- );
- expect(result).toEqual(models);
- });
-
- it("throws on non-OK status", async () => {
- const fakeFetch = (async (): Promise<Response> =>
- new Response("server error", { status: 500 })) as unknown as typeof fetch;
-
- await expect(
- fetchModels({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }),
- ).rejects.toThrow("GET /models failed with status 500");
- });
+ it("returns ModelsResponse on success", async () => {
+ const models = { models: ["openai/gpt-4", "anthropic/claude-3"] };
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response(JSON.stringify(models), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })) as unknown as typeof fetch;
+
+ const result = await fetchModels(
+ { fetchImpl: fakeFetch },
+ { server: "http://localhost:24203" },
+ );
+ expect(result).toEqual(models);
+ });
+
+ it("throws on non-OK status", async () => {
+ const fakeFetch = (async (): Promise<Response> =>
+ new Response("server error", { status: 500 })) as unknown as typeof fetch;
+
+ await expect(
+ fetchModels({ fetchImpl: fakeFetch }, { server: "http://localhost:24203" }),
+ ).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,
- status: "idle",
- workspaceId: "default",
- },
- ],
- };
- 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+def");
- });
-
- it("appends ?workspaceId=<value> when a workspaceId 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", workspaceId: "proj" },
- );
- expect(calledUrl).toBe("http://localhost:24203/conversations?workspaceId=proj");
- });
-
- it("combines ?status= and ?workspaceId= when both are 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", status: "active,idle", workspaceId: "proj" },
- );
- expect(calledUrl).toBe(
- "http://localhost:24203/conversations?status=active%2Cidle&workspaceId=proj",
- );
- });
-
- 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");
- });
+ 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,
+ status: "idle",
+ workspaceId: "default",
+ },
+ ],
+ };
+ 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+def");
+ });
+
+ it("appends ?workspaceId=<value> when a workspaceId 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", workspaceId: "proj" },
+ );
+ expect(calledUrl).toBe("http://localhost:24203/conversations?workspaceId=proj");
+ });
+
+ it("combines ?status= and ?workspaceId= when both are 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", status: "active,idle", workspaceId: "proj" },
+ );
+ expect(calledUrl).toBe(
+ "http://localhost:24203/conversations?status=active%2Cidle&workspaceId=proj",
+ );
+ });
+
+ 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");
- });
+ 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");
- });
+ 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");
- });
+ 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,
- status: "idle",
- workspaceId: "default",
- },
- ],
- });
- 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,
- status: "idle",
- workspaceId: "default",
- },
- {
- id: "abcdef1234567890bbbbbbbbbbbbbbbb",
- title: "second",
- createdAt: 1,
- lastActivityAt: 3,
- status: "idle",
- workspaceId: "default",
- },
- ],
- });
- 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);
- });
+ 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,
+ status: "idle",
+ workspaceId: "default",
+ },
+ ],
+ });
+ 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,
+ status: "idle",
+ workspaceId: "default",
+ },
+ {
+ id: "abcdef1234567890bbbbbbbbbbbbbbbb",
+ title: "second",
+ createdAt: 1,
+ lastActivityAt: 3,
+ status: "idle",
+ workspaceId: "default",
+ },
+ ],
+ });
+ 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 e13842a..8092dfa 100644
--- a/packages/cli/src/http.ts
+++ b/packages/cli/src/http.ts
@@ -8,222 +8,222 @@
*/
import type {
- AgentEvent,
- ChatRequest,
- CompactResponse,
- ConversationListResponse,
- LastMessageResponse,
- ModelsResponse,
- OpenConversationResponse,
- QueueResponse,
+ AgentEvent,
+ ChatRequest,
+ CompactResponse,
+ ConversationListResponse,
+ LastMessageResponse,
+ ModelsResponse,
+ OpenConversationResponse,
+ QueueResponse,
} from "@dispatch/transport-contract";
import { splitNdjsonLines } from "./ndjson.js";
interface FetchDeps {
- readonly fetchImpl: typeof fetch;
+ readonly fetchImpl: typeof fetch;
}
interface StreamChatOpts {
- readonly server: string;
- readonly request: ChatRequest;
+ readonly server: string;
+ readonly request: ChatRequest;
}
export async function streamChat(
- deps: FetchDeps,
- opts: StreamChatOpts,
+ deps: FetchDeps,
+ opts: StreamChatOpts,
): Promise<{ conversationId: string | null; events: AsyncIterable<AgentEvent> }> {
- const url = `${opts.server}/chat`;
- const res = await deps.fetchImpl(url, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(opts.request),
- });
-
- if (!res.ok) {
- const body = await res.text();
- throw new Error(`POST /chat failed with status ${res.status}: ${body}`);
- }
-
- const conversationId = res.headers.get("X-Conversation-Id");
-
- if (!res.body) {
- throw new Error("POST /chat returned no body");
- }
-
- const events = readNdjsonStream(res.body);
- return { conversationId, events };
+ const url = `${opts.server}/chat`;
+ const res = await deps.fetchImpl(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(opts.request),
+ });
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`POST /chat failed with status ${res.status}: ${body}`);
+ }
+
+ const conversationId = res.headers.get("X-Conversation-Id");
+
+ if (!res.body) {
+ throw new Error("POST /chat returned no body");
+ }
+
+ const events = readNdjsonStream(res.body);
+ return { conversationId, events };
}
async function* readNdjsonStream(body: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent> {
- const reader = body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- const { lines, rest } = splitNdjsonLines(buffer);
- buffer = rest;
- for (const line of lines) {
- yield JSON.parse(line) as AgentEvent;
- }
- }
- if (buffer.length > 0) {
- yield JSON.parse(buffer) as AgentEvent;
- }
- } finally {
- reader.releaseLock();
- }
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const { lines, rest } = splitNdjsonLines(buffer);
+ buffer = rest;
+ for (const line of lines) {
+ yield JSON.parse(line) as AgentEvent;
+ }
+ }
+ if (buffer.length > 0) {
+ yield JSON.parse(buffer) as AgentEvent;
+ }
+ } finally {
+ reader.releaseLock();
+ }
}
interface FetchModelsOpts {
- readonly server: string;
+ readonly server: string;
}
export async function fetchModels(deps: FetchDeps, opts: FetchModelsOpts): Promise<ModelsResponse> {
- const url = `${opts.server}/models`;
- const res = await deps.fetchImpl(url);
+ const url = `${opts.server}/models`;
+ const res = await deps.fetchImpl(url);
- if (!res.ok) {
- const body = await res.text();
- throw new Error(`GET /models failed with status ${res.status}: ${body}`);
- }
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`GET /models failed with status ${res.status}: ${body}`);
+ }
- return (await res.json()) as ModelsResponse;
+ return (await res.json()) as ModelsResponse;
}
interface FetchConversationsOpts {
- readonly server: string;
- readonly query?: string;
- readonly status?: string;
- readonly workspaceId?: string;
+ readonly server: string;
+ readonly query?: string;
+ readonly status?: string;
+ readonly workspaceId?: string;
}
export async function fetchConversations(
- deps: FetchDeps,
- opts: FetchConversationsOpts,
+ deps: FetchDeps,
+ opts: FetchConversationsOpts,
): Promise<ConversationListResponse> {
- const params = new URLSearchParams();
- if (opts.query !== undefined) params.set("q", opts.query);
- if (opts.status !== undefined) params.set("status", opts.status);
- if (opts.workspaceId !== undefined) params.set("workspaceId", opts.workspaceId);
- const qs = params.toString();
- const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${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;
+ const params = new URLSearchParams();
+ if (opts.query !== undefined) params.set("q", opts.query);
+ if (opts.status !== undefined) params.set("status", opts.status);
+ if (opts.workspaceId !== undefined) params.set("workspaceId", opts.workspaceId);
+ const qs = params.toString();
+ const url = qs.length > 0 ? `${opts.server}/conversations?${qs}` : `${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;
+ readonly server: string;
+ readonly conversationId: string;
}
export async function fetchLastMessage(
- deps: FetchDeps,
- opts: FetchLastMessageOpts,
+ deps: FetchDeps,
+ opts: FetchLastMessageOpts,
): Promise<LastMessageResponse> {
- const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/last`;
- const res = await deps.fetchImpl(url);
+ 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}`);
- }
+ 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;
+ return (await res.json()) as LastMessageResponse;
}
interface EnqueueMessageOpts {
- readonly server: string;
- readonly conversationId: string;
- readonly text: string;
+ readonly server: string;
+ readonly conversationId: string;
+ readonly text: string;
}
export async function enqueueMessage(
- deps: FetchDeps,
- opts: EnqueueMessageOpts,
+ 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;
+ 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;
+ readonly server: string;
+ readonly conversationId: string;
}
export async function openConversation(
- deps: FetchDeps,
- opts: OpenConversationOpts,
+ deps: FetchDeps,
+ opts: OpenConversationOpts,
): Promise<OpenConversationResponse> {
- const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/open`;
- const res = await deps.fetchImpl(url, { method: "POST" });
+ 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}`);
- }
+ 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;
+ return (await res.json()) as OpenConversationResponse;
}
interface CompactConversationOpts {
- readonly server: string;
- readonly conversationId: string;
+ readonly server: string;
+ readonly conversationId: string;
}
export async function compactConversation(
- deps: FetchDeps,
- opts: CompactConversationOpts,
+ deps: FetchDeps,
+ opts: CompactConversationOpts,
): Promise<CompactResponse> {
- const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/compact`;
- const res = await deps.fetchImpl(url, { method: "POST" });
+ const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/compact`;
+ const res = await deps.fetchImpl(url, { method: "POST" });
- if (!res.ok) {
- const body = await res.text();
- throw new Error(`POST /conversations/:id/compact failed with status ${res.status}: ${body}`);
- }
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`POST /conversations/:id/compact failed with status ${res.status}: ${body}`);
+ }
- return (await res.json()) as CompactResponse;
+ return (await res.json()) as CompactResponse;
}
interface StopTurnOpts {
- readonly server: string;
- readonly conversationId: string;
+ readonly server: string;
+ readonly conversationId: string;
}
export async function stopTurn(
- deps: FetchDeps,
- opts: StopTurnOpts,
+ deps: FetchDeps,
+ opts: StopTurnOpts,
): Promise<{ conversationId: string; abortedTurn: boolean }> {
- const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/stop`;
- const res = await deps.fetchImpl(url, { method: "POST" });
+ const url = `${opts.server}/conversations/${encodeURIComponent(opts.conversationId)}/stop`;
+ const res = await deps.fetchImpl(url, { method: "POST" });
- if (!res.ok) {
- const body = await res.text();
- throw new Error(`POST /conversations/:id/stop failed with status ${res.status}: ${body}`);
- }
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`POST /conversations/:id/stop failed with status ${res.status}: ${body}`);
+ }
- return (await res.json()) as { conversationId: string; abortedTurn: boolean };
+ return (await res.json()) as { conversationId: string; abortedTurn: boolean };
}
/**
@@ -233,8 +233,8 @@ export async function stopTurn(
export type ConversationIdResolution = string | { readonly error: string };
interface ResolveConversationIdOpts {
- readonly server: string;
- readonly shortId: string;
+ readonly server: string;
+ readonly shortId: string;
}
/**
@@ -244,28 +244,28 @@ interface ResolveConversationIdOpts {
* the candidate short ids + titles so the user can disambiguate.
*/
export async function resolveConversationId(
- deps: FetchDeps,
- opts: ResolveConversationIdOpts,
+ 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}`,
- };
+ 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 6f16547..23d9c9d 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -7,20 +7,20 @@
export { type ParsedCommand, parseArgs } from "./args.js";
export { formatCatalog } from "./catalog.js";
export {
- type ConversationIdResolution,
- enqueueMessage,
- fetchConversations,
- fetchLastMessage,
- fetchModels,
- openConversation,
- resolveConversationId,
- streamChat,
+ 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 {
- extractLastText,
- formatConversationList,
- formatRelativeTime,
- renderEvent,
+ extractLastText,
+ formatConversationList,
+ formatRelativeTime,
+ renderEvent,
} from "./render.js";
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index cba0de7..9fca347 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -9,15 +9,15 @@ import { readFile } from "node:fs/promises";
import { parseArgs } from "./args.js";
import { formatCatalog } from "./catalog.js";
import {
- compactConversation,
- enqueueMessage,
- fetchConversations,
- fetchLastMessage,
- fetchModels,
- openConversation,
- resolveConversationId,
- stopTurn,
- streamChat,
+ compactConversation,
+ enqueueMessage,
+ fetchConversations,
+ fetchLastMessage,
+ fetchModels,
+ openConversation,
+ resolveConversationId,
+ stopTurn,
+ streamChat,
} from "./http.js";
import { buildChatRequest, composeMessage } from "./message.js";
import { extractLastText, formatConversationList, renderEvent } from "./render.js";
@@ -36,214 +36,214 @@ const USAGE = `Usage:
Effort levels: low, medium, high (default), xhigh, max`;
async function main(): Promise<void> {
- const defaultServer = `http://localhost:${process.env.BACKEND_PORT ?? "24203"}`;
- const parsed = parseArgs(process.argv.slice(2), { defaultServer });
+ const defaultServer = `http://localhost:${process.env.BACKEND_PORT ?? "24203"}`;
+ const parsed = parseArgs(process.argv.slice(2), { defaultServer });
- switch (parsed.kind) {
- case "help":
- process.stdout.write(`${USAGE}\n`);
- process.exit(0);
- break;
- case "error":
- process.stderr.write(`Error: ${parsed.message}\n`);
- process.exit(1);
- break;
- case "models": {
- const result = await fetchModels({ fetchImpl: globalThis.fetch }, { server: parsed.server });
- process.stdout.write(`${formatCatalog(result)}\n`);
- break;
- }
- case "list": {
- const status = parsed.all ? undefined : (parsed.status ?? "active,idle");
- const result = await fetchConversations(
- { fetchImpl: globalThis.fetch },
- {
- server: parsed.server,
- ...(parsed.query !== undefined && { query: parsed.query }),
- ...(status !== undefined && { status }),
- ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }),
- },
- );
- 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 "compact": {
- 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 result = await compactConversation(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, conversationId: resolved },
- );
- process.stdout.write(
- `Compacted ${resolved}: ${result.messagesSummarized} messages summarized, ${result.messagesKept} kept.\n`,
- );
- break;
- }
- case "stop": {
- 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 result = await stopTurn(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, conversationId: resolved },
- );
- process.stdout.write(
- result.abortedTurn
- ? `Stopped generation for ${resolved}\n`
- : `No active generation for ${resolved}\n`,
- );
- break;
- }
- case "open": {
- 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);
- }
- await openConversation(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, conversationId: resolved },
- );
- process.stdout.write(`Signaled frontend to open ${resolved}\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;
+ switch (parsed.kind) {
+ case "help":
+ process.stdout.write(`${USAGE}\n`);
+ process.exit(0);
+ break;
+ case "error":
+ process.stderr.write(`Error: ${parsed.message}\n`);
+ process.exit(1);
+ break;
+ case "models": {
+ const result = await fetchModels({ fetchImpl: globalThis.fetch }, { server: parsed.server });
+ process.stdout.write(`${formatCatalog(result)}\n`);
+ break;
+ }
+ case "list": {
+ const status = parsed.all ? undefined : (parsed.status ?? "active,idle");
+ const result = await fetchConversations(
+ { fetchImpl: globalThis.fetch },
+ {
+ server: parsed.server,
+ ...(parsed.query !== undefined && { query: parsed.query }),
+ ...(status !== undefined && { status }),
+ ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }),
+ },
+ );
+ 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 "compact": {
+ 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 result = await compactConversation(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, conversationId: resolved },
+ );
+ process.stdout.write(
+ `Compacted ${resolved}: ${result.messagesSummarized} messages summarized, ${result.messagesKept} kept.\n`,
+ );
+ break;
+ }
+ case "stop": {
+ 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 result = await stopTurn(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, conversationId: resolved },
+ );
+ process.stdout.write(
+ result.abortedTurn
+ ? `Stopped generation for ${resolved}\n`
+ : `No active generation for ${resolved}\n`,
+ );
+ break;
+ }
+ case "open": {
+ 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);
+ }
+ await openConversation(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, conversationId: resolved },
+ );
+ process.stdout.write(`Signaled frontend to open ${resolved}\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.open) {
- await openConversation(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, conversationId },
- );
- process.stdout.write(`Signaled frontend to open ${conversationId}\n`);
- }
+ if (parsed.open) {
+ await openConversation(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, conversationId },
+ );
+ process.stdout.write(`Signaled frontend to open ${conversationId}\n`);
+ }
- let fileContent: string | undefined;
- if (parsed.file) {
- fileContent = await readFile(parsed.file, "utf-8");
- }
- const message = composeMessage({
- ...(parsed.text !== undefined && { text: parsed.text }),
- ...(parsed.file !== undefined && { file: parsed.file }),
- ...(fileContent !== undefined && { fileContent }),
- });
+ let fileContent: string | undefined;
+ if (parsed.file) {
+ fileContent = await readFile(parsed.file, "utf-8");
+ }
+ const message = composeMessage({
+ ...(parsed.text !== undefined && { text: parsed.text }),
+ ...(parsed.file !== undefined && { file: parsed.file }),
+ ...(fileContent !== undefined && { fileContent }),
+ });
- if (parsed.queue) {
- const queued = await enqueueMessage(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, conversationId, text: message },
- );
- const line = queued.startedTurn
- ? `Started turn for ${conversationId}`
- : `Queued to ${conversationId}`;
- process.stdout.write(`${line}\n`);
- } else {
- const request = {
- conversationId,
- message,
- ...(parsed.cwd !== undefined && { cwd: parsed.cwd }),
- ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }),
- ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }),
- };
- 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`);
- }
- break;
- }
- case "chat": {
- let fileContent: string | undefined;
- if (parsed.file) {
- fileContent = await readFile(parsed.file, "utf-8");
- }
+ if (parsed.queue) {
+ const queued = await enqueueMessage(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, conversationId, text: message },
+ );
+ const line = queued.startedTurn
+ ? `Started turn for ${conversationId}`
+ : `Queued to ${conversationId}`;
+ process.stdout.write(`${line}\n`);
+ } else {
+ const request = {
+ conversationId,
+ message,
+ ...(parsed.cwd !== undefined && { cwd: parsed.cwd }),
+ ...(parsed.reasoningEffort !== undefined && { reasoningEffort: parsed.reasoningEffort }),
+ ...(parsed.workspaceId !== undefined && { workspaceId: parsed.workspaceId }),
+ };
+ 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`);
+ }
+ break;
+ }
+ case "chat": {
+ let fileContent: string | undefined;
+ if (parsed.file) {
+ fileContent = await readFile(parsed.file, "utf-8");
+ }
- const cwd = parsed.cwd ?? process.cwd();
- const message = composeMessage({
- ...(parsed.text !== undefined && { text: parsed.text }),
- ...(parsed.file !== undefined && { file: parsed.file }),
- ...(fileContent !== undefined && { fileContent }),
- });
- const request = buildChatRequest(parsed, { cwd, message });
+ const cwd = parsed.cwd ?? process.cwd();
+ const message = composeMessage({
+ ...(parsed.text !== undefined && { text: parsed.text }),
+ ...(parsed.file !== undefined && { file: parsed.file }),
+ ...(fileContent !== undefined && { fileContent }),
+ });
+ const request = buildChatRequest(parsed, { cwd, message });
- const { conversationId, events } = await streamChat(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, request },
- );
+ const { conversationId, events } = await streamChat(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, request },
+ );
- if (conversationId && parsed.open) {
- await openConversation(
- { fetchImpl: globalThis.fetch },
- { server: parsed.server, conversationId },
- );
- process.stdout.write(`Signaled frontend to open ${conversationId}\n`);
- }
+ if (conversationId && parsed.open) {
+ await openConversation(
+ { fetchImpl: globalThis.fetch },
+ { server: parsed.server, conversationId },
+ );
+ process.stdout.write(`Signaled frontend to open ${conversationId}\n`);
+ }
- for await (const event of events) {
- const rendered = renderEvent(event, { showReasoning: parsed.showReasoning });
- if (rendered?.stdout) process.stdout.write(rendered.stdout);
- if (rendered?.stderr) process.stderr.write(rendered.stderr);
- }
+ for await (const event of events) {
+ const rendered = renderEvent(event, { showReasoning: parsed.showReasoning });
+ if (rendered?.stdout) process.stdout.write(rendered.stdout);
+ if (rendered?.stderr) process.stderr.write(rendered.stderr);
+ }
- if (conversationId) {
- process.stdout.write(`\n[conversation] ${conversationId}\n`);
- }
- break;
- }
- }
+ if (conversationId) {
+ process.stdout.write(`\n[conversation] ${conversationId}\n`);
+ }
+ break;
+ }
+ }
}
main().catch((err: unknown) => {
- process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
- process.exit(1);
+ process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
+ process.exit(1);
});
diff --git a/packages/cli/src/message.test.ts b/packages/cli/src/message.test.ts
index 440ec85..536d64f 100644
--- a/packages/cli/src/message.test.ts
+++ b/packages/cli/src/message.test.ts
@@ -3,145 +3,145 @@ import { parseArgs } from "./args.js";
import { buildChatRequest, composeMessage } from "./message.js";
describe("composeMessage", () => {
- it("returns text only", () => {
- expect(composeMessage({ text: "hello" })).toBe("hello");
- });
-
- it("returns file only with neutral label", () => {
- expect(composeMessage({ file: "foo.txt", fileContent: "contents" })).toBe(
- "Attached file (foo.txt):\ncontents",
- );
- });
-
- it("returns text + labeled file block", () => {
- expect(composeMessage({ text: "check this", file: "a.ts", fileContent: "const x = 1;" })).toBe(
- "check this\n\nAttached file (a.ts):\nconst x = 1;",
- );
- });
-
- it("handles missing fileContent gracefully", () => {
- expect(composeMessage({ file: "f.txt" })).toBe("Attached file (f.txt):\n");
- });
-
- it("uses basename for absolute paths", () => {
- expect(composeMessage({ file: "/tmp/opencode/demo/note.txt", fileContent: "hello" })).toBe(
- "Attached file (note.txt):\nhello",
- );
- });
-
- it("handles empty input", () => {
- expect(composeMessage({})).toBe("");
- });
+ it("returns text only", () => {
+ expect(composeMessage({ text: "hello" })).toBe("hello");
+ });
+
+ it("returns file only with neutral label", () => {
+ expect(composeMessage({ file: "foo.txt", fileContent: "contents" })).toBe(
+ "Attached file (foo.txt):\ncontents",
+ );
+ });
+
+ it("returns text + labeled file block", () => {
+ expect(composeMessage({ text: "check this", file: "a.ts", fileContent: "const x = 1;" })).toBe(
+ "check this\n\nAttached file (a.ts):\nconst x = 1;",
+ );
+ });
+
+ it("handles missing fileContent gracefully", () => {
+ expect(composeMessage({ file: "f.txt" })).toBe("Attached file (f.txt):\n");
+ });
+
+ it("uses basename for absolute paths", () => {
+ expect(composeMessage({ file: "/tmp/opencode/demo/note.txt", fileContent: "hello" })).toBe(
+ "Attached file (note.txt):\nhello",
+ );
+ });
+
+ it("handles empty input", () => {
+ expect(composeMessage({})).toBe("");
+ });
});
describe("buildChatRequest", () => {
- it("maps fields correctly", () => {
- const req = buildChatRequest(
- {
- modelName: "cred/model",
- text: "hi",
- showReasoning: false,
- },
- { cwd: "/work", message: "hi" },
- );
- expect(req).toEqual({
- message: "hi",
- model: "cred/model",
- cwd: "/work",
- });
- });
-
- it("includes conversationId when provided", () => {
- const req = buildChatRequest(
- {
- modelName: "m",
- text: "x",
- conversationId: "conv-123",
- showReasoning: false,
- },
- { cwd: "/work", message: "x" },
- );
- expect(req.conversationId).toBe("conv-123");
- });
-
- it("omits conversationId when not provided", () => {
- const req = buildChatRequest(
- { modelName: "m", text: "x", showReasoning: false },
- { cwd: "/work", message: "x" },
- );
- expect(req).not.toHaveProperty("conversationId");
- });
-
- it("uses explicit cwd over context cwd", () => {
- const req = buildChatRequest(
- { modelName: "m", text: "x", cwd: "/explicit", showReasoning: false },
- { cwd: "/default", message: "x" },
- );
- expect(req.cwd).toBe("/explicit");
- });
-
- it("includes reasoningEffort when provided", () => {
- const req = buildChatRequest(
- { modelName: "m", text: "x", reasoningEffort: "xhigh", showReasoning: false },
- { cwd: "/work", message: "x" },
- );
- expect(req.reasoningEffort).toBe("xhigh");
- });
-
- it("omits reasoningEffort when not provided", () => {
- const req = buildChatRequest(
- { modelName: "m", text: "x", showReasoning: false },
- { cwd: "/work", message: "x" },
- );
- expect(req).not.toHaveProperty("reasoningEffort");
- });
-
- it("includes workspaceId when provided", () => {
- const req = buildChatRequest(
- { modelName: "m", text: "x", workspaceId: "my-work", showReasoning: false },
- { cwd: "/work", message: "x" },
- );
- expect(req.workspaceId).toBe("my-work");
- });
-
- it("omits workspaceId when not provided", () => {
- const req = buildChatRequest(
- { modelName: "m", text: "x", showReasoning: false },
- { cwd: "/work", message: "x" },
- );
- expect(req).not.toHaveProperty("workspaceId");
- });
+ it("maps fields correctly", () => {
+ const req = buildChatRequest(
+ {
+ modelName: "cred/model",
+ text: "hi",
+ showReasoning: false,
+ },
+ { cwd: "/work", message: "hi" },
+ );
+ expect(req).toEqual({
+ message: "hi",
+ model: "cred/model",
+ cwd: "/work",
+ });
+ });
+
+ it("includes conversationId when provided", () => {
+ const req = buildChatRequest(
+ {
+ modelName: "m",
+ text: "x",
+ conversationId: "conv-123",
+ showReasoning: false,
+ },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req.conversationId).toBe("conv-123");
+ });
+
+ it("omits conversationId when not provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req).not.toHaveProperty("conversationId");
+ });
+
+ it("uses explicit cwd over context cwd", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", cwd: "/explicit", showReasoning: false },
+ { cwd: "/default", message: "x" },
+ );
+ expect(req.cwd).toBe("/explicit");
+ });
+
+ it("includes reasoningEffort when provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", reasoningEffort: "xhigh", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req.reasoningEffort).toBe("xhigh");
+ });
+
+ it("omits reasoningEffort when not provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req).not.toHaveProperty("reasoningEffort");
+ });
+
+ it("includes workspaceId when provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", workspaceId: "my-work", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req.workspaceId).toBe("my-work");
+ });
+
+ it("omits workspaceId when not provided", () => {
+ const req = buildChatRequest(
+ { modelName: "m", text: "x", showReasoning: false },
+ { cwd: "/work", message: "x" },
+ );
+ expect(req).not.toHaveProperty("workspaceId");
+ });
});
describe("workspace flag → ChatRequest", () => {
- const defaultServer = "http://localhost:24203";
-
- it("--workspace flag sets workspaceId on request", () => {
- const parsed = parseArgs(["my-model", "--text", "hi", "--workspace", "my-work"], {
- defaultServer,
- });
- expect(parsed.kind).toBe("chat");
- if (parsed.kind !== "chat") return;
- const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
- expect(req.workspaceId).toBe("my-work");
- });
-
- it("--workspace flag omitted sends no workspaceId", () => {
- const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer });
- expect(parsed.kind).toBe("chat");
- if (parsed.kind !== "chat") return;
- const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
- expect(req.workspaceId).toBeUndefined();
- expect(req).not.toHaveProperty("workspaceId");
- });
-
- it("-w shorthand sets workspaceId on request", () => {
- const parsed = parseArgs(["my-model", "--text", "hi", "-w", "shorthand"], {
- defaultServer,
- });
- expect(parsed.kind).toBe("chat");
- if (parsed.kind !== "chat") return;
- const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
- expect(req.workspaceId).toBe("shorthand");
- });
+ const defaultServer = "http://localhost:24203";
+
+ it("--workspace flag sets workspaceId on request", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi", "--workspace", "my-work"], {
+ defaultServer,
+ });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.workspaceId).toBe("my-work");
+ });
+
+ it("--workspace flag omitted sends no workspaceId", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi"], { defaultServer });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.workspaceId).toBeUndefined();
+ expect(req).not.toHaveProperty("workspaceId");
+ });
+
+ it("-w shorthand sets workspaceId on request", () => {
+ const parsed = parseArgs(["my-model", "--text", "hi", "-w", "shorthand"], {
+ defaultServer,
+ });
+ expect(parsed.kind).toBe("chat");
+ if (parsed.kind !== "chat") return;
+ const req = buildChatRequest(parsed, { cwd: "/work", message: "hi" });
+ expect(req.workspaceId).toBe("shorthand");
+ });
});
diff --git a/packages/cli/src/message.ts b/packages/cli/src/message.ts
index ec4d6d1..ddbec6b 100644
--- a/packages/cli/src/message.ts
+++ b/packages/cli/src/message.ts
@@ -8,52 +8,52 @@
import type { ChatRequest, ReasoningEffort } from "@dispatch/transport-contract";
interface ComposeInput {
- readonly text?: string;
- readonly file?: string;
- readonly fileContent?: string;
+ readonly text?: string;
+ readonly file?: string;
+ readonly fileContent?: string;
}
function basename(filePath: string): string {
- const segments = filePath.split("/");
- return segments[segments.length - 1] ?? filePath;
+ const segments = filePath.split("/");
+ return segments[segments.length - 1] ?? filePath;
}
export function composeMessage(input: ComposeInput): string {
- const { text, fileContent } = input;
- const file = input.file;
-
- if (text && file) {
- return `${text}\n\nAttached file (${basename(file)}):\n${fileContent ?? ""}`;
- }
- if (file) {
- return `Attached file (${basename(file)}):\n${fileContent ?? ""}`;
- }
- return text ?? "";
+ const { text, fileContent } = input;
+ const file = input.file;
+
+ if (text && file) {
+ return `${text}\n\nAttached file (${basename(file)}):\n${fileContent ?? ""}`;
+ }
+ if (file) {
+ return `Attached file (${basename(file)}):\n${fileContent ?? ""}`;
+ }
+ return text ?? "";
}
interface ChatCmd {
- readonly modelName: string;
- readonly text?: string | undefined;
- readonly file?: string | undefined;
- readonly cwd?: string | undefined;
- readonly conversationId?: string | undefined;
- readonly reasoningEffort?: ReasoningEffort | undefined;
- readonly workspaceId?: string | undefined;
- readonly showReasoning: boolean;
+ readonly modelName: string;
+ readonly text?: string | undefined;
+ readonly file?: string | undefined;
+ readonly cwd?: string | undefined;
+ readonly conversationId?: string | undefined;
+ readonly reasoningEffort?: ReasoningEffort | undefined;
+ readonly workspaceId?: string | undefined;
+ readonly showReasoning: boolean;
}
interface BuildCtx {
- readonly cwd: string;
- readonly message: string;
+ readonly cwd: string;
+ readonly message: string;
}
export function buildChatRequest(cmd: ChatCmd, ctx: BuildCtx): ChatRequest {
- return {
- message: ctx.message,
- model: cmd.modelName,
- ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }),
- ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }),
- ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }),
- ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }),
- };
+ return {
+ message: ctx.message,
+ model: cmd.modelName,
+ ...(cmd.conversationId !== undefined && { conversationId: cmd.conversationId }),
+ ...(cmd.cwd !== undefined ? { cwd: cmd.cwd } : { cwd: ctx.cwd }),
+ ...(cmd.reasoningEffort !== undefined && { reasoningEffort: cmd.reasoningEffort }),
+ ...(cmd.workspaceId !== undefined && { workspaceId: cmd.workspaceId }),
+ };
}
diff --git a/packages/cli/src/ndjson.test.ts b/packages/cli/src/ndjson.test.ts
index 8ed3bff..5c74dfb 100644
--- a/packages/cli/src/ndjson.test.ts
+++ b/packages/cli/src/ndjson.test.ts
@@ -2,47 +2,47 @@ import { describe, expect, it } from "vitest";
import { splitNdjsonLines } from "./ndjson.js";
describe("splitNdjsonLines", () => {
- it("splits complete lines", () => {
- const result = splitNdjsonLines('{"a":1}\n{"b":2}\n');
- expect(result.lines).toEqual(['{"a":1}', '{"b":2}']);
- expect(result.rest).toBe("");
- });
-
- it("keeps incomplete trailing line in rest", () => {
- const result = splitNdjsonLines('{"a":1}\n{"b":2');
- expect(result.lines).toEqual(['{"a":1}']);
- expect(result.rest).toBe('{"b":2');
- });
-
- it("returns empty lines array for single incomplete line", () => {
- const result = splitNdjsonLines('{"a":1');
- expect(result.lines).toEqual([]);
- expect(result.rest).toBe('{"a":1');
- });
-
- it("filters out empty lines", () => {
- const result = splitNdjsonLines('{"a":1}\n\n{"b":2}\n');
- expect(result.lines).toEqual(['{"a":1}', '{"b":2}']);
- expect(result.rest).toBe("");
- });
-
- it("handles a line split across two buffers", () => {
- const buf1 = '{"a":1}\n{"b":';
- const buf2 = '2}\n{"c":3}\n';
-
- const r1 = splitNdjsonLines(buf1);
- expect(r1.lines).toEqual(['{"a":1}']);
- expect(r1.rest).toBe('{"b":');
-
- const combined = r1.rest + buf2;
- const r2 = splitNdjsonLines(combined);
- expect(r2.lines).toEqual(['{"b":2}', '{"c":3}']);
- expect(r2.rest).toBe("");
- });
-
- it("handles empty buffer", () => {
- const result = splitNdjsonLines("");
- expect(result.lines).toEqual([]);
- expect(result.rest).toBe("");
- });
+ it("splits complete lines", () => {
+ const result = splitNdjsonLines('{"a":1}\n{"b":2}\n');
+ expect(result.lines).toEqual(['{"a":1}', '{"b":2}']);
+ expect(result.rest).toBe("");
+ });
+
+ it("keeps incomplete trailing line in rest", () => {
+ const result = splitNdjsonLines('{"a":1}\n{"b":2');
+ expect(result.lines).toEqual(['{"a":1}']);
+ expect(result.rest).toBe('{"b":2');
+ });
+
+ it("returns empty lines array for single incomplete line", () => {
+ const result = splitNdjsonLines('{"a":1');
+ expect(result.lines).toEqual([]);
+ expect(result.rest).toBe('{"a":1');
+ });
+
+ it("filters out empty lines", () => {
+ const result = splitNdjsonLines('{"a":1}\n\n{"b":2}\n');
+ expect(result.lines).toEqual(['{"a":1}', '{"b":2}']);
+ expect(result.rest).toBe("");
+ });
+
+ it("handles a line split across two buffers", () => {
+ const buf1 = '{"a":1}\n{"b":';
+ const buf2 = '2}\n{"c":3}\n';
+
+ const r1 = splitNdjsonLines(buf1);
+ expect(r1.lines).toEqual(['{"a":1}']);
+ expect(r1.rest).toBe('{"b":');
+
+ const combined = r1.rest + buf2;
+ const r2 = splitNdjsonLines(combined);
+ expect(r2.lines).toEqual(['{"b":2}', '{"c":3}']);
+ expect(r2.rest).toBe("");
+ });
+
+ it("handles empty buffer", () => {
+ const result = splitNdjsonLines("");
+ expect(result.lines).toEqual([]);
+ expect(result.rest).toBe("");
+ });
});
diff --git a/packages/cli/src/ndjson.ts b/packages/cli/src/ndjson.ts
index 57093c1..5902354 100644
--- a/packages/cli/src/ndjson.ts
+++ b/packages/cli/src/ndjson.ts
@@ -6,13 +6,13 @@
*/
export interface SplitResult {
- readonly lines: readonly string[];
- readonly rest: string;
+ readonly lines: readonly string[];
+ readonly rest: string;
}
export function splitNdjsonLines(buffer: string): SplitResult {
- const parts = buffer.split("\n");
- const rest = parts[parts.length - 1] ?? "";
- const lines = parts.slice(0, -1).filter((l) => l.length > 0);
- return { lines, rest };
+ const parts = buffer.split("\n");
+ const rest = parts[parts.length - 1] ?? "";
+ const lines = parts.slice(0, -1).filter((l) => l.length > 0);
+ return { lines, rest };
}
diff --git a/packages/cli/src/render.test.ts b/packages/cli/src/render.test.ts
index 1c92733..5e35a50 100644
--- a/packages/cli/src/render.test.ts
+++ b/packages/cli/src/render.test.ts
@@ -2,253 +2,253 @@ import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract"
import type { StepId } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import {
- extractLastText,
- formatConversationList,
- formatRelativeTime,
- renderEvent,
+ extractLastText,
+ formatConversationList,
+ formatRelativeTime,
+ renderEvent,
} from "./render.js";
describe("renderEvent", () => {
- const opts = { showReasoning: false };
- const optsReasoning = { showReasoning: true };
-
- it("renders text-delta as stdout", () => {
- const e: AgentEvent = {
- type: "text-delta",
- conversationId: "c",
- turnId: "t",
- delta: "hello",
- };
- expect(renderEvent(e, opts)).toEqual({ stdout: "hello" });
- });
-
- it("hides reasoning-delta by default", () => {
- const e: AgentEvent = {
- type: "reasoning-delta",
- conversationId: "c",
- turnId: "t",
- delta: "thinking...",
- };
- expect(renderEvent(e, opts)).toBeUndefined();
- });
-
- it("shows reasoning-delta when showReasoning is true", () => {
- const e: AgentEvent = {
- type: "reasoning-delta",
- conversationId: "c",
- turnId: "t",
- delta: "thinking...",
- };
- expect(renderEvent(e, optsReasoning)).toEqual({ stdout: "thinking..." });
- });
-
- it("renders tool-call with name and JSON input", () => {
- const e: AgentEvent = {
- type: "tool-call",
- conversationId: "c",
- turnId: "t",
- stepId: "t1#0" as StepId,
- toolCallId: "tc1",
- toolName: "read_file",
- input: { path: "/foo" },
- };
- const result = renderEvent(e, opts);
- expect(result?.stdout).toContain("[tool] read_file");
- expect(result?.stdout).toContain('"/foo"');
- });
-
- it("renders tool-output data as stdout", () => {
- const e: AgentEvent = {
- type: "tool-output",
- conversationId: "c",
- turnId: "t",
- toolCallId: "tc1",
- data: "some output",
- stream: "stdout",
- };
- expect(renderEvent(e, opts)).toEqual({ stdout: "some output" });
- });
-
- it("renders tool-result without error", () => {
- const e: AgentEvent = {
- type: "tool-result",
- conversationId: "c",
- turnId: "t",
- stepId: "t1#0" as StepId,
- toolCallId: "tc1",
- toolName: "read_file",
- content: "file contents",
- isError: false,
- };
- expect(renderEvent(e, opts)).toEqual({
- stdout: "[tool:read_file] file contents\n",
- });
- });
-
- it("renders tool-result with error flag", () => {
- const e: AgentEvent = {
- type: "tool-result",
- conversationId: "c",
- turnId: "t",
- stepId: "t1#0" as StepId,
- toolCallId: "tc1",
- toolName: "read_file",
- content: "not found",
- isError: true,
- };
- expect(renderEvent(e, opts)).toEqual({
- stdout: "[tool:read_file] ERROR not found\n",
- });
- });
-
- it("renders usage event", () => {
- const e: AgentEvent = {
- type: "usage",
- conversationId: "c",
- turnId: "t",
- usage: { inputTokens: 100, outputTokens: 50 },
- };
- expect(renderEvent(e, opts)).toEqual({
- stdout: "\n[usage] in=100 out=50\n",
- });
- });
-
- it("renders error event to stderr", () => {
- const e: AgentEvent = {
- type: "error",
- conversationId: "c",
- turnId: "t",
- message: "something went wrong",
- };
- expect(renderEvent(e, opts)).toEqual({
- stderr: "[error] something went wrong\n",
- });
- });
-
- it("returns undefined for status", () => {
- const e: AgentEvent = {
- type: "status",
- conversationId: "c",
- status: "running",
- };
- expect(renderEvent(e, opts)).toBeUndefined();
- });
-
- it("returns undefined for turn-start", () => {
- const e: AgentEvent = { type: "turn-start", conversationId: "c", turnId: "t" };
- expect(renderEvent(e, opts)).toBeUndefined();
- });
-
- it("returns undefined for turn-sealed", () => {
- const e: AgentEvent = { type: "turn-sealed", conversationId: "c", turnId: "t" };
- expect(renderEvent(e, opts)).toBeUndefined();
- });
-
- it("returns undefined for done", () => {
- const e: AgentEvent = {
- type: "done",
- conversationId: "c",
- turnId: "t",
- reason: "completed",
- };
- expect(renderEvent(e, opts)).toBeUndefined();
- });
+ const opts = { showReasoning: false };
+ const optsReasoning = { showReasoning: true };
+
+ it("renders text-delta as stdout", () => {
+ const e: AgentEvent = {
+ type: "text-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "hello",
+ };
+ expect(renderEvent(e, opts)).toEqual({ stdout: "hello" });
+ });
+
+ it("hides reasoning-delta by default", () => {
+ const e: AgentEvent = {
+ type: "reasoning-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "thinking...",
+ };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("shows reasoning-delta when showReasoning is true", () => {
+ const e: AgentEvent = {
+ type: "reasoning-delta",
+ conversationId: "c",
+ turnId: "t",
+ delta: "thinking...",
+ };
+ expect(renderEvent(e, optsReasoning)).toEqual({ stdout: "thinking..." });
+ });
+
+ it("renders tool-call with name and JSON input", () => {
+ const e: AgentEvent = {
+ type: "tool-call",
+ conversationId: "c",
+ turnId: "t",
+ stepId: "t1#0" as StepId,
+ toolCallId: "tc1",
+ toolName: "read_file",
+ input: { path: "/foo" },
+ };
+ const result = renderEvent(e, opts);
+ expect(result?.stdout).toContain("[tool] read_file");
+ expect(result?.stdout).toContain('"/foo"');
+ });
+
+ it("renders tool-output data as stdout", () => {
+ const e: AgentEvent = {
+ type: "tool-output",
+ conversationId: "c",
+ turnId: "t",
+ toolCallId: "tc1",
+ data: "some output",
+ stream: "stdout",
+ };
+ expect(renderEvent(e, opts)).toEqual({ stdout: "some output" });
+ });
+
+ it("renders tool-result without error", () => {
+ const e: AgentEvent = {
+ type: "tool-result",
+ conversationId: "c",
+ turnId: "t",
+ stepId: "t1#0" as StepId,
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "file contents",
+ isError: false,
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stdout: "[tool:read_file] file contents\n",
+ });
+ });
+
+ it("renders tool-result with error flag", () => {
+ const e: AgentEvent = {
+ type: "tool-result",
+ conversationId: "c",
+ turnId: "t",
+ stepId: "t1#0" as StepId,
+ toolCallId: "tc1",
+ toolName: "read_file",
+ content: "not found",
+ isError: true,
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stdout: "[tool:read_file] ERROR not found\n",
+ });
+ });
+
+ it("renders usage event", () => {
+ const e: AgentEvent = {
+ type: "usage",
+ conversationId: "c",
+ turnId: "t",
+ usage: { inputTokens: 100, outputTokens: 50 },
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stdout: "\n[usage] in=100 out=50\n",
+ });
+ });
+
+ it("renders error event to stderr", () => {
+ const e: AgentEvent = {
+ type: "error",
+ conversationId: "c",
+ turnId: "t",
+ message: "something went wrong",
+ };
+ expect(renderEvent(e, opts)).toEqual({
+ stderr: "[error] something went wrong\n",
+ });
+ });
+
+ it("returns undefined for status", () => {
+ const e: AgentEvent = {
+ type: "status",
+ conversationId: "c",
+ status: "running",
+ };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("returns undefined for turn-start", () => {
+ const e: AgentEvent = { type: "turn-start", conversationId: "c", turnId: "t" };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("returns undefined for turn-sealed", () => {
+ const e: AgentEvent = { type: "turn-sealed", conversationId: "c", turnId: "t" };
+ expect(renderEvent(e, opts)).toBeUndefined();
+ });
+
+ it("returns undefined for done", () => {
+ const e: AgentEvent = {
+ type: "done",
+ conversationId: "c",
+ turnId: "t",
+ reason: "completed",
+ };
+ 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("");
- });
+ 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
+ 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 '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 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 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 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");
- });
+ 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,
- status: "idle",
- workspaceId: "default",
- });
-
- 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",
- );
- });
+ 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,
+ status: "idle",
+ workspaceId: "default",
+ });
+
+ 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 9f25dd3..f512b7a 100644
--- a/packages/cli/src/render.ts
+++ b/packages/cli/src/render.ts
@@ -8,40 +8,40 @@
import type { AgentEvent, ConversationMeta } from "@dispatch/transport-contract";
interface RenderOpts {
- readonly showReasoning: boolean;
+ readonly showReasoning: boolean;
}
interface RenderOutput {
- readonly stdout?: string;
- readonly stderr?: string;
+ readonly stdout?: string;
+ readonly stderr?: string;
}
export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | undefined {
- switch (e.type) {
- case "text-delta":
- return { stdout: e.delta };
- case "reasoning-delta":
- return opts.showReasoning ? { stdout: e.delta } : undefined;
- case "tool-call":
- return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` };
- case "tool-output":
- return { stdout: e.data };
- case "tool-result":
- return {
- stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`,
- };
- case "usage":
- return {
- stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`,
- };
- case "error":
- return { stderr: `[error] ${e.message}\n` };
- case "status":
- case "turn-start":
- case "turn-sealed":
- case "done":
- return undefined;
- }
+ switch (e.type) {
+ case "text-delta":
+ return { stdout: e.delta };
+ case "reasoning-delta":
+ return opts.showReasoning ? { stdout: e.delta } : undefined;
+ case "tool-call":
+ return { stdout: `\n[tool] ${e.toolName} ${JSON.stringify(e.input)}\n` };
+ case "tool-output":
+ return { stdout: e.data };
+ case "tool-result":
+ return {
+ stdout: `[tool:${e.toolName}]${e.isError ? " ERROR" : ""} ${e.content}\n`,
+ };
+ case "usage":
+ return {
+ stdout: `\n[usage] in=${e.usage.inputTokens} out=${e.usage.outputTokens}\n`,
+ };
+ case "error":
+ return { stderr: `[error] ${e.message}\n` };
+ case "status":
+ case "turn-start":
+ case "turn-sealed":
+ case "done":
+ return undefined;
+ }
}
/**
@@ -50,13 +50,13 @@ export function renderEvent(e: AgentEvent, opts: RenderOpts): RenderOutput | und
* 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;
+ let text = "";
+ for (const e of events) {
+ if (e.type === "text-delta") {
+ text += e.delta;
+ }
+ }
+ return text;
}
/**
@@ -65,15 +65,15 @@ export function extractLastText(events: readonly AgentEvent[]): string {
* (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);
+ 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);
}
/**
@@ -82,11 +82,11 @@ export function formatRelativeTime(epochMs: number, now: number): string {
* string. `now` is injected for `formatRelativeTime` (pure + testable).
*/
export function formatConversationList(
- conversations: readonly ConversationMeta[],
- now: number,
+ 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");
+ if (conversations.length === 0) return "";
+ return conversations
+ .map((c) => `${c.id.slice(0, 8)} | ${c.title} | ${formatRelativeTime(c.lastActivityAt, now)}`)
+ .join("\n");
}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
index e011575..f8864ec 100644
--- a/packages/cli/tsconfig.json
+++ b/packages/cli/tsconfig.json
@@ -1,6 +1,6 @@
{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
- "include": ["src/**/*.ts"],
- "references": [{ "path": "../transport-contract" }]
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../transport-contract" }]
}