diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index d67c6c4..fe923b9 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -136,6 +136,7 @@ function sanitizeSdkOutput( export async function runClaudeWithSdk( promptPath: string, { sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions, + onMessage?: (message: SDKMessage) => void, ): Promise { // Create prompt configuration - may be a string or multi-block message const prompt = await createPromptConfig(promptPath, showFullOutput); @@ -161,9 +162,13 @@ export async function runClaudeWithSdk( for await (const message of query({ prompt, options: sdkOptions })) { messages.push(message); - const sanitized = sanitizeSdkOutput(message, showFullOutput); - if (sanitized) { - console.log(sanitized); + if (onMessage) { + onMessage(message); + } else { + const sanitized = sanitizeSdkOutput(message, showFullOutput); + if (sanitized) { + console.log(sanitized); + } } if (message.type === "result") { diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index b18b3f9..91fcf9b 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -1,6 +1,7 @@ import { runClaudeWithSdk } from "./run-claude-sdk"; import type { ClaudeRunResult } from "./run-claude-sdk"; import { parseSdkOptions } from "./parse-sdk-options"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; export type ClaudeOptions = { claudeArgs?: string; @@ -19,7 +20,8 @@ export type ClaudeOptions = { export async function runClaude( promptPath: string, options: ClaudeOptions, + onMessage?: (message: SDKMessage) => void, ): Promise { const parsedOptions = parseSdkOptions(options); - return runClaudeWithSdk(promptPath, parsedOptions); + return runClaudeWithSdk(promptPath, parsedOptions, onMessage); } diff --git a/src/ci-output-formatter.ts b/src/ci-output-formatter.ts new file mode 100644 index 0000000..755f0c5 --- /dev/null +++ b/src/ci-output-formatter.ts @@ -0,0 +1,303 @@ +/** + * Human-readable CI output formatter for Claude Code SDK messages. + * + * When show_full_output is true, replaces raw JSON dumps with concise, + * scannable log lines that show what Claude is doing in real time. + */ + +import type { + SDKMessage, + SDKSystemMessage, + SDKAssistantMessage, + SDKUserMessage, + SDKResultMessage, + SDKPermissionDenial, +} from "@anthropic-ai/claude-agent-sdk"; + +const MAX_LINE_LENGTH = 200; + +export function truncate( + text: string, + maxLen: number = MAX_LINE_LENGTH, +): string { + const oneLine = text.replace(/\n/g, " ").trim(); + if (oneLine.length <= maxLen) return oneLine; + return oneLine.slice(0, maxLen - 3) + "..."; +} + +function countLines(text: string): number { + if (!text || !text.trim()) return 0; + return text.split("\n").length; +} + +/** + * Format tool-specific parameters for concise display. + */ +export function formatToolParams( + toolName: string, + input: Record, +): string { + switch (toolName) { + case "Bash": { + const cmd = String(input.command ?? ""); + const desc = input.description ? ` — "${input.description}"` : ""; + return `${truncate(cmd, 120)}${desc}`; + } + case "Read": + case "Write": + case "Edit": + return String(input.file_path ?? ""); + case "NotebookEdit": + return String(input.notebook_path ?? ""); + case "Grep": { + const pattern = String(input.pattern ?? ""); + const path = input.path ? ` in ${input.path}` : ""; + return `"${pattern}"${path}`; + } + case "Glob": + return String(input.pattern ?? ""); + case "WebFetch": + return String(input.url ?? ""); + case "WebSearch": + return String(input.query ?? ""); + case "Task": { + const desc = input.description + ? ` — ${input.description}` + : input.prompt + ? ` — ${truncate(String(input.prompt), 80)}` + : ""; + return `${input.subagent_type ?? "agent"}${desc}`; + } + default: + // MCP tools and others — just show the name + return ""; + } +} + +/** + * Format a tool result (from user message tool_result content blocks). + */ +export function formatToolResult(content: unknown): string { + if (typeof content === "string") { + if (!content.trim()) return "(empty)"; + const lines = countLines(content); + if (lines === 1 && content.length <= MAX_LINE_LENGTH) { + return content.trim(); + } + const firstLine = truncate(content.split("\n")[0] ?? "", 150); + return `${firstLine} (${lines} lines)`; + } + + // Content blocks array (e.g., [{type: "tool_result", content: [{type: "text", text: "..."}]}]) + if (Array.isArray(content)) { + const texts = content + .filter( + (block: { type?: string }) => + block && typeof block === "object" && block.type === "text", + ) + .map((block: { text?: string }) => String(block.text ?? "")); + if (texts.length > 0) { + return formatToolResult(texts.join("\n")); + } + // Image or other content + return `(${content.length} content blocks)`; + } + + return "(complex result)"; +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) return `${seconds}s`; + return `${minutes}m${seconds.toString().padStart(2, "0")}s`; +} + +export function formatCost(usd: number): string { + if (usd < 0.01) return `$${usd.toFixed(4)}`; + return `$${usd.toFixed(2)}`; +} + +export function formatPermissionDenials( + denials: SDKPermissionDenial[], +): string { + if (denials.length === 0) return ""; + const tools = denials + .map((d) => { + const input = d.tool_input; + if (d.tool_name === "Bash" && input.command) { + const cmd = String(input.command).split(" ")[0]; + return `Bash(${cmd})`; + } + return d.tool_name; + }) + .join(", "); + const label = + denials.length === 1 + ? "1 permission denial" + : `${denials.length} permission denials`; + return ` — ⚠️ ${label}: ${tools}`; +} + +/** + * Creates a message handler that outputs human-readable CI logs. + * Returns an (SDKMessage) => void callback to pass to runClaudeWithSdk. + */ +export function createCiOutputFormatter(): (message: SDKMessage) => void { + // Track tool_use_id → tool name for correlating results back to their tool call + const toolUseMap = new Map(); + + return (message: SDKMessage): void => { + switch (message.type) { + case "system": { + if (message.subtype === "init") { + const init = message as SDKSystemMessage; + const toolCount = init.tools.length; + const mcpCount = init.mcp_servers.filter( + (s) => s.status === "connected", + ).length; + const mcpInfo = mcpCount > 0 ? `, ${mcpCount} MCP servers` : ""; + console.log( + `🚀 Claude Code v${init.claude_code_version} (${init.model}) — ${toolCount} tools${mcpInfo}`, + ); + } else if (message.subtype === "task_started") { + const task = message as { + subtype: "task_started"; + description: string; + }; + console.log(` 📋 Task started: ${truncate(task.description)}`); + } else if (message.subtype === "task_progress") { + const task = message as { + subtype: "task_progress"; + description: string; + usage: { tool_uses: number }; + last_tool_name?: string; + }; + const lastTool = task.last_tool_name + ? `, last: ${task.last_tool_name}` + : ""; + console.log( + ` 📋 Task progress: ${task.usage.tool_uses} tool uses${lastTool}`, + ); + } else if (message.subtype === "task_notification") { + const task = message as { + subtype: "task_notification"; + status: string; + summary: string; + }; + console.log( + ` 📋 Task ${task.status}: ${truncate(task.summary, 150)}`, + ); + } + // Skip status, compact_boundary, hook_* subtypes + break; + } + + case "assistant": { + const assistant = message as SDKAssistantMessage; + const content = assistant.message?.content; + if (!Array.isArray(content)) break; + + for (const block of content) { + if (block.type === "thinking" || block.type === "redacted_thinking") { + continue; + } + + if (block.type === "text" && block.text) { + const text = block.text.trim(); + if (text) { + console.log(`💬 ${truncate(text)}`); + } + } + + if (block.type === "tool_use") { + const toolBlock = block as { + type: "tool_use"; + id: string; + name: string; + input: Record; + }; + // Track tool_use_id → display label for result correlation + const params = formatToolParams(toolBlock.name, toolBlock.input); + const paramStr = params ? `: ${params}` : ""; + const displayLabel = `${toolBlock.name}${paramStr}`; + toolUseMap.set(toolBlock.id, displayLabel); + console.log(`🔧 ${displayLabel}`); + } + } + break; + } + + case "user": { + const user = message as SDKUserMessage; + const content = user.message?.content; + if (!Array.isArray(content)) break; + + for (const block of content as Array<{ + type: string; + content?: unknown; + is_error?: boolean; + tool_use_id?: string; + }>) { + if (block.type === "tool_result") { + const isError = block.is_error === true; + const resultContent = block.content; + // Look up which tool call this result belongs to + const toolLabel = block.tool_use_id + ? toolUseMap.get(block.tool_use_id) + : undefined; + const prefix = toolLabel ? `[${toolLabel}] ` : ""; + + if (isError) { + const errorText = + typeof resultContent === "string" + ? resultContent + : Array.isArray(resultContent) + ? resultContent + .filter( + (b: { type?: string }) => + b && typeof b === "object" && b.type === "text", + ) + .map((b: { text?: string }) => String(b.text ?? "")) + .join(" ") + : "unknown error"; + console.log(` ↳ ❌ ${prefix}${truncate(errorText)}`); + } else if (resultContent) { + console.log(` ↳ ${prefix}${formatToolResult(resultContent)}`); + } + } + } + break; + } + + case "result": { + const result = message as SDKResultMessage; + const duration = formatDuration(result.duration_ms); + const cost = formatCost(result.total_cost_usd); + const turns = result.num_turns; + const denials = formatPermissionDenials(result.permission_denials); + + if (result.subtype === "success") { + console.log( + `✅ Done — ${turns} turns, ${cost}, ${duration}${denials}`, + ); + } else { + const errors = + "errors" in result && result.errors + ? `: ${result.errors.join(", ")}` + : ""; + console.log( + `❌ Failed (${result.subtype}) — ${turns} turns, ${cost}, ${duration}${errors}${denials}`, + ); + } + break; + } + + // Skip stream_event, tool_progress, tool_use_summary, rate_limit, etc. + default: + break; + } + }; +} diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index cfc95d2..ba2ed1d 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -34,6 +34,7 @@ import { installPlugins } from "../../base-action/src/install-plugins"; import { preparePrompt } from "../../base-action/src/prepare-prompt"; import { runClaude } from "../../base-action/src/run-claude"; import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk"; +import { createCiOutputFormatter } from "../ci-output-formatter"; /** * Install Claude Code CLI, handling retry logic and custom executable paths. @@ -243,15 +244,24 @@ async function run() { promptFile, }); - const claudeResult: ClaudeRunResult = await runClaude(promptConfig.path, { - claudeArgs: prepareResult.claudeArgs, - disallowedTools: process.env.DISALLOWED_TOOLS || undefined, - appendSystemPrompt: process.env.APPEND_SYSTEM_PROMPT, - model: process.env.ANTHROPIC_MODEL, - pathToClaudeCodeExecutable: - process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, - showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT, - }); + const showFullOutput = + process.env.INPUT_SHOW_FULL_OUTPUT === "true" || + process.env.ACTIONS_STEP_DEBUG === "true"; + const onMessage = showFullOutput ? createCiOutputFormatter() : undefined; + + const claudeResult: ClaudeRunResult = await runClaude( + promptConfig.path, + { + claudeArgs: prepareResult.claudeArgs, + disallowedTools: process.env.DISALLOWED_TOOLS || undefined, + appendSystemPrompt: process.env.APPEND_SYSTEM_PROMPT, + model: process.env.ANTHROPIC_MODEL, + pathToClaudeCodeExecutable: + process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, + showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT, + }, + onMessage, + ); claudeSuccess = claudeResult.conclusion === "success"; executionFile = claudeResult.executionFile; diff --git a/test/ci-output-formatter.test.ts b/test/ci-output-formatter.test.ts new file mode 100644 index 0000000..60883a7 --- /dev/null +++ b/test/ci-output-formatter.test.ts @@ -0,0 +1,741 @@ +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); + }); + }); +});