diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
| commit | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch) | |
| tree | 2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/cli/src | |
| parent | 63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff) | |
| parent | 727c98c9dae516a2070eb950410314380a20c974 (diff) | |
| download | dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip | |
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/args.test.ts | 956 | ||||
| -rw-r--r-- | packages/cli/src/args.ts | 700 | ||||
| -rw-r--r-- | packages/cli/src/catalog.test.ts | 22 | ||||
| -rw-r--r-- | packages/cli/src/catalog.ts | 2 | ||||
| -rw-r--r-- | packages/cli/src/http.test.ts | 970 | ||||
| -rw-r--r-- | packages/cli/src/http.ts | 338 | ||||
| -rw-r--r-- | packages/cli/src/index.ts | 24 | ||||
| -rw-r--r-- | packages/cli/src/main.ts | 414 | ||||
| -rw-r--r-- | packages/cli/src/message.test.ts | 270 | ||||
| -rw-r--r-- | packages/cli/src/message.ts | 66 | ||||
| -rw-r--r-- | packages/cli/src/ndjson.test.ts | 86 | ||||
| -rw-r--r-- | packages/cli/src/ndjson.ts | 12 | ||||
| -rw-r--r-- | packages/cli/src/render.test.ts | 464 | ||||
| -rw-r--r-- | packages/cli/src/render.ts | 100 |
14 files changed, 2212 insertions, 2212 deletions
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"); } |
