diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d62057c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Test fixtures should not be formatted to preserve exact output matching +test/fixtures/ \ No newline at end of file diff --git a/action.yml b/action.yml index 8856cfc..ca5a7e9 100644 --- a/action.yml +++ b/action.yml @@ -191,10 +191,18 @@ runs: if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' shell: bash run: | - echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY - echo '```json' >> $GITHUB_STEP_SUMMARY - cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY - echo '```' >> $GITHUB_STEP_SUMMARY + # Try to format the turns, but if it fails, dump the raw JSON + if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then + echo "Successfully formatted Claude Code report" + else + echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi - name: Revoke app token if: always() && inputs.github_token == '' diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts new file mode 100755 index 0000000..d136810 --- /dev/null +++ b/src/entrypoints/format-turns.ts @@ -0,0 +1,461 @@ +#!/usr/bin/env bun + +import { readFileSync, existsSync } from "fs"; +import { exit } from "process"; + +export interface ToolUse { + type: string; + name?: string; + input?: Record; + id?: string; +} + +export interface ToolResult { + type: string; + tool_use_id?: string; + content?: any; + is_error?: boolean; +} + +export interface ContentItem { + type: string; + text?: string; + tool_use_id?: string; + content?: any; + is_error?: boolean; + name?: string; + input?: Record; + id?: string; +} + +export interface Message { + content: ContentItem[]; + usage?: { + input_tokens?: number; + output_tokens?: number; + }; +} + +export interface Turn { + type: string; + subtype?: string; + message?: Message; + tools?: any[]; + cost_usd?: number; + duration_ms?: number; + result?: string; +} + +export interface GroupedContent { + type: string; + tools_count?: number; + data?: Turn; + text_parts?: string[]; + tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[]; + usage?: Record; +} + +export function detectContentType(content: any): string { + const contentStr = String(content).trim(); + + // Check for JSON + if (contentStr.startsWith("{") && contentStr.endsWith("}")) { + try { + JSON.parse(contentStr); + return "json"; + } catch { + // Fall through + } + } + + if (contentStr.startsWith("[") && contentStr.endsWith("]")) { + try { + JSON.parse(contentStr); + return "json"; + } catch { + // Fall through + } + } + + // Check for code-like content + const codeKeywords = [ + "def ", + "class ", + "import ", + "from ", + "function ", + "const ", + "let ", + "var ", + ]; + if (codeKeywords.some((keyword) => contentStr.includes(keyword))) { + if ( + contentStr.includes("def ") || + contentStr.includes("import ") || + contentStr.includes("from ") + ) { + return "python"; + } else if ( + ["function ", "const ", "let ", "var ", "=>"].some((js) => + contentStr.includes(js), + ) + ) { + return "javascript"; + } else { + return "python"; // default for code + } + } + + // Check for shell/bash output + const shellIndicators = ["ls -", "cd ", "mkdir ", "rm ", "$ ", "# "]; + if ( + contentStr.startsWith("/") || + contentStr.includes("Error:") || + contentStr.startsWith("total ") || + shellIndicators.some((indicator) => contentStr.includes(indicator)) + ) { + return "bash"; + } + + // Check for diff format + if ( + contentStr.startsWith("@@") || + contentStr.includes("+++ ") || + contentStr.includes("--- ") + ) { + return "diff"; + } + + // Check for HTML/XML + if (contentStr.startsWith("<") && contentStr.endsWith(">")) { + return "html"; + } + + // Check for markdown + const mdIndicators = ["# ", "## ", "### ", "- ", "* ", "```"]; + if (mdIndicators.some((indicator) => contentStr.includes(indicator))) { + return "markdown"; + } + + // Default to plain text + return "text"; +} + +export function formatResultContent(content: any): string { + if (!content) { + return "*(No output)*\n\n"; + } + + let contentStr: string; + + // Check if content is a list with "type": "text" structure + try { + let parsedContent: any; + if (typeof content === "string") { + parsedContent = JSON.parse(content); + } else { + parsedContent = content; + } + + if ( + Array.isArray(parsedContent) && + parsedContent.length > 0 && + typeof parsedContent[0] === "object" && + parsedContent[0]?.type === "text" + ) { + // Extract the text field from the first item + contentStr = parsedContent[0]?.text || ""; + } else { + contentStr = String(content).trim(); + } + } catch { + contentStr = String(content).trim(); + } + + // Truncate very long results + if (contentStr.length > 3000) { + contentStr = contentStr.substring(0, 2997) + "..."; + } + + // Detect content type + const contentType = detectContentType(contentStr); + + // Handle JSON content specially - pretty print it + if (contentType === "json") { + try { + // Try to parse and pretty print JSON + const parsed = JSON.parse(contentStr); + contentStr = JSON.stringify(parsed, null, 2); + } catch { + // Keep original if parsing fails + } + } + + // Format with appropriate syntax highlighting + if ( + contentType === "text" && + contentStr.length < 100 && + !contentStr.includes("\n") + ) { + // Short text results don't need code blocks + return `**→** ${contentStr}\n\n`; + } else { + return `**Result:**\n\`\`\`${contentType}\n${contentStr}\n\`\`\`\n\n`; + } +} + +export function formatToolWithResult( + toolUse: ToolUse, + toolResult?: ToolResult, +): string { + const toolName = toolUse.name || "unknown_tool"; + const toolInput = toolUse.input || {}; + + let result = `### 🔧 \`${toolName}\`\n\n`; + + // Add parameters if they exist and are not empty + if (Object.keys(toolInput).length > 0) { + result += "**Parameters:**\n```json\n"; + result += JSON.stringify(toolInput, null, 2); + result += "\n```\n\n"; + } + + // Add result if available + if (toolResult) { + const content = toolResult.content || ""; + const isError = toolResult.is_error || false; + + if (isError) { + result += `❌ **Error:** \`${content}\`\n\n`; + } else { + result += formatResultContent(content); + } + } + + return result; +} + +export function groupTurnsNaturally(data: Turn[]): GroupedContent[] { + const groupedContent: GroupedContent[] = []; + const toolResultsMap = new Map(); + + // First pass: collect all tool results by tool_use_id + for (const turn of data) { + if (turn.type === "user") { + const content = turn.message?.content || []; + for (const item of content) { + if (item.type === "tool_result" && item.tool_use_id) { + toolResultsMap.set(item.tool_use_id, { + type: item.type, + tool_use_id: item.tool_use_id, + content: item.content, + is_error: item.is_error, + }); + } + } + } + } + + // Second pass: process turns and group naturally + for (const turn of data) { + const turnType = turn.type || "unknown"; + + if (turnType === "system") { + const subtype = turn.subtype || ""; + if (subtype === "init") { + const tools = turn.tools || []; + groupedContent.push({ + type: "system_init", + tools_count: tools.length, + }); + } else { + groupedContent.push({ + type: "system_other", + data: turn, + }); + } + } else if (turnType === "assistant") { + const message = turn.message || { content: [] }; + const content = message.content || []; + const usage = message.usage || {}; + + // Process content items + const textParts: string[] = []; + const toolCalls: { tool_use: ToolUse; tool_result?: ToolResult }[] = []; + + for (const item of content) { + const itemType = item.type || ""; + + if (itemType === "text") { + textParts.push(item.text || ""); + } else if (itemType === "tool_use") { + const toolUseId = item.id; + const toolResult = toolUseId + ? toolResultsMap.get(toolUseId) + : undefined; + toolCalls.push({ + tool_use: { + type: item.type, + name: item.name, + input: item.input, + id: item.id, + }, + tool_result: toolResult, + }); + } + } + + if (textParts.length > 0 || toolCalls.length > 0) { + groupedContent.push({ + type: "assistant_action", + text_parts: textParts, + tool_calls: toolCalls, + usage: usage, + }); + } + } else if (turnType === "user") { + // Handle user messages that aren't tool results + const message = turn.message || { content: [] }; + const content = message.content || []; + const textParts: string[] = []; + + for (const item of content) { + if (item.type === "text") { + textParts.push(item.text || ""); + } + } + + if (textParts.length > 0) { + groupedContent.push({ + type: "user_message", + text_parts: textParts, + }); + } + } else if (turnType === "result") { + groupedContent.push({ + type: "final_result", + data: turn, + }); + } + } + + return groupedContent; +} + +export function formatGroupedContent(groupedContent: GroupedContent[]): string { + let markdown = "## Claude Code Report\n\n"; + + for (const item of groupedContent) { + const itemType = item.type; + + if (itemType === "system_init") { + markdown += `## 🚀 System Initialization\n\n**Available Tools:** ${item.tools_count} tools loaded\n\n---\n\n`; + } else if (itemType === "system_other") { + markdown += `## ⚙️ System Message\n\n${JSON.stringify(item.data, null, 2)}\n\n---\n\n`; + } else if (itemType === "assistant_action") { + // Add text content first (if any) - no header needed + for (const text of item.text_parts || []) { + if (text.trim()) { + markdown += `${text}\n\n`; + } + } + + // Add tool calls with their results + for (const toolCall of item.tool_calls || []) { + markdown += formatToolWithResult( + toolCall.tool_use, + toolCall.tool_result, + ); + } + + // Add usage info if available + const usage = item.usage || {}; + if (Object.keys(usage).length > 0) { + const inputTokens = usage.input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + markdown += `*Token usage: ${inputTokens} input, ${outputTokens} output*\n\n`; + } + + // Only add separator if this section had content + if ( + (item.text_parts && item.text_parts.length > 0) || + (item.tool_calls && item.tool_calls.length > 0) + ) { + markdown += "---\n\n"; + } + } else if (itemType === "user_message") { + markdown += "## 👤 User\n\n"; + for (const text of item.text_parts || []) { + if (text.trim()) { + markdown += `${text}\n\n`; + } + } + markdown += "---\n\n"; + } else if (itemType === "final_result") { + const data = item.data || {}; + const cost = (data as any).cost_usd || 0; + const duration = (data as any).duration_ms || 0; + const resultText = (data as any).result || ""; + + markdown += "## ✅ Final Result\n\n"; + if (resultText) { + markdown += `${resultText}\n\n`; + } + markdown += `**Cost:** $${cost.toFixed(4)} | **Duration:** ${(duration / 1000).toFixed(1)}s\n\n`; + } + } + + return markdown; +} + +export function formatTurnsFromData(data: Turn[]): string { + // Group turns naturally + const groupedContent = groupTurnsNaturally(data); + + // Generate markdown + const markdown = formatGroupedContent(groupedContent); + + return markdown; +} + +function main(): void { + // Get the JSON file path from command line arguments + const args = process.argv.slice(2); + if (args.length === 0) { + console.error("Usage: format-turns.ts "); + exit(1); + } + + const jsonFile = args[0]; + if (!jsonFile) { + console.error("Error: No JSON file provided"); + exit(1); + } + + if (!existsSync(jsonFile)) { + console.error(`Error: ${jsonFile} not found`); + exit(1); + } + + try { + // Read the JSON file + const fileContent = readFileSync(jsonFile, "utf-8"); + const data: Turn[] = JSON.parse(fileContent); + + // Group turns naturally + const groupedContent = groupTurnsNaturally(data); + + // Generate markdown + const markdown = formatGroupedContent(groupedContent); + + // Print to stdout (so it can be captured by shell) + console.log(markdown); + } catch (error) { + console.error(`Error processing file: ${error}`); + exit(1); + } +} + +if (import.meta.main) { + main(); +} diff --git a/test/fixtures/sample-turns-expected-output.md b/test/fixtures/sample-turns-expected-output.md new file mode 100644 index 0000000..82c506d --- /dev/null +++ b/test/fixtures/sample-turns-expected-output.md @@ -0,0 +1,95 @@ +## Claude Code Report + +## 🚀 System Initialization + +**Available Tools:** 8 tools loaded + +--- + +I'll help you with this task. Let me start by examining the file to understand what needs to be changed. + +### 🔧 `Read` + +**Parameters:** +```json +{ + "file_path": "/path/to/sample/file.py" +} +``` + +**Result:** +```python +def example_function(): + print("Debug message") # This should be removed + return "Hello World" + +if __name__ == "__main__": + result = example_function() + print(result) +``` + +*Token usage: 100 input, 75 output* + +--- + +I can see the debug print statement that needs to be removed. Let me fix this by editing the file. + +### 🔧 `Edit` + +**Parameters:** +```json +{ + "file_path": "/path/to/sample/file.py", + "old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"", + "new_string": "def example_function():\n return \"Hello World\"" +} +``` + +**→** File successfully edited. The debug print statement has been removed. + +*Token usage: 200 input, 50 output* + +--- + +Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change. + +### 🔧 `mcp__github__add_pull_request_review_comment` + +**Parameters:** +```json +{ + "owner": "example-org", + "repo": "example-repo", + "pull_number": 123, + "body": "Removed debug print statement as requested.", + "commit_id": "abc123def456", + "path": "sample/file.py", + "line": 2 +} +``` + +**→** Successfully posted review comment to PR #123 + +*Token usage: 150 input, 80 output* + +--- + +Great! I've successfully completed the requested task: + +1. ✅ Located the debug print statement in the file +2. ✅ Removed the print statement while preserving the function logic +3. ✅ Added a review comment documenting the change + +The debug print statement has been removed as requested by the reviewers. + +*Token usage: 180 input, 60 output* + +--- + +## ✅ Final Result + +Successfully removed debug print statement from file and added review comment to document the change. + +**Cost:** $0.0347 | **Duration:** 18.8s + + diff --git a/test/fixtures/sample-turns.json b/test/fixtures/sample-turns.json new file mode 100644 index 0000000..2690675 --- /dev/null +++ b/test/fixtures/sample-turns.json @@ -0,0 +1,196 @@ +[ + { + "type": "system", + "subtype": "init", + "session_id": "sample-session-id", + "tools": [ + "Task", + "Bash", + "Read", + "Edit", + "Write", + "mcp__github__get_file_contents", + "mcp__github__create_or_update_file", + "mcp__github__add_pull_request_review_comment" + ], + "mcp_servers": [ + { + "name": "github", + "status": "connected" + } + ] + }, + { + "type": "assistant", + "message": { + "id": "msg_sample123", + "type": "message", + "role": "assistant", + "model": "claude-test-model", + "content": [ + { + "type": "text", + "text": "I'll help you with this task. Let me start by examining the file to understand what needs to be changed." + }, + { + "type": "tool_use", + "id": "tool_call_1", + "name": "Read", + "input": { + "file_path": "/path/to/sample/file.py" + } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 100, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 50, + "output_tokens": 75 + } + }, + "session_id": "sample-session-id" + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_call_1", + "content": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"\n\nif __name__ == \"__main__\":\n result = example_function()\n print(result)", + "is_error": false + } + ] + } + }, + { + "type": "assistant", + "message": { + "id": "msg_sample124", + "type": "message", + "role": "assistant", + "model": "claude-test-model", + "content": [ + { + "type": "text", + "text": "I can see the debug print statement that needs to be removed. Let me fix this by editing the file." + }, + { + "type": "tool_use", + "id": "tool_call_2", + "name": "Edit", + "input": { + "file_path": "/path/to/sample/file.py", + "old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"", + "new_string": "def example_function():\n return \"Hello World\"" + } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 200, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 100, + "output_tokens": 50 + } + }, + "session_id": "sample-session-id" + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_call_2", + "content": "File successfully edited. The debug print statement has been removed.", + "is_error": false + } + ] + } + }, + { + "type": "assistant", + "message": { + "id": "msg_sample125", + "type": "message", + "role": "assistant", + "model": "claude-test-model", + "content": [ + { + "type": "text", + "text": "Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change." + }, + { + "type": "tool_use", + "id": "tool_call_3", + "name": "mcp__github__add_pull_request_review_comment", + "input": { + "owner": "example-org", + "repo": "example-repo", + "pull_number": 123, + "body": "Removed debug print statement as requested.", + "commit_id": "abc123def456", + "path": "sample/file.py", + "line": 2 + } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 150, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 75, + "output_tokens": 80 + } + }, + "session_id": "sample-session-id" + }, + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_call_3", + "content": "Successfully posted review comment to PR #123", + "is_error": false + } + ] + } + }, + { + "type": "assistant", + "message": { + "id": "msg_sample126", + "type": "message", + "role": "assistant", + "model": "claude-test-model", + "content": [ + { + "type": "text", + "text": "Great! I've successfully completed the requested task:\n\n1. ✅ Located the debug print statement in the file\n2. ✅ Removed the print statement while preserving the function logic\n3. ✅ Added a review comment documenting the change\n\nThe debug print statement has been removed as requested by the reviewers." + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 180, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 90, + "output_tokens": 60 + } + }, + "session_id": "sample-session-id" + }, + { + "type": "result", + "cost_usd": 0.0347, + "duration_ms": 18750, + "result": "Successfully removed debug print statement from file and added review comment to document the change." + } +] diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts new file mode 100644 index 0000000..bb26f2e --- /dev/null +++ b/test/format-turns.test.ts @@ -0,0 +1,439 @@ +import { expect, test, describe } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { + formatTurnsFromData, + groupTurnsNaturally, + formatGroupedContent, + detectContentType, + formatResultContent, + formatToolWithResult, + type Turn, + type ToolUse, + type ToolResult, +} from "../src/entrypoints/format-turns"; + +describe("detectContentType", () => { + test("detects JSON objects", () => { + expect(detectContentType('{"key": "value"}')).toBe("json"); + expect(detectContentType('{"number": 42}')).toBe("json"); + }); + + test("detects JSON arrays", () => { + expect(detectContentType("[1, 2, 3]")).toBe("json"); + expect(detectContentType('["a", "b"]')).toBe("json"); + }); + + test("detects Python code", () => { + expect(detectContentType("def hello():\n pass")).toBe("python"); + expect(detectContentType("import os")).toBe("python"); + expect(detectContentType("from math import pi")).toBe("python"); + }); + + test("detects JavaScript code", () => { + expect(detectContentType("function test() {}")).toBe("javascript"); + expect(detectContentType("const x = 5")).toBe("javascript"); + expect(detectContentType("let y = 10")).toBe("javascript"); + expect(detectContentType("const fn = () => console.log()")).toBe( + "javascript", + ); + }); + + test("detects bash/shell content", () => { + expect(detectContentType("/usr/bin/test")).toBe("bash"); + expect(detectContentType("Error: command not found")).toBe("bash"); + expect(detectContentType("ls -la")).toBe("bash"); + expect(detectContentType("$ echo hello")).toBe("bash"); + }); + + test("detects diff format", () => { + expect(detectContentType("@@ -1,3 +1,3 @@")).toBe("diff"); + expect(detectContentType("+++ file.txt")).toBe("diff"); + expect(detectContentType("--- file.txt")).toBe("diff"); + }); + + test("detects HTML/XML", () => { + expect(detectContentType("
hello
")).toBe("html"); + expect(detectContentType("content")).toBe("html"); + }); + + test("detects markdown", () => { + expect(detectContentType("- List item")).toBe("markdown"); + expect(detectContentType("* List item")).toBe("markdown"); + expect(detectContentType("```code```")).toBe("markdown"); + }); + + test("defaults to text", () => { + expect(detectContentType("plain text")).toBe("text"); + expect(detectContentType("just some words")).toBe("text"); + }); +}); + +describe("formatResultContent", () => { + test("handles empty content", () => { + expect(formatResultContent("")).toBe("*(No output)*\n\n"); + expect(formatResultContent(null)).toBe("*(No output)*\n\n"); + expect(formatResultContent(undefined)).toBe("*(No output)*\n\n"); + }); + + test("formats short text without code blocks", () => { + const result = formatResultContent("success"); + expect(result).toBe("**→** success\n\n"); + }); + + test("formats long text with code blocks", () => { + const longText = + "This is a longer piece of text that should be formatted in a code block because it exceeds the short text threshold"; + const result = formatResultContent(longText); + expect(result).toContain("**Result:**"); + expect(result).toContain("```text"); + expect(result).toContain(longText); + }); + + test("pretty prints JSON content", () => { + const jsonContent = '{"key": "value", "number": 42}'; + const result = formatResultContent(jsonContent); + expect(result).toContain("```json"); + expect(result).toContain('"key": "value"'); + expect(result).toContain('"number": 42'); + }); + + test("truncates very long content", () => { + const veryLongContent = "A".repeat(4000); + const result = formatResultContent(veryLongContent); + expect(result).toContain("..."); + // Should not contain the full long content + expect(result.length).toBeLessThan(veryLongContent.length); + }); + + test("handles type:text structure", () => { + const structuredContent = [{ type: "text", text: "Hello world" }]; + const result = formatResultContent(JSON.stringify(structuredContent)); + expect(result).toBe("**→** Hello world\n\n"); + }); +}); + +describe("formatToolWithResult", () => { + test("formats tool with parameters and result", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "read_file", + input: { file_path: "/path/to/file.txt" }, + id: "tool_123", + }; + + const toolResult: ToolResult = { + type: "tool_result", + tool_use_id: "tool_123", + content: "File content here", + is_error: false, + }; + + const result = formatToolWithResult(toolUse, toolResult); + + expect(result).toContain("### 🔧 `read_file`"); + expect(result).toContain("**Parameters:**"); + expect(result).toContain('"file_path": "/path/to/file.txt"'); + expect(result).toContain("**→** File content here"); + }); + + test("formats tool with error result", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "failing_tool", + input: { param: "value" }, + }; + + const toolResult: ToolResult = { + type: "tool_result", + content: "Permission denied", + is_error: true, + }; + + const result = formatToolWithResult(toolUse, toolResult); + + expect(result).toContain("### 🔧 `failing_tool`"); + expect(result).toContain("❌ **Error:** `Permission denied`"); + }); + + test("formats tool without parameters", () => { + const toolUse: ToolUse = { + type: "tool_use", + name: "simple_tool", + }; + + const result = formatToolWithResult(toolUse); + + expect(result).toContain("### 🔧 `simple_tool`"); + expect(result).not.toContain("**Parameters:**"); + }); + + test("handles unknown tool name", () => { + const toolUse: ToolUse = { + type: "tool_use", + }; + + const result = formatToolWithResult(toolUse); + + expect(result).toContain("### 🔧 `unknown_tool`"); + }); +}); + +describe("groupTurnsNaturally", () => { + test("groups system initialization", () => { + const data: Turn[] = [ + { + type: "system", + subtype: "init", + tools: [{ name: "tool1" }, { name: "tool2" }], + }, + ]; + + const result = groupTurnsNaturally(data); + + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe("system_init"); + expect(result[0]?.tools_count).toBe(2); + }); + + test("groups assistant actions with tool calls", () => { + const data: Turn[] = [ + { + type: "assistant", + message: { + content: [ + { type: "text", text: "I'll help you" }, + { + type: "tool_use", + id: "tool_123", + name: "read_file", + input: { file_path: "/test.txt" }, + }, + ], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_123", + content: "file content", + is_error: false, + }, + ], + }, + }, + ]; + + const result = groupTurnsNaturally(data); + + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe("assistant_action"); + expect(result[0]?.text_parts).toEqual(["I'll help you"]); + expect(result[0]?.tool_calls).toHaveLength(1); + expect(result[0]?.tool_calls?.[0]?.tool_use.name).toBe("read_file"); + expect(result[0]?.tool_calls?.[0]?.tool_result?.content).toBe( + "file content", + ); + expect(result[0]?.usage).toEqual({ input_tokens: 100, output_tokens: 50 }); + }); + + test("groups user messages", () => { + const data: Turn[] = [ + { + type: "user", + message: { + content: [{ type: "text", text: "Please help me" }], + }, + }, + ]; + + const result = groupTurnsNaturally(data); + + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe("user_message"); + expect(result[0]?.text_parts).toEqual(["Please help me"]); + }); + + test("groups final results", () => { + const data: Turn[] = [ + { + type: "result", + cost_usd: 0.1234, + duration_ms: 5000, + result: "Task completed", + }, + ]; + + const result = groupTurnsNaturally(data); + + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe("final_result"); + expect(result[0]?.data).toEqual(data[0]!); + }); +}); + +describe("formatGroupedContent", () => { + test("formats system initialization", () => { + const groupedContent = [ + { + type: "system_init", + tools_count: 3, + }, + ]; + + const result = formatGroupedContent(groupedContent); + + expect(result).toContain("## Claude Code Report"); + expect(result).toContain("## 🚀 System Initialization"); + expect(result).toContain("**Available Tools:** 3 tools loaded"); + }); + + test("formats assistant actions", () => { + const groupedContent = [ + { + type: "assistant_action", + text_parts: ["I'll help you with that"], + tool_calls: [ + { + tool_use: { + type: "tool_use", + name: "test_tool", + input: { param: "value" }, + }, + tool_result: { + type: "tool_result", + content: "result", + is_error: false, + }, + }, + ], + usage: { input_tokens: 100, output_tokens: 50 }, + }, + ]; + + const result = formatGroupedContent(groupedContent); + + expect(result).toContain("I'll help you with that"); + expect(result).toContain("### 🔧 `test_tool`"); + expect(result).toContain("*Token usage: 100 input, 50 output*"); + }); + + test("formats user messages", () => { + const groupedContent = [ + { + type: "user_message", + text_parts: ["Help me please"], + }, + ]; + + const result = formatGroupedContent(groupedContent); + + expect(result).toContain("## 👤 User"); + expect(result).toContain("Help me please"); + }); + + test("formats final results", () => { + const groupedContent = [ + { + type: "final_result", + data: { + type: "result", + cost_usd: 0.1234, + duration_ms: 5678, + result: "Success!", + } as Turn, + }, + ]; + + const result = formatGroupedContent(groupedContent); + + expect(result).toContain("## ✅ Final Result"); + expect(result).toContain("Success!"); + expect(result).toContain("**Cost:** $0.1234"); + expect(result).toContain("**Duration:** 5.7s"); + }); +}); + +describe("formatTurnsFromData", () => { + test("handles empty data", () => { + const result = formatTurnsFromData([]); + expect(result).toBe("## Claude Code Report\n\n"); + }); + + test("formats complete conversation", () => { + const data: Turn[] = [ + { + type: "system", + subtype: "init", + tools: [{ name: "tool1" }], + }, + { + type: "assistant", + message: { + content: [ + { type: "text", text: "I'll help you" }, + { + type: "tool_use", + id: "tool_123", + name: "read_file", + input: { file_path: "/test.txt" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_123", + content: "file content", + is_error: false, + }, + ], + }, + }, + { + type: "result", + cost_usd: 0.05, + duration_ms: 2000, + result: "Done", + }, + ]; + + const result = formatTurnsFromData(data); + + expect(result).toContain("## Claude Code Report"); + expect(result).toContain("## 🚀 System Initialization"); + expect(result).toContain("I'll help you"); + expect(result).toContain("### 🔧 `read_file`"); + expect(result).toContain("## ✅ Final Result"); + expect(result).toContain("Done"); + }); +}); + +describe("integration tests", () => { + test("formats real conversation data correctly", () => { + // Load the sample JSON data + const jsonPath = join(__dirname, "fixtures", "sample-turns.json"); + const expectedPath = join( + __dirname, + "fixtures", + "sample-turns-expected-output.md", + ); + + const jsonData = JSON.parse(readFileSync(jsonPath, "utf-8")); + const expectedOutput = readFileSync(expectedPath, "utf-8").trim(); + + // Format the data using our function + const actualOutput = formatTurnsFromData(jsonData).trim(); + + // Compare the outputs + expect(actualOutput).toBe(expectedOutput); + }); +});