mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 14:24:13 +08:00
feat: add formatted output for Claude Code execution reports (#18)
* feat: add formatted output for Claude Code execution reports - Write turns formatter - Modify GitHub Action to call formatter instead of dumping raw JSON - Add comprehensive unit tests (30 tests) covering all functionality - Add integration test with sample data for output consistency - Support syntax highlighting for multiple content types (JSON, Python, bash, etc.) - Include turn grouping logic and token usage tracking - Provide CLI interface for standalone formatter usage 🤖 Generated with [Claude Code](https://claude.ai/code) Note: seriously I have never written any line of ts code in my life, so please make sure this is fine as I don't give any guarantees Co-Authored-By: Claude <noreply@anthropic.com> * Add fallback --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Test fixtures should not be formatted to preserve exact output matching
|
||||
test/fixtures/
|
||||
16
action.yml
16
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 == ''
|
||||
|
||||
461
src/entrypoints/format-turns.ts
Executable file
461
src/entrypoints/format-turns.ts
Executable file
@@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
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<string, ToolResult>();
|
||||
|
||||
// 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 <json-file>");
|
||||
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();
|
||||
}
|
||||
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
@@ -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
|
||||
|
||||
|
||||
196
test/fixtures/sample-turns.json
vendored
Normal file
196
test/fixtures/sample-turns.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
]
|
||||
439
test/format-turns.test.ts
Normal file
439
test/format-turns.test.ts
Normal file
@@ -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("<div>hello</div>")).toBe("html");
|
||||
expect(detectContentType("<xml>content</xml>")).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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user