diff --git a/actions/setup/js/log_parser_format.cjs b/actions/setup/js/log_parser_format.cjs new file mode 100644 index 0000000000..70878b4b81 --- /dev/null +++ b/actions/setup/js/log_parser_format.cjs @@ -0,0 +1,595 @@ +// @ts-check + +/** + * Minimal dependency contract injected from log_parser_shared.cjs. + * Keeping this explicit helps prevent silent drift between modules. + * + * @typedef {Object} LogParserFormatterDeps + * @property {(command: string) => string} formatBashCommand + * @property {(toolName: string) => string} formatMcpName + * @property {(name: string, input: Object) => string} formatToolDisplayName + * @property {(resultText: string, maxLineLength?: number) => string} formatResultPreview + * @property {(options: {summary: string, statusIcon?: string, sections?: Array<{label: string, content: string, language?: string}>, metadata?: string, maxContentLength?: number}) => string} formatToolCallAsDetails + * @property {(input: Record) => string} formatMcpParameters + * @property {(str: string, maxLength: number) => string} truncateString + * @property {(text: string) => number} estimateTokens + * @property {(ms: number) => string} formatDuration + * @property {(text: string) => string} unfenceMarkdown + * @property {number} MAX_AGENT_TEXT_LENGTH + * @property {string} SIZE_LIMIT_WARNING + */ + +/** + * Public formatter API returned by createLogParserFormatters(). + * + * @typedef {Object} LogParserFormatters + * @property {(logEntries: Array, options: {formatToolCallback: Function, formatInitCallback: Function, summaryTracker?: any}) => {markdown: string, commandSummary: Array, sizeLimitReached: boolean}} generateConversationMarkdown + * @property {(toolUse: any, toolResult: any, options?: {includeDetailedParameters?: boolean}) => string} formatToolUse + * @property {(logEntries: Array, options?: {model?: string, parserName?: string}) => string} generatePlainTextSummary + * @property {(logEntries: Array, options?: {model?: string, parserName?: string}) => string} generateCopilotCliStyleSummary + */ + +/** + * Creates formatter functions for log parsing summaries and rendering. + * Dependencies are injected to avoid module cycles with log_parser_shared.cjs. + * + * @param {LogParserFormatterDeps} deps - Dependency injection container + * @returns {LogParserFormatters} Formatter functions + */ +function createLogParserFormatters(deps) { + const { + formatBashCommand, + formatMcpName, + formatToolDisplayName, + formatResultPreview, + formatToolCallAsDetails, + formatMcpParameters, + truncateString, + estimateTokens, + formatDuration, + unfenceMarkdown, + MAX_AGENT_TEXT_LENGTH, + SIZE_LIMIT_WARNING, + } = deps; + + const INTERNAL_TOOLS = ["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"]; + + /** + * Generates markdown summary from conversation log entries + * This is the core shared logic between Claude and Copilot log parsers + * + * When a summaryTracker is provided, the function tracks the accumulated size + * and stops rendering additional content when approaching the step summary limit. + * + * @param {Array} logEntries - Array of log entries with type, message, etc. + * @param {Object} options - Configuration options + * @param {Function} options.formatToolCallback - Callback function to format tool use (content, toolResult) => string + * @param {Function} options.formatInitCallback - Callback function to format initialization (initEntry) => string or {markdown: string, mcpFailures: string[]} + * @param {any} [options.summaryTracker] - Optional tracker for step summary size limits + * @returns {{markdown: string, commandSummary: Array, sizeLimitReached: boolean}} Generated markdown, command summary, and size limit status + */ + function generateConversationMarkdown(logEntries, options) { + const { formatToolCallback, formatInitCallback, summaryTracker } = options; + + const toolUsePairs = collectToolUsePairs(logEntries); + + let markdown = ""; + let sizeLimitReached = false; + + function addContent(content) { + if (summaryTracker && !summaryTracker.add(content)) { + sizeLimitReached = true; + return false; + } + markdown += content; + return true; + } + + const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); + + if (initEntry && formatInitCallback) { + if (!addContent("## 🚀 Initialization\n\n")) { + return { markdown, commandSummary: [], sizeLimitReached }; + } + const initResult = formatInitCallback(initEntry); + if (typeof initResult === "string") { + if (!addContent(initResult)) { + return { markdown, commandSummary: [], sizeLimitReached }; + } + } else if (initResult && initResult.markdown) { + if (!addContent(initResult.markdown)) { + return { markdown, commandSummary: [], sizeLimitReached }; + } + } + if (!addContent("\n")) { + return { markdown, commandSummary: [], sizeLimitReached }; + } + } + + if (!addContent("\n## 🤖 Reasoning\n\n")) { + return { markdown, commandSummary: [], sizeLimitReached }; + } + + for (const entry of logEntries) { + if (sizeLimitReached) break; + + if (entry.type === "assistant" && entry.message?.content) { + for (const content of entry.message.content) { + if (sizeLimitReached) break; + + if (content.type === "text" && content.text) { + let text = content.text.trim(); + text = unfenceMarkdown(text); + if (text && text.length > 0) { + if (!addContent(text + "\n\n")) { + break; + } + } + } else if (content.type === "tool_use") { + const toolResult = toolUsePairs.get(content.id); + const toolMarkdown = formatToolCallback(content, toolResult); + if (toolMarkdown) { + if (!addContent(toolMarkdown)) { + break; + } + } + } + } + } + } + + if (sizeLimitReached) { + markdown += SIZE_LIMIT_WARNING; + return { markdown, commandSummary: [], sizeLimitReached }; + } + + if (!addContent("## 🤖 Commands and Tools\n\n")) { + markdown += SIZE_LIMIT_WARNING; + return { markdown, commandSummary: [], sizeLimitReached: true }; + } + + const commandSummary = []; + + for (const entry of logEntries) { + if (entry.type === "assistant" && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === "tool_use") { + const toolName = content.name; + const input = content.input || {}; + + if (INTERNAL_TOOLS.includes(toolName)) { + continue; + } + + const toolResult = toolUsePairs.get(content.id); + let statusIcon = "❓"; + if (toolResult) { + statusIcon = toolResult.is_error === true ? "❌" : "✅"; + } + + if (toolName === "Bash") { + const formattedCommand = formatBashCommand(input.command || ""); + commandSummary.push(`* ${statusIcon} \`${formattedCommand}\``); + } else if (toolName.startsWith("mcp__")) { + const mcpName = formatMcpName(toolName); + commandSummary.push(`* ${statusIcon} \`${mcpName}(...)\``); + } else { + commandSummary.push(`* ${statusIcon} ${toolName}`); + } + } + } + } + } + + if (commandSummary.length > 0) { + for (const cmd of commandSummary) { + if (!addContent(`${cmd}\n`)) { + markdown += SIZE_LIMIT_WARNING; + return { markdown, commandSummary, sizeLimitReached: true }; + } + } + } else if (!addContent("No commands or tools used.\n")) { + markdown += SIZE_LIMIT_WARNING; + return { markdown, commandSummary, sizeLimitReached: true }; + } + + return { markdown, commandSummary, sizeLimitReached }; + } + + /** + * Formats a tool use entry with its result into markdown + * @param {any} toolUse - The tool use object containing name, input, etc. + * @param {any} toolResult - The corresponding tool result object + * @param {Object} options - Configuration options + * @param {boolean} [options.includeDetailedParameters] - Whether to include detailed parameter section (default: false) + * @returns {string} Formatted markdown string + */ + function formatToolUse(toolUse, toolResult, options = {}) { + const { includeDetailedParameters = false } = options; + const toolName = toolUse.name; + const input = toolUse.input || {}; + + if (toolName === "TodoWrite") { + return ""; + } + + function getStatusIcon() { + if (toolResult) { + return toolResult.is_error === true ? "❌" : "✅"; + } + return "❓"; + } + + const statusIcon = getStatusIcon(); + let summary = ""; + let details = ""; + + if (toolResult && toolResult.content) { + if (typeof toolResult.content === "string") { + details = toolResult.content; + } else if (Array.isArray(toolResult.content)) { + details = toolResult.content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n"); + } + } + + const inputText = JSON.stringify(input); + const outputText = details; + const totalTokens = estimateTokens(inputText) + estimateTokens(outputText); + + let metadata = ""; + if (toolResult && toolResult.duration_ms) { + metadata += `${formatDuration(toolResult.duration_ms)} `; + } + if (totalTokens > 0) { + metadata += `~${totalTokens}t`; + } + metadata = metadata.trim(); + + switch (toolName) { + case "Bash": { + const command = input.command || ""; + const description = input.description || ""; + const formattedCommand = formatBashCommand(command); + + if (description) { + summary = `${description}: ${formattedCommand}`; + } else { + summary = `${formattedCommand}`; + } + break; + } + + case "Read": { + const filePath = input.file_path || input.path || ""; + const relativePath = filePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); + summary = `Read ${relativePath}`; + break; + } + + case "Write": + case "Edit": + case "MultiEdit": { + const writeFilePath = input.file_path || input.path || ""; + const writeRelativePath = writeFilePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); + summary = `Write ${writeRelativePath}`; + break; + } + + case "Grep": + case "Glob": { + const query = input.query || input.pattern || ""; + summary = `Search for ${truncateString(query, 80)}`; + break; + } + + case "LS": { + const lsPath = input.path || ""; + const lsRelativePath = lsPath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); + summary = `LS: ${lsRelativePath || lsPath}`; + break; + } + + default: + if (toolName.startsWith("mcp__")) { + const mcpName = formatMcpName(toolName); + const params = formatMcpParameters(input); + summary = `${mcpName}(${params})`; + } else { + const keys = Object.keys(input); + if (keys.length > 0) { + const mainParam = keys.find(k => ["query", "command", "path", "file_path", "content"].includes(k)) || keys[0]; + const value = String(input[mainParam] || ""); + + if (value) { + summary = `${toolName}: ${truncateString(value, 100)}`; + } else { + summary = toolName; + } + } else { + summary = toolName; + } + } + } + + /** @type {Array<{label: string, content: string, language?: string}>} */ + const sections = []; + + if (includeDetailedParameters) { + const inputKeys = Object.keys(input); + if (inputKeys.length > 0) { + sections.push({ + label: "Parameters", + content: JSON.stringify(input, null, 2), + language: "json", + }); + } + } + + if (details && details.trim()) { + sections.push({ + label: includeDetailedParameters ? "Response" : "Output", + content: details, + }); + } + + return formatToolCallAsDetails({ + summary, + statusIcon, + sections, + metadata: metadata || undefined, + }); + } + + function collectToolUsePairs(logEntries) { + const toolUsePairs = new Map(); + for (const entry of logEntries) { + if (entry.type === "user" && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === "tool_result" && content.tool_use_id) { + toolUsePairs.set(content.tool_use_id, content); + } + } + } + } + return toolUsePairs; + } + + function appendConversationLine(lines, line, state) { + if (state.conversationLineCount >= state.maxConversationLines) { + state.conversationTruncated = true; + return false; + } + lines.push(line); + state.conversationLineCount++; + return true; + } + + function appendAgentText(lines, text, state) { + let displayText = text; + if (displayText.length > MAX_AGENT_TEXT_LENGTH) { + displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`; + } + + const textLines = displayText.split("\n"); + for (let i = 0; i < textLines.length; i++) { + const prefix = i === 0 ? "◆ " : " "; + if (!appendConversationLine(lines, `${prefix}${textLines[i]}`, state)) { + return; + } + } + appendConversationLine(lines, "", state); + } + + function appendToolExecutionLine(lines, content, toolUsePairs, state) { + const toolName = content.name; + const input = content.input || {}; + + if (INTERNAL_TOOLS.includes(toolName)) { + return; + } + + const toolResult = toolUsePairs.get(content.id); + const isError = toolResult?.is_error === true; + const statusIcon = isError ? "✗" : "✓"; + + let displayName; + let resultPreview = ""; + + if (toolName === "Bash") { + const cmd = formatBashCommand(input.command || ""); + displayName = `$ ${cmd}`; + + if (toolResult && toolResult.content) { + const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content); + resultPreview = formatResultPreview(resultText); + } + } else if (toolName.startsWith("mcp__")) { + const formattedName = formatMcpName(toolName).replace("::", "-"); + displayName = formatToolDisplayName(formattedName, input); + + if (toolResult && toolResult.content) { + const resultText = typeof toolResult.content === "string" ? toolResult.content : JSON.stringify(toolResult.content); + resultPreview = formatResultPreview(resultText); + } + } else { + displayName = formatToolDisplayName(toolName, input); + + if (toolResult && toolResult.content) { + const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content); + resultPreview = formatResultPreview(resultText); + } + } + + if (!appendConversationLine(lines, `${statusIcon} ${displayName}`, state)) { + return; + } + + if (resultPreview) { + for (const previewLine of resultPreview.split("\n")) { + if (!appendConversationLine(lines, previewLine, state)) { + return; + } + } + } + + appendConversationLine(lines, "", state); + } + + function appendStatistics(lines, logEntries, toolUsePairs) { + const lastEntry = logEntries[logEntries.length - 1]; + lines.push("Statistics:"); + if (lastEntry?.num_turns) { + lines.push(` Turns: ${lastEntry.num_turns}`); + } + if (lastEntry?.duration_ms) { + const duration = formatDuration(lastEntry.duration_ms); + if (duration) { + lines.push(` Duration: ${duration}`); + } + } + + let toolCounts = { total: 0, success: 0, error: 0 }; + for (const entry of logEntries) { + if (entry.type === "assistant" && entry.message?.content) { + for (const content of entry.message.content) { + if (content.type === "tool_use") { + const toolName = content.name; + if (INTERNAL_TOOLS.includes(toolName)) { + continue; + } + toolCounts.total++; + const toolResult = toolUsePairs.get(content.id); + const isError = toolResult?.is_error === true; + if (isError) { + toolCounts.error++; + } else { + toolCounts.success++; + } + } + } + } + } + + if (toolCounts.total > 0) { + lines.push(` Tools: ${toolCounts.success}/${toolCounts.total} succeeded`); + } + if (lastEntry?.usage) { + const usage = lastEntry.usage; + if (usage.input_tokens || usage.output_tokens) { + const inputTokens = usage.input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + const cacheCreationTokens = usage.cache_creation_input_tokens || 0; + const cacheReadTokens = usage.cache_read_input_tokens || 0; + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + + lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${inputTokens.toLocaleString()} in / ${outputTokens.toLocaleString()} out)`); + } + } + if (lastEntry?.total_cost_usd) { + lines.push(` Cost: $${lastEntry.total_cost_usd.toFixed(4)}`); + } + } + + function generateSummaryLines(logEntries) { + const lines = []; + const toolUsePairs = collectToolUsePairs(logEntries); + + const state = { + conversationLineCount: 0, + maxConversationLines: 5000, + conversationTruncated: false, + }; + + for (const entry of logEntries) { + if (state.conversationLineCount >= state.maxConversationLines) { + state.conversationTruncated = true; + break; + } + + if (entry.type === "assistant" && entry.message?.content) { + for (const content of entry.message.content) { + if (state.conversationLineCount >= state.maxConversationLines) { + state.conversationTruncated = true; + break; + } + + if (content.type === "text" && content.text) { + let text = content.text.trim(); + text = unfenceMarkdown(text); + if (text && text.length > 0) { + appendAgentText(lines, text, state); + } + } else if (content.type === "tool_use") { + appendToolExecutionLine(lines, content, toolUsePairs, state); + } + } + } + } + + if (state.conversationTruncated) { + lines.push("... (conversation truncated)"); + lines.push(""); + } + + appendStatistics(lines, logEntries, toolUsePairs); + + return lines; + } + + /** + * Generates plain-text Copilot CLI style summary for logs. + * @param {Array} logEntries - Array of log entries with type, message, etc. + * @param {Object} options - Configuration options + * @param {string} [options.model] - Model name to include in the header + * @param {string} [options.parserName] - Name of the parser (e.g., "Copilot", "Claude") + * @returns {string} Plain text summary for console output + */ + function generatePlainTextSummary(logEntries, options = {}) { + const { model, parserName = "Agent" } = options; + const lines = []; + + lines.push(`=== ${parserName} Execution Summary ===`); + if (model) { + lines.push(`Model: ${model}`); + } + lines.push(""); + + lines.push("Conversation:"); + lines.push(""); + + lines.push(...generateSummaryLines(logEntries)); + + return lines.join("\n"); + } + + /** + * Generates a markdown-formatted Copilot CLI style summary for step summaries. + * @param {Array} logEntries - Array of log entries with type, message, etc. + * @param {Object} options - Configuration options + * @param {string} [options.model] - Model name to include in the header + * @param {string} [options.parserName] - Name of the parser (e.g., "Copilot", "Claude") + * @returns {string} Markdown-formatted summary for step summary rendering + */ + function generateCopilotCliStyleSummary(logEntries, options = {}) { + const lines = []; + + lines.push("```"); + lines.push("Conversation:"); + lines.push(""); + + lines.push(...generateSummaryLines(logEntries)); + + lines.push("```"); + + return lines.join("\n"); + } + + return { + generateConversationMarkdown, + formatToolUse, + generatePlainTextSummary, + generateCopilotCliStyleSummary, + }; +} + +module.exports = createLogParserFormatters; diff --git a/actions/setup/js/log_parser_shared.cjs b/actions/setup/js/log_parser_shared.cjs index e1050de49c..0414b014fc 100644 --- a/actions/setup/js/log_parser_shared.cjs +++ b/actions/setup/js/log_parser_shared.cjs @@ -4,6 +4,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { unfenceMarkdown } = require("./markdown_unfencing.cjs"); const { ERR_PARSE } = require("./error_codes.cjs"); +const createLogParserFormatters = require("./log_parser_format.cjs"); /** * Shared utility functions for log parsers @@ -258,179 +259,6 @@ function isLikelyCustomAgent(toolName) { return true; } -/** - * Generates markdown summary from conversation log entries - * This is the core shared logic between Claude and Copilot log parsers - * - * When a summaryTracker is provided, the function tracks the accumulated size - * and stops rendering additional content when approaching the step summary limit. - * - * @param {Array} logEntries - Array of log entries with type, message, etc. - * @param {Object} options - Configuration options - * @param {Function} options.formatToolCallback - Callback function to format tool use (content, toolResult) => string - * @param {Function} options.formatInitCallback - Callback function to format initialization (initEntry) => string or {markdown: string, mcpFailures: string[]} - * @param {StepSummaryTracker} [options.summaryTracker] - Optional tracker for step summary size limits - * @returns {{markdown: string, commandSummary: Array, sizeLimitReached: boolean}} Generated markdown, command summary, and size limit status - */ -function generateConversationMarkdown(logEntries, options) { - const { formatToolCallback, formatInitCallback, summaryTracker } = options; - - const toolUsePairs = new Map(); // Map tool_use_id to tool_result - - // First pass: collect tool results by tool_use_id - for (const entry of logEntries) { - if (entry.type === "user" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_result" && content.tool_use_id) { - toolUsePairs.set(content.tool_use_id, content); - } - } - } - } - - let markdown = ""; - let sizeLimitReached = false; - - /** - * Helper to add content with size tracking - * @param {string} content - Content to add - * @returns {boolean} True if content was added, false if limit reached - */ - function addContent(content) { - if (summaryTracker && !summaryTracker.add(content)) { - sizeLimitReached = true; - return false; - } - markdown += content; - return true; - } - - // Check for initialization data first - const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); - - if (initEntry && formatInitCallback) { - if (!addContent("## 🚀 Initialization\n\n")) { - return { markdown, commandSummary: [], sizeLimitReached }; - } - const initResult = formatInitCallback(initEntry); - // Handle both string and object returns (for backward compatibility) - if (typeof initResult === "string") { - if (!addContent(initResult)) { - return { markdown, commandSummary: [], sizeLimitReached }; - } - } else if (initResult && initResult.markdown) { - if (!addContent(initResult.markdown)) { - return { markdown, commandSummary: [], sizeLimitReached }; - } - } - if (!addContent("\n")) { - return { markdown, commandSummary: [], sizeLimitReached }; - } - } - - if (!addContent("\n## 🤖 Reasoning\n\n")) { - return { markdown, commandSummary: [], sizeLimitReached }; - } - - // Second pass: process assistant messages in sequence - for (const entry of logEntries) { - if (sizeLimitReached) break; - - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (sizeLimitReached) break; - - if (content.type === "text" && content.text) { - // Add reasoning text directly - let text = content.text.trim(); - // Apply unfencing to remove accidental outer markdown fences - text = unfenceMarkdown(text); - if (text && text.length > 0) { - if (!addContent(text + "\n\n")) { - break; - } - } - } else if (content.type === "tool_use") { - // Process tool use with its result - const toolResult = toolUsePairs.get(content.id); - const toolMarkdown = formatToolCallback(content, toolResult); - if (toolMarkdown) { - if (!addContent(toolMarkdown)) { - break; - } - } - } - } - } - } - - // Add size limit notice if limit was reached - if (sizeLimitReached) { - markdown += SIZE_LIMIT_WARNING; - return { markdown, commandSummary: [], sizeLimitReached }; - } - - if (!addContent("## 🤖 Commands and Tools\n\n")) { - markdown += SIZE_LIMIT_WARNING; - return { markdown, commandSummary: [], sizeLimitReached: true }; - } - - const commandSummary = []; // For the succinct summary - - // Collect all tool uses for summary - for (const entry of logEntries) { - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_use") { - const toolName = content.name; - const input = content.input || {}; - - // Skip internal tools - only show external commands and API calls - if (["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"].includes(toolName)) { - continue; // Skip internal file operations and searches - } - - // Find the corresponding tool result to get status - const toolResult = toolUsePairs.get(content.id); - let statusIcon = "❓"; - if (toolResult) { - statusIcon = toolResult.is_error === true ? "❌" : "✅"; - } - - // Add to command summary (only external tools) - if (toolName === "Bash") { - const formattedCommand = formatBashCommand(input.command || ""); - commandSummary.push(`* ${statusIcon} \`${formattedCommand}\``); - } else if (toolName.startsWith("mcp__")) { - const mcpName = formatMcpName(toolName); - commandSummary.push(`* ${statusIcon} \`${mcpName}(...)\``); - } else { - // Handle other external tools (if any) - commandSummary.push(`* ${statusIcon} ${toolName}`); - } - } - } - } - } - - // Add command summary - if (commandSummary.length > 0) { - for (const cmd of commandSummary) { - if (!addContent(`${cmd}\n`)) { - markdown += SIZE_LIMIT_WARNING; - return { markdown, commandSummary, sizeLimitReached: true }; - } - } - } else { - if (!addContent("No commands or tools used.\n")) { - markdown += SIZE_LIMIT_WARNING; - return { markdown, commandSummary, sizeLimitReached: true }; - } - } - - return { markdown, commandSummary, sizeLimitReached }; -} - /** * Generates information section markdown from the last log entry * @param {any} lastEntry - The last log entry with metadata (num_turns, duration_ms, etc.) @@ -713,161 +541,6 @@ function formatInitializationSummary(initEntry, options = {}) { return { markdown }; } -/** - * Formats a tool use entry with its result into markdown - * @param {any} toolUse - The tool use object containing name, input, etc. - * @param {any} toolResult - The corresponding tool result object - * @param {Object} options - Configuration options - * @param {boolean} [options.includeDetailedParameters] - Whether to include detailed parameter section (default: false) - * @returns {string} Formatted markdown string - */ -function formatToolUse(toolUse, toolResult, options = {}) { - const { includeDetailedParameters = false } = options; - const toolName = toolUse.name; - const input = toolUse.input || {}; - - // Skip TodoWrite except the very last one (we'll handle this separately) - if (toolName === "TodoWrite") { - return ""; // Skip for now, would need global context to find the last one - } - - // Helper function to determine status icon - function getStatusIcon() { - if (toolResult) { - return toolResult.is_error === true ? "❌" : "✅"; - } - return "❓"; // Unknown by default - } - - const statusIcon = getStatusIcon(); - let summary = ""; - let details = ""; - - // Get tool output from result - if (toolResult && toolResult.content) { - if (typeof toolResult.content === "string") { - details = toolResult.content; - } else if (Array.isArray(toolResult.content)) { - details = toolResult.content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n"); - } - } - - // Calculate token estimate from input + output - const inputText = JSON.stringify(input); - const outputText = details; - const totalTokens = estimateTokens(inputText) + estimateTokens(outputText); - - // Format metadata (duration and tokens) - let metadata = ""; - if (toolResult && toolResult.duration_ms) { - metadata += `${formatDuration(toolResult.duration_ms)} `; - } - if (totalTokens > 0) { - metadata += `~${totalTokens}t`; - } - metadata = metadata.trim(); - - // Build the summary based on tool type - switch (toolName) { - case "Bash": - const command = input.command || ""; - const description = input.description || ""; - - // Format the command to be single line - const formattedCommand = formatBashCommand(command); - - if (description) { - summary = `${description}: ${formattedCommand}`; - } else { - summary = `${formattedCommand}`; - } - break; - - case "Read": - const filePath = input.file_path || input.path || ""; - const relativePath = filePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); // Remove /home/runner/work/repo/repo/ prefix - summary = `Read ${relativePath}`; - break; - - case "Write": - case "Edit": - case "MultiEdit": - const writeFilePath = input.file_path || input.path || ""; - const writeRelativePath = writeFilePath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); - summary = `Write ${writeRelativePath}`; - break; - - case "Grep": - case "Glob": - const query = input.query || input.pattern || ""; - summary = `Search for ${truncateString(query, 80)}`; - break; - - case "LS": - const lsPath = input.path || ""; - const lsRelativePath = lsPath.replace(/^\/[^\/]*\/[^\/]*\/[^\/]*\/[^\/]*\//, ""); - summary = `LS: ${lsRelativePath || lsPath}`; - break; - - default: - // Handle MCP calls and other tools - if (toolName.startsWith("mcp__")) { - const mcpName = formatMcpName(toolName); - const params = formatMcpParameters(input); - summary = `${mcpName}(${params})`; - } else { - // Generic tool formatting - show the tool name and main parameters - const keys = Object.keys(input); - if (keys.length > 0) { - // Try to find the most important parameter - const mainParam = keys.find(k => ["query", "command", "path", "file_path", "content"].includes(k)) || keys[0]; - const value = String(input[mainParam] || ""); - - if (value) { - summary = `${toolName}: ${truncateString(value, 100)}`; - } else { - summary = toolName; - } - } else { - summary = toolName; - } - } - } - - // Build sections for formatToolCallAsDetails - /** @type {Array<{label: string, content: string, language?: string}>} */ - const sections = []; - - // For Copilot: include detailed parameters section - if (includeDetailedParameters) { - const inputKeys = Object.keys(input); - if (inputKeys.length > 0) { - sections.push({ - label: "Parameters", - content: JSON.stringify(input, null, 2), - language: "json", - }); - } - } - - // Add response section if we have details - // Note: formatToolCallAsDetails will truncate content to MAX_TOOL_OUTPUT_LENGTH - if (details && details.trim()) { - sections.push({ - label: includeDetailedParameters ? "Response" : "Output", - content: details, - }); - } - - // Use the shared formatToolCallAsDetails helper - return formatToolCallAsDetails({ - summary, - statusIcon, - sections, - metadata: metadata || undefined, - }); -} - /** * Parses log content as JSON array or JSONL format * Handles multiple formats: JSON array, JSONL, and mixed format with debug logs @@ -934,6 +607,21 @@ function parseLogEntries(logContent) { return logEntries; } +const { generateConversationMarkdown, formatToolUse, generatePlainTextSummary, generateCopilotCliStyleSummary } = createLogParserFormatters({ + formatBashCommand, + formatMcpName, + formatToolDisplayName, + formatResultPreview, + formatToolCallAsDetails, + formatMcpParameters, + truncateString, + estimateTokens, + formatDuration, + unfenceMarkdown, + MAX_AGENT_TEXT_LENGTH, + SIZE_LIMIT_WARNING, +}); + /** * Generic helper to format a tool call as an HTML details section. * This is a reusable helper for all code engines (Claude, Copilot, Codex). @@ -1076,425 +764,6 @@ function formatResultPreview(resultText, maxLineLength = 80) { return ` ├ ${firstLine}\n └ ${secondLine} (+ ${nonEmptyLineCount - 2} more)`; } -/** - * Generates a lightweight plain text summary optimized for raw text rendering. - * This is designed for console output (core.info) instead of markdown step summaries. - * - * The output includes: - * - A compact header with model info - * - Agent conversation with response text and tool executions - * - Basic execution statistics - * - * @param {Array} logEntries - Array of log entries with type, message, etc. - * @param {Object} options - Configuration options - * @param {string} [options.model] - Model name to include in the header - * @param {string} [options.parserName] - Name of the parser (e.g., "Copilot", "Claude") - * @returns {string} Plain text summary for console output - */ -function generatePlainTextSummary(logEntries, options = {}) { - const { model, parserName = "Agent" } = options; - const lines = []; - - // Header - lines.push(`=== ${parserName} Execution Summary ===`); - if (model) { - lines.push(`Model: ${model}`); - } - lines.push(""); - - // Collect tool usage pairs for status lookup - const toolUsePairs = new Map(); - for (const entry of logEntries) { - if (entry.type === "user" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_result" && content.tool_use_id) { - toolUsePairs.set(content.tool_use_id, content); - } - } - } - } - - // Generate conversation flow with agent responses and tool executions - lines.push("Conversation:"); - lines.push(""); - - let conversationLineCount = 0; - const MAX_CONVERSATION_LINES = 5000; // Limit conversation output - let conversationTruncated = false; - - for (const entry of logEntries) { - if (conversationLineCount >= MAX_CONVERSATION_LINES) { - conversationTruncated = true; - break; - } - - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (conversationLineCount >= MAX_CONVERSATION_LINES) { - conversationTruncated = true; - break; - } - - if (content.type === "text" && content.text) { - // Display agent response text - let text = content.text.trim(); - // Apply unfencing to remove accidental outer markdown fences - text = unfenceMarkdown(text); - if (text && text.length > 0) { - // Truncate long responses to keep output manageable - let displayText = text; - if (displayText.length > MAX_AGENT_TEXT_LENGTH) { - displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`; - } - - // Split into lines: first line gets "◆ " prefix, continuation lines are indented - const textLines = displayText.split("\n"); - for (let i = 0; i < textLines.length; i++) { - if (conversationLineCount >= MAX_CONVERSATION_LINES) { - conversationTruncated = true; - break; - } - const prefix = i === 0 ? "◆ " : " "; - lines.push(`${prefix}${textLines[i]}`); - conversationLineCount++; - } - lines.push(""); // Add blank line after agent response - conversationLineCount++; - } - } else if (content.type === "tool_use") { - // Display tool execution - const toolName = content.name; - const input = content.input || {}; - - // Skip internal tools (file operations) - if (["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"].includes(toolName)) { - continue; - } - - const toolResult = toolUsePairs.get(content.id); - const isError = toolResult?.is_error === true; - const statusIcon = isError ? "✗" : "✓"; - - // Format tool execution in Copilot CLI style - let displayName; - let resultPreview = ""; - - if (toolName === "Bash") { - const cmd = formatBashCommand(input.command || ""); - displayName = `$ ${cmd}`; - - // Show first 2 lines of result using copilot-cli tree-branch style - if (toolResult && toolResult.content) { - const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content); - resultPreview = formatResultPreview(resultText); - } - } else if (toolName.startsWith("mcp__")) { - // Format MCP tool names like github-list_pull_requests - const formattedName = formatMcpName(toolName).replace("::", "-"); - displayName = formatToolDisplayName(formattedName, input); - - // Show first 2 lines of result using copilot-cli tree-branch style - if (toolResult && toolResult.content) { - const resultText = typeof toolResult.content === "string" ? toolResult.content : JSON.stringify(toolResult.content); - resultPreview = formatResultPreview(resultText); - } - } else { - displayName = formatToolDisplayName(toolName, input); - - // Show first 2 lines of result using copilot-cli tree-branch style - if (toolResult && toolResult.content) { - const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content); - resultPreview = formatResultPreview(resultText); - } - } - - lines.push(`${statusIcon} ${displayName}`); - conversationLineCount++; - - if (resultPreview) { - lines.push(resultPreview); - conversationLineCount += resultPreview.split("\n").length; - } - - lines.push(""); // Add blank line after tool execution - conversationLineCount++; - } - } - } - } - - if (conversationTruncated) { - lines.push("... (conversation truncated)"); - lines.push(""); - } - - // Statistics - const lastEntry = logEntries[logEntries.length - 1]; - lines.push("Statistics:"); - if (lastEntry?.num_turns) { - lines.push(` Turns: ${lastEntry.num_turns}`); - } - if (lastEntry?.duration_ms) { - const duration = formatDuration(lastEntry.duration_ms); - if (duration) { - lines.push(` Duration: ${duration}`); - } - } - - // Count tools for statistics - let toolCounts = { total: 0, success: 0, error: 0 }; - for (const entry of logEntries) { - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_use") { - const toolName = content.name; - // Skip internal tools - if (["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"].includes(toolName)) { - continue; - } - toolCounts.total++; - const toolResult = toolUsePairs.get(content.id); - const isError = toolResult?.is_error === true; - if (isError) { - toolCounts.error++; - } else { - toolCounts.success++; - } - } - } - } - } - - if (toolCounts.total > 0) { - lines.push(` Tools: ${toolCounts.success}/${toolCounts.total} succeeded`); - } - if (lastEntry?.usage) { - const usage = lastEntry.usage; - if (usage.input_tokens || usage.output_tokens) { - // Calculate total tokens (matching Go parser logic) - const inputTokens = usage.input_tokens || 0; - const outputTokens = usage.output_tokens || 0; - const cacheCreationTokens = usage.cache_creation_input_tokens || 0; - const cacheReadTokens = usage.cache_read_input_tokens || 0; - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - - lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`); - } - } - if (lastEntry?.total_cost_usd) { - lines.push(` Cost: $${lastEntry.total_cost_usd.toFixed(4)}`); - } - - return lines.join("\n"); -} - -/** - * Generates a markdown-formatted Copilot CLI style summary for step summaries. - * Similar to generatePlainTextSummary but outputs markdown with code blocks for proper rendering. - * - * The output includes: - * - A "Conversation:" section showing agent responses and tool executions - * - A "Statistics:" section with execution metrics - * - * @param {Array} logEntries - Array of log entries with type, message, etc. - * @param {Object} options - Configuration options - * @param {string} [options.model] - Model name to include in the header - * @param {string} [options.parserName] - Name of the parser (e.g., "Copilot", "Claude") - * @returns {string} Markdown-formatted summary for step summary rendering - */ -function generateCopilotCliStyleSummary(logEntries, options = {}) { - const { model, parserName = "Agent" } = options; - const lines = []; - - // Collect tool usage pairs for status lookup - const toolUsePairs = new Map(); - for (const entry of logEntries) { - if (entry.type === "user" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_result" && content.tool_use_id) { - toolUsePairs.set(content.tool_use_id, content); - } - } - } - } - - // Generate conversation flow with agent responses and tool executions - lines.push("```"); - lines.push("Conversation:"); - lines.push(""); - - let conversationLineCount = 0; - const MAX_CONVERSATION_LINES = 5000; // Limit conversation output - let conversationTruncated = false; - - for (const entry of logEntries) { - if (conversationLineCount >= MAX_CONVERSATION_LINES) { - conversationTruncated = true; - break; - } - - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (conversationLineCount >= MAX_CONVERSATION_LINES) { - conversationTruncated = true; - break; - } - - if (content.type === "text" && content.text) { - // Display agent response text - let text = content.text.trim(); - // Apply unfencing to remove accidental outer markdown fences - text = unfenceMarkdown(text); - if (text && text.length > 0) { - // Truncate long responses to keep output manageable - let displayText = text; - if (displayText.length > MAX_AGENT_TEXT_LENGTH) { - displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`; - } - - // Split into lines: first line gets "◆ " prefix, continuation lines are indented - const textLines = displayText.split("\n"); - for (let i = 0; i < textLines.length; i++) { - if (conversationLineCount >= MAX_CONVERSATION_LINES) { - conversationTruncated = true; - break; - } - const prefix = i === 0 ? "◆ " : " "; - lines.push(`${prefix}${textLines[i]}`); - conversationLineCount++; - } - lines.push(""); // Add blank line after agent response - conversationLineCount++; - } - } else if (content.type === "tool_use") { - // Display tool execution - const toolName = content.name; - const input = content.input || {}; - - // Skip internal tools (file operations) - if (["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"].includes(toolName)) { - continue; - } - - const toolResult = toolUsePairs.get(content.id); - const isError = toolResult?.is_error === true; - const statusIcon = isError ? "✗" : "✓"; - - // Format tool execution in Copilot CLI style - let displayName; - let resultPreview = ""; - - if (toolName === "Bash") { - const cmd = formatBashCommand(input.command || ""); - displayName = `$ ${cmd}`; - - // Show first 2 lines of result using copilot-cli tree-branch style - if (toolResult && toolResult.content) { - const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content); - resultPreview = formatResultPreview(resultText); - } - } else if (toolName.startsWith("mcp__")) { - // Format MCP tool names like github-list_pull_requests - const formattedName = formatMcpName(toolName).replace("::", "-"); - displayName = formatToolDisplayName(formattedName, input); - - // Show first 2 lines of result using copilot-cli tree-branch style - if (toolResult && toolResult.content) { - const resultText = typeof toolResult.content === "string" ? toolResult.content : JSON.stringify(toolResult.content); - resultPreview = formatResultPreview(resultText); - } - } else { - displayName = formatToolDisplayName(toolName, input); - - // Show first 2 lines of result using copilot-cli tree-branch style - if (toolResult && toolResult.content) { - const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content); - resultPreview = formatResultPreview(resultText); - } - } - - lines.push(`${statusIcon} ${displayName}`); - conversationLineCount++; - - if (resultPreview) { - lines.push(resultPreview); - conversationLineCount += resultPreview.split("\n").length; - } - - lines.push(""); // Add blank line after tool execution - conversationLineCount++; - } - } - } - } - - if (conversationTruncated) { - lines.push("... (conversation truncated)"); - lines.push(""); - } - - // Statistics - const lastEntry = logEntries[logEntries.length - 1]; - lines.push("Statistics:"); - if (lastEntry?.num_turns) { - lines.push(` Turns: ${lastEntry.num_turns}`); - } - if (lastEntry?.duration_ms) { - const duration = formatDuration(lastEntry.duration_ms); - if (duration) { - lines.push(` Duration: ${duration}`); - } - } - - // Count tools for statistics - let toolCounts = { total: 0, success: 0, error: 0 }; - for (const entry of logEntries) { - if (entry.type === "assistant" && entry.message?.content) { - for (const content of entry.message.content) { - if (content.type === "tool_use") { - const toolName = content.name; - // Skip internal tools - if (["Read", "Write", "Edit", "MultiEdit", "LS", "Grep", "Glob", "TodoWrite"].includes(toolName)) { - continue; - } - toolCounts.total++; - const toolResult = toolUsePairs.get(content.id); - const isError = toolResult?.is_error === true; - if (isError) { - toolCounts.error++; - } else { - toolCounts.success++; - } - } - } - } - } - - if (toolCounts.total > 0) { - lines.push(` Tools: ${toolCounts.success}/${toolCounts.total} succeeded`); - } - if (lastEntry?.usage) { - const usage = lastEntry.usage; - if (usage.input_tokens || usage.output_tokens) { - // Calculate total tokens (matching Go parser logic) - const inputTokens = usage.input_tokens || 0; - const outputTokens = usage.output_tokens || 0; - const cacheCreationTokens = usage.cache_creation_input_tokens || 0; - const cacheReadTokens = usage.cache_read_input_tokens || 0; - const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - - lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`); - } - } - if (lastEntry?.total_cost_usd) { - lines.push(` Cost: $${lastEntry.total_cost_usd.toFixed(4)}`); - } - - lines.push("```"); - - return lines.join("\n"); -} - /** * Wraps agent log markdown in a details/summary section * @param {string} markdown - The agent log markdown content diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index 62fc249372..6f13be853f 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -1009,4 +1009,3 @@ func TestSpec_DesignDecision_StderrDiagnostics(t *testing.T) { names := ValidArtifactSetNames() assert.NotEmpty(t, names, "ValidArtifactSetNames returns data via return value, not stdout") } -