import { expect, test, describe, beforeEach, afterEach } from "bun:test"; import { createCiOutputFormatter, truncate, formatToolParams, formatToolResult, formatDuration, formatCost, formatPermissionDenials, } from "../src/ci-output-formatter"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; // Capture console.log output let logged: string[]; const originalLog = console.log; beforeEach(() => { logged = []; console.log = (...args: unknown[]) => { logged.push(args.map(String).join(" ")); }; }); afterEach(() => { console.log = originalLog; }); // --- Helper factories for SDK messages --- function makeSystemInit( overrides: Partial<{ claude_code_version: string; model: string; tools: string[]; mcp_servers: { name: string; status: string }[]; }> = {}, ): SDKMessage { return { type: "system", subtype: "init", claude_code_version: overrides.claude_code_version ?? "2.1.59", model: overrides.model ?? "claude-sonnet-4-6", tools: overrides.tools ?? ["Read", "Write", "Edit", "Bash", "Grep"], mcp_servers: overrides.mcp_servers ?? [], apiKeySource: "api_key", betas: [], cwd: "/tmp", permissionMode: "default", slash_commands: [], output_style: "text", skills: [], plugins: [], uuid: "00000000-0000-0000-0000-000000000001" as `${string}-${string}-${string}-${string}-${string}`, session_id: "test-session", } as unknown as SDKMessage; } function makeAssistantMessage(content: unknown[]): SDKMessage { return { type: "assistant", message: { content, id: "msg_test", type: "message", role: "assistant", model: "claude-sonnet-4-6", stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, }, parent_tool_use_id: null, uuid: "00000000-0000-0000-0000-000000000002" as `${string}-${string}-${string}-${string}-${string}`, session_id: "test-session", } as unknown as SDKMessage; } function makeUserToolResult( results: Array<{ tool_use_id: string; content: unknown; is_error?: boolean; }>, ): SDKMessage { return { type: "user", message: { role: "user", content: results.map((r) => ({ type: "tool_result", tool_use_id: r.tool_use_id, content: r.content, is_error: r.is_error ?? false, })), }, parent_tool_use_id: null, session_id: "test-session", } as unknown as SDKMessage; } function makeResultSuccess( overrides: Partial<{ num_turns: number; total_cost_usd: number; duration_ms: number; permission_denials: Array<{ tool_name: string; tool_use_id: string; tool_input: Record; }>; }> = {}, ): SDKMessage { return { type: "result", subtype: "success", is_error: false, num_turns: overrides.num_turns ?? 10, total_cost_usd: overrides.total_cost_usd ?? 0.42, duration_ms: overrides.duration_ms ?? 65000, duration_api_ms: 60000, stop_reason: "end_turn", result: "done", usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, modelUsage: {}, permission_denials: overrides.permission_denials ?? [], uuid: "00000000-0000-0000-0000-000000000099" as `${string}-${string}-${string}-${string}-${string}`, session_id: "test-session", } as unknown as SDKMessage; } function makeResultError( errors: string[], overrides: Partial<{ subtype: string; num_turns: number; total_cost_usd: number; duration_ms: number; permission_denials: Array<{ tool_name: string; tool_use_id: string; tool_input: Record; }>; }> = {}, ): SDKMessage { return { type: "result", subtype: overrides.subtype ?? "error_during_execution", is_error: true, num_turns: overrides.num_turns ?? 5, total_cost_usd: overrides.total_cost_usd ?? 0.15, duration_ms: overrides.duration_ms ?? 30000, duration_api_ms: 25000, stop_reason: null, errors, usage: { input_tokens: 500, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, }, modelUsage: {}, permission_denials: overrides.permission_denials ?? [], uuid: "00000000-0000-0000-0000-000000000099" as `${string}-${string}-${string}-${string}-${string}`, session_id: "test-session", } as unknown as SDKMessage; } // --- Unit tests for helper functions --- describe("truncate", () => { test("returns short text unchanged", () => { expect(truncate("hello world")).toBe("hello world"); }); test("truncates long text with ellipsis", () => { const long = "a".repeat(250); const result = truncate(long); expect(result.length).toBe(200); expect(result.endsWith("...")).toBe(true); }); test("collapses newlines to spaces", () => { expect(truncate("line1\nline2\nline3")).toBe("line1 line2 line3"); }); test("trims whitespace", () => { expect(truncate(" hello ")).toBe("hello"); }); test("respects custom maxLen", () => { const result = truncate("a".repeat(50), 20); expect(result.length).toBe(20); expect(result.endsWith("...")).toBe(true); }); }); describe("formatToolParams", () => { test("Bash: shows command and description", () => { const result = formatToolParams("Bash", { command: "git log --oneline -10", description: "Show recent git history", }); expect(result).toBe('git log --oneline -10 — "Show recent git history"'); }); test("Bash: shows command only when no description", () => { const result = formatToolParams("Bash", { command: "ls -la" }); expect(result).toBe("ls -la"); }); test("Read/Write/Edit: shows file_path", () => { for (const tool of ["Read", "Write", "Edit"]) { const result = formatToolParams(tool, { file_path: "/src/index.ts", }); expect(result).toBe("/src/index.ts"); } }); test("Grep: shows pattern and path", () => { const result = formatToolParams("Grep", { pattern: "TODO", path: "src/", }); expect(result).toBe('"TODO" in src/'); }); test("Grep: shows pattern only when no path", () => { const result = formatToolParams("Grep", { pattern: "TODO" }); expect(result).toBe('"TODO"'); }); test("Glob: shows pattern", () => { expect(formatToolParams("Glob", { pattern: "**/*.ts" })).toBe("**/*.ts"); }); test("WebFetch: shows url", () => { expect(formatToolParams("WebFetch", { url: "https://example.com" })).toBe( "https://example.com", ); }); test("WebSearch: shows query", () => { expect(formatToolParams("WebSearch", { query: "bun test" })).toBe( "bun test", ); }); test("Task: shows subagent_type and description", () => { const result = formatToolParams("Task", { subagent_type: "Explore", description: "Find auth code", }); expect(result).toBe("Explore — Find auth code"); }); test("unknown tool: returns empty string", () => { expect(formatToolParams("mcp__github__get_issue", {})).toBe(""); }); }); describe("formatToolResult", () => { test("short string: returned as-is", () => { expect(formatToolResult("file contents here")).toBe("file contents here"); }); test("multiline string: first line + count", () => { const result = formatToolResult("line1\nline2\nline3"); expect(result).toContain("line1"); expect(result).toContain("(3 lines)"); }); test("empty string: shows (empty)", () => { expect(formatToolResult("")).toBe("(empty)"); }); test("whitespace-only string: shows (empty)", () => { expect(formatToolResult(" \n \n ")).toBe("(empty)"); }); test("content blocks array with text", () => { const result = formatToolResult([{ type: "text", text: "hello world" }]); expect(result).toBe("hello world"); }); test("content blocks array without text", () => { const result = formatToolResult([{ type: "image", source: {} }]); expect(result).toBe("(1 content blocks)"); }); test("non-string, non-array: shows complex result", () => { expect(formatToolResult(42)).toBe("(complex result)"); expect(formatToolResult(null)).toBe("(complex result)"); }); }); describe("formatDuration", () => { test("milliseconds for < 1s", () => { expect(formatDuration(500)).toBe("500ms"); }); test("seconds for < 1m", () => { expect(formatDuration(45000)).toBe("45s"); }); test("minutes and seconds for >= 1m", () => { expect(formatDuration(65000)).toBe("1m05s"); }); test("large durations", () => { expect(formatDuration(455000)).toBe("7m35s"); }); }); describe("formatCost", () => { test("small costs show 4 decimals", () => { expect(formatCost(0.0012)).toBe("$0.0012"); }); test("larger costs show 2 decimals", () => { expect(formatCost(1.65)).toBe("$1.65"); }); test("boundary at 0.01", () => { expect(formatCost(0.01)).toBe("$0.01"); }); }); describe("formatPermissionDenials", () => { test("empty array returns empty string", () => { expect(formatPermissionDenials([])).toBe(""); }); test("single denial", () => { const result = formatPermissionDenials([ { tool_name: "Bash", tool_use_id: "id1", tool_input: { command: "git push origin main" }, }, ]); expect(result).toContain("1 permission denial"); expect(result).toContain("Bash(git)"); }); test("multiple denials with consistent grammar", () => { const result = formatPermissionDenials([ { tool_name: "Bash", tool_use_id: "id1", tool_input: { command: "bun test" }, }, { tool_name: "Write", tool_use_id: "id2", tool_input: {}, }, ]); expect(result).toContain("2 permission denials"); expect(result).toContain("Bash(bun)"); expect(result).toContain("Write"); }); test("non-Bash tools show tool name only", () => { const result = formatPermissionDenials([ { tool_name: "Edit", tool_use_id: "id1", tool_input: { file_path: "/etc/passwd" }, }, ]); expect(result).toContain("Edit"); expect(result).not.toContain("file_path"); }); }); // --- Integration tests for createCiOutputFormatter --- describe("createCiOutputFormatter", () => { describe("system init", () => { test("formats init message with version, model, tools", () => { const formatter = createCiOutputFormatter(); formatter(makeSystemInit()); expect(logged[0]).toContain("Claude Code v2.1.59"); expect(logged[0]).toContain("claude-sonnet-4-6"); expect(logged[0]).toContain("5 tools"); }); test("includes MCP server count when connected", () => { const formatter = createCiOutputFormatter(); formatter( makeSystemInit({ mcp_servers: [ { name: "github_comment", status: "connected" }, { name: "github_ci", status: "connected" }, { name: "failing", status: "error" }, ], }), ); expect(logged[0]).toContain("2 MCP servers"); }); test("omits MCP info when none connected", () => { const formatter = createCiOutputFormatter(); formatter(makeSystemInit({ mcp_servers: [] })); expect(logged[0]).not.toContain("MCP"); }); }); describe("assistant messages", () => { test("formats text content", () => { const formatter = createCiOutputFormatter(); formatter( makeAssistantMessage([ { type: "text", text: "I'll help you fix this bug." }, ]), ); expect(logged[0]).toBe("💬 I'll help you fix this bug."); }); test("skips thinking blocks", () => { const formatter = createCiOutputFormatter(); formatter( makeAssistantMessage([ { type: "thinking", thinking: "Let me think about this..." }, { type: "text", text: "Here's my answer." }, ]), ); expect(logged).toHaveLength(1); expect(logged[0]).toContain("Here's my answer."); }); test("formats tool_use with params", () => { const formatter = createCiOutputFormatter(); formatter( makeAssistantMessage([ { type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "/src/index.ts" }, }, ]), ); expect(logged[0]).toBe("🔧 Read: /src/index.ts"); }); test("formats tool_use without params (MCP)", () => { const formatter = createCiOutputFormatter(); formatter( makeAssistantMessage([ { type: "tool_use", id: "tu_1", name: "mcp__github_comment__update_claude_comment", input: { body: "done" }, }, ]), ); expect(logged[0]).toBe("🔧 mcp__github_comment__update_claude_comment"); }); test("handles multiple content blocks in one message", () => { const formatter = createCiOutputFormatter(); formatter( makeAssistantMessage([ { type: "text", text: "Reading files..." }, { type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "a.ts" }, }, { type: "tool_use", id: "tu_2", name: "Read", input: { file_path: "b.ts" }, }, ]), ); expect(logged).toHaveLength(3); expect(logged[0]).toContain("Reading files..."); expect(logged[1]).toContain("Read: a.ts"); expect(logged[2]).toContain("Read: b.ts"); }); }); describe("tool results (user messages)", () => { test("formats successful result", () => { const formatter = createCiOutputFormatter(); formatter( makeUserToolResult([ { tool_use_id: "tu_1", content: "file contents here" }, ]), ); expect(logged[0]).toBe(" ↳ file contents here"); }); test("formats error result", () => { const formatter = createCiOutputFormatter(); formatter( makeUserToolResult([ { tool_use_id: "tu_1", content: "File does not exist", is_error: true, }, ]), ); expect(logged[0]).toContain("↳ ❌"); expect(logged[0]).toContain("File does not exist"); }); test("skips empty content", () => { const formatter = createCiOutputFormatter(); formatter(makeUserToolResult([{ tool_use_id: "tu_1", content: null }])); expect(logged).toHaveLength(0); }); }); describe("tool result correlation (issue #1)", () => { test("correlates results back to tool calls for parallel tools", () => { const formatter = createCiOutputFormatter(); // Assistant sends parallel tool calls formatter( makeAssistantMessage([ { type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "src/a.ts" }, }, { type: "tool_use", id: "tu_2", name: "Read", input: { file_path: "src/b.ts" }, }, ]), ); // User message with results formatter( makeUserToolResult([ { tool_use_id: "tu_1", content: "contents of a" }, { tool_use_id: "tu_2", content: "contents of b" }, ]), ); // Results should include tool labels expect(logged[2]).toContain("[Read: src/a.ts]"); expect(logged[2]).toContain("contents of a"); expect(logged[3]).toContain("[Read: src/b.ts]"); expect(logged[3]).toContain("contents of b"); }); test("omits correlation prefix for single tool calls", () => { const formatter = createCiOutputFormatter(); formatter( makeAssistantMessage([ { type: "tool_use", id: "tu_1", name: "Read", input: { file_path: "src/a.ts" }, }, ]), ); formatter( makeUserToolResult([{ tool_use_id: "tu_1", content: "file contents" }]), ); // Single result still has prefix since the map tracks it expect(logged[1]).toContain("[Read: src/a.ts]"); }); }); describe("subagent messages (issue #2)", () => { test("formats task_started", () => { const formatter = createCiOutputFormatter(); formatter({ type: "system", subtype: "task_started", task_id: "task_1", description: "Find auth implementation", uuid: "00000000-0000-0000-0000-000000000010", session_id: "test", } as unknown as SDKMessage); expect(logged[0]).toContain("Task started"); expect(logged[0]).toContain("Find auth implementation"); }); test("formats task_progress", () => { const formatter = createCiOutputFormatter(); formatter({ type: "system", subtype: "task_progress", task_id: "task_1", description: "searching", usage: { total_tokens: 5000, tool_uses: 8, duration_ms: 15000 }, last_tool_name: "Grep", uuid: "00000000-0000-0000-0000-000000000011", session_id: "test", } as unknown as SDKMessage); expect(logged[0]).toContain("8 tool uses"); expect(logged[0]).toContain("last: Grep"); }); test("formats task_progress without last_tool_name", () => { const formatter = createCiOutputFormatter(); formatter({ type: "system", subtype: "task_progress", task_id: "task_1", description: "starting", usage: { total_tokens: 100, tool_uses: 0, duration_ms: 1000 }, uuid: "00000000-0000-0000-0000-000000000011", session_id: "test", } as unknown as SDKMessage); expect(logged[0]).toContain("0 tool uses"); expect(logged[0]).not.toContain("last:"); }); test("formats task_notification", () => { const formatter = createCiOutputFormatter(); formatter({ type: "system", subtype: "task_notification", task_id: "task_1", status: "completed", output_file: "/tmp/output", summary: "Found 3 auth-related files in src/auth/", uuid: "00000000-0000-0000-0000-000000000012", session_id: "test", } as unknown as SDKMessage); expect(logged[0]).toContain("Task completed"); expect(logged[0]).toContain("Found 3 auth-related files"); }); }); describe("result messages", () => { test("formats success result", () => { const formatter = createCiOutputFormatter(); formatter( makeResultSuccess({ num_turns: 46, total_cost_usd: 1.65, duration_ms: 455000, }), ); expect(logged[0]).toBe("✅ Done — 46 turns, $1.65, 7m35s"); }); test("formats error result", () => { const formatter = createCiOutputFormatter(); formatter( makeResultError(["API timeout", "retry failed"], { num_turns: 5, total_cost_usd: 0.15, duration_ms: 30000, }), ); expect(logged[0]).toContain("❌ Failed"); expect(logged[0]).toContain("error_during_execution"); expect(logged[0]).toContain("API timeout, retry failed"); }); test("includes permission denials in result", () => { const formatter = createCiOutputFormatter(); formatter( makeResultSuccess({ num_turns: 10, total_cost_usd: 0.42, duration_ms: 65000, permission_denials: [ { tool_name: "Bash", tool_use_id: "id1", tool_input: { command: "bun test" }, }, ], }), ); expect(logged[0]).toContain("1 permission denial"); expect(logged[0]).toContain("Bash(bun)"); }); }); describe("ignored message types", () => { test("skips stream_event messages", () => { const formatter = createCiOutputFormatter(); formatter({ type: "stream_event", event: {}, parent_tool_use_id: null, uuid: "00000000-0000-0000-0000-000000000020", session_id: "test", } as unknown as SDKMessage); expect(logged).toHaveLength(0); }); test("skips tool_progress messages", () => { const formatter = createCiOutputFormatter(); formatter({ type: "tool_progress", tool_use_id: "tu_1", tool_name: "Bash", parent_tool_use_id: null, elapsed_time_seconds: 5, uuid: "00000000-0000-0000-0000-000000000021", session_id: "test", } as unknown as SDKMessage); expect(logged).toHaveLength(0); }); test("skips system status messages", () => { const formatter = createCiOutputFormatter(); formatter({ type: "system", subtype: "status", status: "ready", uuid: "00000000-0000-0000-0000-000000000022", session_id: "test", } as unknown as SDKMessage); expect(logged).toHaveLength(0); }); }); });