feat: add human-readable CI log output for show_full_output mode
Replace raw JSON dumps with concise, scannable log lines when show_full_output is enabled. Adds an onMessage callback to the SDK runner that formats each message type into 1-2 line summaries with emoji prefixes, tool-specific parameter display, result correlation for parallel tool calls, and subagent progress tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec1a718b2e
commit
ed536bb8a3
5 files changed
+1064
-3
No files matched your search
@@ -136,6 +136,7 @@ function sanitizeSdkOutput(
|
|||||||
export async function runClaudeWithSdk(
|
export async function runClaudeWithSdk(
|
||||||
promptPath: string,
|
promptPath: string,
|
||||||
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
||||||
|
onMessage?: (message: SDKMessage) => void,
|
||||||
): Promise<ClaudeRunResult> {
|
): Promise<ClaudeRunResult> {
|
||||||
// Create prompt configuration - may be a string or multi-block message
|
// Create prompt configuration - may be a string or multi-block message
|
||||||
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
||||||
@@ -161,10 +162,14 @@ export async function runClaudeWithSdk(
|
|||||||
for await (const message of query({ prompt, options: sdkOptions })) {
|
for await (const message of query({ prompt, options: sdkOptions })) {
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
|
||||||
|
if (onMessage) {
|
||||||
|
onMessage(message);
|
||||||
|
} else {
|
||||||
const sanitized = sanitizeSdkOutput(message, showFullOutput);
|
const sanitized = sanitizeSdkOutput(message, showFullOutput);
|
||||||
if (sanitized) {
|
if (sanitized) {
|
||||||
console.log(sanitized);
|
console.log(sanitized);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === "result") {
|
if (message.type === "result") {
|
||||||
resultMessage = message as SDKResultMessage;
|
resultMessage = message as SDKResultMessage;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { runClaudeWithSdk } from "./run-claude-sdk";
|
import { runClaudeWithSdk } from "./run-claude-sdk";
|
||||||
import type { ClaudeRunResult } from "./run-claude-sdk";
|
import type { ClaudeRunResult } from "./run-claude-sdk";
|
||||||
import { parseSdkOptions } from "./parse-sdk-options";
|
import { parseSdkOptions } from "./parse-sdk-options";
|
||||||
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
export type ClaudeOptions = {
|
export type ClaudeOptions = {
|
||||||
claudeArgs?: string;
|
claudeArgs?: string;
|
||||||
@@ -19,7 +20,8 @@ export type ClaudeOptions = {
|
|||||||
export async function runClaude(
|
export async function runClaude(
|
||||||
promptPath: string,
|
promptPath: string,
|
||||||
options: ClaudeOptions,
|
options: ClaudeOptions,
|
||||||
|
onMessage?: (message: SDKMessage) => void,
|
||||||
): Promise<ClaudeRunResult> {
|
): Promise<ClaudeRunResult> {
|
||||||
const parsedOptions = parseSdkOptions(options);
|
const parsedOptions = parseSdkOptions(options);
|
||||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
return runClaudeWithSdk(promptPath, parsedOptions, onMessage);
|
||||||
}
|
}
|
||||||
@@ -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, unknown>,
|
||||||
|
): 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<string, string>();
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+12
-2
@@ -34,6 +34,7 @@ import { installPlugins } from "../../base-action/src/install-plugins";
|
|||||||
import { preparePrompt } from "../../base-action/src/prepare-prompt";
|
import { preparePrompt } from "../../base-action/src/prepare-prompt";
|
||||||
import { runClaude } from "../../base-action/src/run-claude";
|
import { runClaude } from "../../base-action/src/run-claude";
|
||||||
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
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.
|
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
||||||
@@ -243,7 +244,14 @@ async function run() {
|
|||||||
promptFile,
|
promptFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
const claudeResult: ClaudeRunResult = await runClaude(promptConfig.path, {
|
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,
|
claudeArgs: prepareResult.claudeArgs,
|
||||||
disallowedTools: process.env.DISALLOWED_TOOLS || undefined,
|
disallowedTools: process.env.DISALLOWED_TOOLS || undefined,
|
||||||
appendSystemPrompt: process.env.APPEND_SYSTEM_PROMPT,
|
appendSystemPrompt: process.env.APPEND_SYSTEM_PROMPT,
|
||||||
@@ -251,7 +259,9 @@ async function run() {
|
|||||||
pathToClaudeCodeExecutable:
|
pathToClaudeCodeExecutable:
|
||||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||||
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
||||||
});
|
},
|
||||||
|
onMessage,
|
||||||
|
);
|
||||||
|
|
||||||
claudeSuccess = claudeResult.conclusion === "success";
|
claudeSuccess = claudeResult.conclusion === "success";
|
||||||
executionFile = claudeResult.executionFile;
|
executionFile = claudeResult.executionFile;
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}> = {},
|
||||||
|
): 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<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}> = {},
|
||||||
|
): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user