mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
formatResultContent recognized structured tool output shaped like
`[{ type: "text", text: "..." }]` but read only `parsedContent[0].text`.
When a tool result split its output across several text blocks, the step
summary showed the first and silently dropped the rest, so extra findings,
file paths and follow-up instructions vanished from the rendered
Claude Code Report while remaining in the execution transcript.
Collect the text from every block instead of just the first. Blocks of other
types, such as images, are skipped rather than stringified into the summary.
Fixes #1572
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
666 lines
19 KiB
TypeScript
666 lines
19 KiB
TypeScript
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");
|
|
});
|
|
|
|
test("keeps every text block, not just the first", () => {
|
|
const structuredContent = [
|
|
{ type: "text", text: "first line" },
|
|
{ type: "text", text: "second line" },
|
|
{ type: "text", text: "third line" },
|
|
];
|
|
const result = formatResultContent(JSON.stringify(structuredContent));
|
|
|
|
expect(result).toContain("first line");
|
|
expect(result).toContain("second line");
|
|
expect(result).toContain("third line");
|
|
});
|
|
|
|
test("keeps every text block when given an array directly", () => {
|
|
const result = formatResultContent([
|
|
{ type: "text", text: "alpha" },
|
|
{ type: "text", text: "beta" },
|
|
]);
|
|
|
|
expect(result).toContain("alpha");
|
|
expect(result).toContain("beta");
|
|
});
|
|
|
|
test("skips non-text blocks while keeping the text ones", () => {
|
|
const structuredContent = [
|
|
{ type: "text", text: "visible" },
|
|
{ type: "image", source: { data: "ignored-binary" } },
|
|
{ type: "text", text: "also visible" },
|
|
];
|
|
const result = formatResultContent(JSON.stringify(structuredContent));
|
|
|
|
expect(result).toContain("visible");
|
|
expect(result).toContain("also visible");
|
|
expect(result).not.toContain("ignored-binary");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("detectContentType fallbacks", () => {
|
|
test("falls back to text for malformed JSON objects", () => {
|
|
// Looks like an object (starts with { ends with }) but does not parse.
|
|
expect(detectContentType("{not valid json}")).toBe("text");
|
|
});
|
|
|
|
test("falls back to text for malformed JSON arrays", () => {
|
|
// Looks like an array (starts with [ ends with ]) but does not parse.
|
|
expect(detectContentType("[not, valid, json]")).toBe("text");
|
|
});
|
|
|
|
test("classifies non-python, non-js code keywords as python by default", () => {
|
|
// Contains a code keyword ("class ") but matches neither the python-specific
|
|
// nor the javascript-specific checks, so it hits the default branch.
|
|
expect(detectContentType("class Foo {}")).toBe("python");
|
|
});
|
|
});
|
|
|
|
describe("formatResultContent non-string input", () => {
|
|
test("handles a numeric (non-string) result value", () => {
|
|
const result = formatResultContent(42);
|
|
expect(result).toContain("42");
|
|
});
|
|
|
|
test("handles a plain object (non-string, non-text-array) result value", () => {
|
|
const result = formatResultContent({ status: "ok" });
|
|
expect(typeof result).toBe("string");
|
|
expect(result.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("handles a text content block whose text field is not a string", () => {
|
|
expect(() =>
|
|
formatResultContent('[{"type":"text","text":{"foo":"bar"}}]'),
|
|
).not.toThrow();
|
|
expect(formatResultContent('[{"type":"text","text":123}]')).toContain(
|
|
"123",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("system_other handling", () => {
|
|
test("groups a non-init system turn as system_other", () => {
|
|
const systemTurn: Turn = { type: "system", subtype: "some_other_subtype" };
|
|
const grouped = groupTurnsNaturally([systemTurn]);
|
|
expect(grouped).toHaveLength(1);
|
|
expect(grouped[0]?.type).toBe("system_other");
|
|
expect(grouped[0]?.data).toEqual(systemTurn);
|
|
});
|
|
|
|
test("renders a system_other group as a System Message section", () => {
|
|
const markdown = formatGroupedContent([
|
|
{ type: "system_other", data: { type: "system" } as Turn },
|
|
]);
|
|
expect(markdown).toContain("## ⚙️ System Message");
|
|
});
|
|
|
|
test("filters out thinking_tokens system messages", () => {
|
|
const data: Turn[] = [
|
|
{ type: "system", subtype: "init", tools: [{ name: "tool1" }] },
|
|
{ type: "system", subtype: "thinking_tokens" },
|
|
{ type: "system", subtype: "thinking_tokens" },
|
|
{ type: "system", subtype: "other_subtype" },
|
|
];
|
|
|
|
const grouped = groupTurnsNaturally(data);
|
|
|
|
// Should have init and other_subtype, but not thinking_tokens
|
|
expect(grouped).toHaveLength(2);
|
|
expect(grouped[0]?.type).toBe("system_init");
|
|
expect(grouped[1]?.type).toBe("system_other");
|
|
expect(grouped[1]?.data?.subtype).toBe("other_subtype");
|
|
});
|
|
|
|
test("thinking_tokens does not appear in formatted output", () => {
|
|
const data: Turn[] = [
|
|
{ type: "system", subtype: "init", tools: [] },
|
|
{ type: "system", subtype: "thinking_tokens" },
|
|
{ type: "system", subtype: "thinking_tokens" },
|
|
];
|
|
|
|
const result = formatTurnsFromData(data);
|
|
|
|
expect(result).not.toContain("thinking_tokens");
|
|
expect(result).toContain("## 🚀 System Initialization");
|
|
});
|
|
});
|
|
|
|
describe("credential redaction", () => {
|
|
test("redacts credentials embedded in tool results", () => {
|
|
const data: Turn[] = [
|
|
{
|
|
type: "assistant",
|
|
message: {
|
|
content: [
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu_1",
|
|
name: "Bash",
|
|
input: { command: "cat .env" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: "user",
|
|
message: {
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: "toolu_1",
|
|
content:
|
|
"GITHUB_TOKEN=ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = formatTurnsFromData(data);
|
|
|
|
expect(result).toContain("[REDACTED_GITHUB_TOKEN]");
|
|
expect(result).toContain("[REDACTED_AWS_KEY_ID]");
|
|
expect(result).not.toContain("ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW");
|
|
expect(result).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
|
});
|
|
|
|
test("redacts credentials embedded in multi-line tool inputs", () => {
|
|
const data: Turn[] = [
|
|
{
|
|
type: "assistant",
|
|
message: {
|
|
content: [
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu_2",
|
|
name: "Write",
|
|
input: {
|
|
file_path: ".env",
|
|
content:
|
|
"AWS_ACCESS_KEY_ID=x\nGITHUB_TOKEN=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\n",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = formatTurnsFromData(data);
|
|
|
|
expect(result).toContain("[REDACTED_GITHUB_TOKEN]");
|
|
expect(result).not.toContain("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");
|
|
});
|
|
|
|
test("redacts credentials wrapped in ANSI color codes", () => {
|
|
const key = "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcdefgh";
|
|
const data: Turn[] = [
|
|
{
|
|
type: "assistant",
|
|
message: {
|
|
content: [
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu_3",
|
|
name: "Bash",
|
|
input: { command: "node print-config.js" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
type: "user",
|
|
message: {
|
|
content: [
|
|
{
|
|
type: "tool_result",
|
|
tool_use_id: "toolu_3",
|
|
content: `apiKey: \x1b[32m${key}\x1b[39m\nregion: us-east-1`,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = formatTurnsFromData(data);
|
|
|
|
expect(result).toContain("[REDACTED_ANTHROPIC_KEY]");
|
|
expect(result).not.toContain(key);
|
|
});
|
|
});
|