From 188b4a5b8f437234b7c469b0c94d28cd6413a596 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:43:23 +0000 Subject: [PATCH 1/3] feat: improve action log rendering with 2-line tool previews and nicer agent messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add formatResultPreview() helper that shows first 2 lines of tool output using tree-branch characters (├/└) for visual hierarchy - Update generateCopilotCliStyleSummary() to use new helper for all tool types - Update generatePlainTextSummary() to use new helper for all tool types - Change agent message prefix from "Agent: " (per-line) to "◆ " (first line) with " " indent for continuation lines, for a cleaner visual style - Count multi-line resultPreview accurately in conversationLineCount - Export formatResultPreview from module for potential reuse - Update all tests to match new formatting Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e919a297-8597-4b17-97b2-aea5d6289e8b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/log_parser_bootstrap.test.cjs | 2 +- actions/setup/js/log_parser_shared.cjs | 95 +++++++++++-------- actions/setup/js/log_parser_shared.test.cjs | 27 +++--- 3 files changed, 70 insertions(+), 54 deletions(-) diff --git a/actions/setup/js/log_parser_bootstrap.test.cjs b/actions/setup/js/log_parser_bootstrap.test.cjs index 3e9cd8c9ff6..a4095602450 100644 --- a/actions/setup/js/log_parser_bootstrap.test.cjs +++ b/actions/setup/js/log_parser_bootstrap.test.cjs @@ -98,7 +98,7 @@ describe("log_parser_bootstrap.cjs", () => { (expect(summaryCall).toBeDefined(), expect(summaryCall[0]).toContain("```"), expect(summaryCall[0]).toContain("Conversation:"), - expect(summaryCall[0]).toContain("Agent: Hello"), + expect(summaryCall[0]).toContain("◆ Hello"), expect(summaryCall[0]).toContain("Statistics:"), expect(summaryCall[0]).toContain(" Turns: 2"), expect(summaryCall[0]).toContain(" Duration: 5s"), diff --git a/actions/setup/js/log_parser_shared.cjs b/actions/setup/js/log_parser_shared.cjs index 372e43e32df..8601d62a1c8 100644 --- a/actions/setup/js/log_parser_shared.cjs +++ b/actions/setup/js/log_parser_shared.cjs @@ -994,6 +994,37 @@ function formatToolCallAsDetails(options) { return `
\n${fullSummary}\n\n${detailsContent}\n
\n\n`; } +/** + * Formats a tool result content into a preview string showing the first 2 non-empty lines. + * Uses tree-branch characters (├, └) for visual hierarchy in copilot-cli style. + * + * Examples: + * 1 line: " └ result text" + * 2 lines: " ├ line 1\n └ line 2" + * 3+ lines: " ├ line 1\n └ line 2 (+ 1 more)" + * + * @param {string} resultText - The result text to preview + * @param {number} [maxLineLength=80] - Maximum characters per preview line + * @returns {string} Formatted preview string, or empty string if no content + */ +function formatResultPreview(resultText, maxLineLength = 80) { + if (!resultText) return ""; + const resultLines = resultText.split("\n").filter(l => l.trim()); + if (resultLines.length === 0) return ""; + + const line1 = resultLines[0].substring(0, maxLineLength); + if (resultLines.length === 1) { + return ` └ ${line1}`; + } + + const line2 = resultLines[1].substring(0, maxLineLength); + if (resultLines.length === 2) { + return ` ├ ${line1}\n └ ${line2}`; + } + + return ` ├ ${line1}\n └ ${line2} (+ ${resultLines.length - 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. @@ -1065,14 +1096,15 @@ function generatePlainTextSummary(logEntries, options = {}) { displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`; } - // Split into lines and add Agent prefix + // Split into lines: first line gets "◆ " prefix, continuation lines are indented const textLines = displayText.split("\n"); - for (const line of textLines) { + for (let i = 0; i < textLines.length; i++) { if (conversationLineCount >= MAX_CONVERSATION_LINES) { conversationTruncated = true; break; } - lines.push(`Agent: ${line}`); + const prefix = i === 0 ? "◆ " : " "; + lines.push(`${prefix}${textLines[i]}`); conversationLineCount++; } lines.push(""); // Add blank line after agent response @@ -1100,38 +1132,28 @@ function generatePlainTextSummary(logEntries, options = {}) { const cmd = formatBashCommand(input.command || ""); displayName = `$ ${cmd}`; - // Show result preview if available + // 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); - const resultLines = resultText.split("\n").filter(l => l.trim()); - if (resultLines.length > 0) { - const previewLine = resultLines[0].substring(0, 80); - if (resultLines.length > 1) { - resultPreview = ` └ ${resultLines.length} lines...`; - } else if (previewLine) { - resultPreview = ` └ ${previewLine}`; - } - } + 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 result preview if available + // 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); - const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText; - resultPreview = ` └ ${truncated}`; + resultPreview = formatResultPreview(resultText); } } else { displayName = formatToolDisplayName(toolName, input); - // Show result preview if available + // 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); - const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText; - resultPreview = ` └ ${truncated}`; + resultPreview = formatResultPreview(resultText); } } @@ -1140,7 +1162,7 @@ function generatePlainTextSummary(logEntries, options = {}) { if (resultPreview) { lines.push(resultPreview); - conversationLineCount++; + conversationLineCount += resultPreview.split("\n").length; } lines.push(""); // Add blank line after tool execution @@ -1279,14 +1301,15 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) { displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`; } - // Split into lines and add Agent prefix + // Split into lines: first line gets "◆ " prefix, continuation lines are indented const textLines = displayText.split("\n"); - for (const line of textLines) { + for (let i = 0; i < textLines.length; i++) { if (conversationLineCount >= MAX_CONVERSATION_LINES) { conversationTruncated = true; break; } - lines.push(`Agent: ${line}`); + const prefix = i === 0 ? "◆ " : " "; + lines.push(`${prefix}${textLines[i]}`); conversationLineCount++; } lines.push(""); // Add blank line after agent response @@ -1314,38 +1337,28 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) { const cmd = formatBashCommand(input.command || ""); displayName = `$ ${cmd}`; - // Show result preview if available + // 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); - const resultLines = resultText.split("\n").filter(l => l.trim()); - if (resultLines.length > 0) { - const previewLine = resultLines[0].substring(0, 80); - if (resultLines.length > 1) { - resultPreview = ` └ ${resultLines.length} lines...`; - } else if (previewLine) { - resultPreview = ` └ ${previewLine}`; - } - } + 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 result preview if available + // 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); - const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText; - resultPreview = ` └ ${truncated}`; + resultPreview = formatResultPreview(resultText); } } else { displayName = formatToolDisplayName(toolName, input); - // Show result preview if available + // 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); - const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText; - resultPreview = ` └ ${truncated}`; + resultPreview = formatResultPreview(resultText); } } @@ -1354,7 +1367,7 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) { if (resultPreview) { lines.push(resultPreview); - conversationLineCount++; + conversationLineCount += resultPreview.split("\n").length; } lines.push(""); // Add blank line after tool execution @@ -1370,6 +1383,7 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) { } // Statistics + const lastEntry = logEntries[logEntries.length - 1]; lines.push("Statistics:"); if (lastEntry?.num_turns) { @@ -1628,6 +1642,7 @@ module.exports = { formatToolUse, parseLogEntries, formatToolCallAsDetails, + formatResultPreview, generatePlainTextSummary, generateCopilotCliStyleSummary, wrapAgentLogInSection, diff --git a/actions/setup/js/log_parser_shared.test.cjs b/actions/setup/js/log_parser_shared.test.cjs index e8465ea962e..93ca36f0a6f 100644 --- a/actions/setup/js/log_parser_shared.test.cjs +++ b/actions/setup/js/log_parser_shared.test.cjs @@ -1742,9 +1742,9 @@ describe("log_parser_shared.cjs", () => { const result = generatePlainTextSummary(logEntries, { parserName: "Agent" }); expect(result).toContain("Conversation:"); - expect(result).toContain("Agent: I'll help you with that task."); + expect(result).toContain("◆ I'll help you with that task."); expect(result).toContain("✓ $ echo hello"); - expect(result).toContain("Agent: The command executed successfully!"); + expect(result).toContain("◆ The command executed successfully!"); }); it("should truncate long agent responses", async () => { @@ -1764,7 +1764,7 @@ describe("log_parser_shared.cjs", () => { const result = generatePlainTextSummary(logEntries, { parserName: "Agent" }); - expect(result).toContain("Agent: " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]"); + expect(result).toContain("◆ " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]"); expect(result).not.toContain("a".repeat(2001)); }); @@ -1784,9 +1784,9 @@ describe("log_parser_shared.cjs", () => { const result = generatePlainTextSummary(logEntries, { parserName: "Agent" }); - expect(result).toContain("Agent: Line 1"); - expect(result).toContain("Agent: Line 2"); - expect(result).toContain("Agent: Line 3"); + expect(result).toContain("◆ Line 1"); + expect(result).toContain(" Line 2"); + expect(result).toContain(" Line 3"); }); }); @@ -1827,11 +1827,12 @@ describe("log_parser_shared.cjs", () => { expect(result).toContain("Conversation:"); // Check for Agent message - expect(result).toContain("Agent: I'll help you explore the repository structure first."); + expect(result).toContain("◆ I'll help you explore the repository structure first."); - // Check for tool execution with success icon + // Check for tool execution with success icon and first 2 lines of output expect(result).toContain("✓ $ ls -la"); - expect(result).toContain(" └ 3 lines..."); + expect(result).toContain(" ├ file1.txt"); + expect(result).toContain(" └ file2.txt (+ 1 more)"); // Check for Statistics section expect(result).toContain("Statistics:"); @@ -1884,7 +1885,7 @@ describe("log_parser_shared.cjs", () => { const result = generateCopilotCliStyleSummary(logEntries, { parserName: "Agent" }); - expect(result).toContain("Agent: " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]"); + expect(result).toContain("◆ " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]"); }); it("should skip internal file operation tools", async () => { @@ -1940,9 +1941,9 @@ describe("log_parser_shared.cjs", () => { const result = generateCopilotCliStyleSummary(logEntries, { parserName: "Agent" }); - expect(result).toContain("Agent: Line 1"); - expect(result).toContain("Agent: Line 2"); - expect(result).toContain("Agent: Line 3"); + expect(result).toContain("◆ Line 1"); + expect(result).toContain(" Line 2"); + expect(result).toContain(" Line 3"); }); it("should truncate conversation when it exceeds max lines", async () => { From fc790bb720fc63e671924321f144b7d2900f50d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 18:44:19 +0000 Subject: [PATCH 2/3] fix: remove extra blank line before Statistics comment in generateCopilotCliStyleSummary Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e919a297-8597-4b17-97b2-aea5d6289e8b Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/log_parser_shared.cjs | 1 - 1 file changed, 1 deletion(-) diff --git a/actions/setup/js/log_parser_shared.cjs b/actions/setup/js/log_parser_shared.cjs index 8601d62a1c8..2b22a2e5e37 100644 --- a/actions/setup/js/log_parser_shared.cjs +++ b/actions/setup/js/log_parser_shared.cjs @@ -1383,7 +1383,6 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) { } // Statistics - const lastEntry = logEntries[logEntries.length - 1]; lines.push("Statistics:"); if (lastEntry?.num_turns) { From c99c027c61e284ef1c2bad10a3bd7b41eabd7658 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:18:11 +0000 Subject: [PATCH 3/3] refactor: improve formatResultPreview - CRLF handling, efficient scanning, truncation ellipsis - Normalize Windows CRLF line endings by stripping trailing \r from each line - Replace full array allocation with line-by-line scanning to avoid memory pressure for large tool outputs (only first 2 non-empty lines needed) - Add "..." ellipsis when a preview line is truncated at maxLineLength - Add 8 unit tests covering all new behaviours Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b80da586-4cc8-45c6-9e54-a9d5876ffa93 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/log_parser_shared.cjs | 44 ++++++++++++--- actions/setup/js/log_parser_shared.test.cjs | 62 +++++++++++++++++++++ 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/log_parser_shared.cjs b/actions/setup/js/log_parser_shared.cjs index 2b22a2e5e37..e7a23fd410d 100644 --- a/actions/setup/js/log_parser_shared.cjs +++ b/actions/setup/js/log_parser_shared.cjs @@ -1009,20 +1009,46 @@ function formatToolCallAsDetails(options) { */ function formatResultPreview(resultText, maxLineLength = 80) { if (!resultText) return ""; - const resultLines = resultText.split("\n").filter(l => l.trim()); - if (resultLines.length === 0) return ""; - const line1 = resultLines[0].substring(0, maxLineLength); - if (resultLines.length === 1) { - return ` └ ${line1}`; + // Scan line-by-line to avoid building a full array for large outputs. + // Normalize CRLF by stripping trailing \r from each line. + let firstLine = ""; + let secondLine = ""; + let nonEmptyLineCount = 0; + let start = 0; + + while (start <= resultText.length) { + const newlineIndex = resultText.indexOf("\n", start); + const end = newlineIndex === -1 ? resultText.length : newlineIndex; + // Strip trailing \r to handle Windows CRLF line endings + const rawLine = resultText.substring(start, end).replace(/\r$/, ""); + + if (rawLine.trim()) { + nonEmptyLineCount += 1; + if (nonEmptyLineCount === 1) { + const truncated = rawLine.substring(0, maxLineLength); + firstLine = rawLine.length > maxLineLength ? truncated + "..." : truncated; + } else if (nonEmptyLineCount === 2) { + const truncated = rawLine.substring(0, maxLineLength); + secondLine = rawLine.length > maxLineLength ? truncated + "..." : truncated; + } + } + + if (newlineIndex === -1) { + break; + } + start = newlineIndex + 1; } - const line2 = resultLines[1].substring(0, maxLineLength); - if (resultLines.length === 2) { - return ` ├ ${line1}\n └ ${line2}`; + if (nonEmptyLineCount === 0) return ""; + if (nonEmptyLineCount === 1) { + return ` └ ${firstLine}`; + } + if (nonEmptyLineCount === 2) { + return ` ├ ${firstLine}\n └ ${secondLine}`; } - return ` ├ ${line1}\n └ ${line2} (+ ${resultLines.length - 2} more)`; + return ` ├ ${firstLine}\n └ ${secondLine} (+ ${nonEmptyLineCount - 2} more)`; } /** diff --git a/actions/setup/js/log_parser_shared.test.cjs b/actions/setup/js/log_parser_shared.test.cjs index 93ca36f0a6f..de09bef08d9 100644 --- a/actions/setup/js/log_parser_shared.test.cjs +++ b/actions/setup/js/log_parser_shared.test.cjs @@ -1971,6 +1971,68 @@ describe("log_parser_shared.cjs", () => { }); }); + describe("formatResultPreview", () => { + it("should return empty string for empty or falsy input", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + expect(formatResultPreview("")).toBe(""); + expect(formatResultPreview(null)).toBe(""); + expect(formatResultPreview(undefined)).toBe(""); + expect(formatResultPreview(" \n \n ")).toBe(""); + }); + + it("should format single non-empty line with └", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + expect(formatResultPreview("hello")).toBe(" └ hello"); + expect(formatResultPreview("\nhello\n")).toBe(" └ hello"); + }); + + it("should format exactly two non-empty lines with ├ and └", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + expect(formatResultPreview("line1\nline2")).toBe(" ├ line1\n └ line2"); + }); + + it("should show (+ N more) for three or more non-empty lines", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + expect(formatResultPreview("line1\nline2\nline3")).toBe(" ├ line1\n └ line2 (+ 1 more)"); + expect(formatResultPreview("line1\nline2\nline3\nline4\nline5")).toBe(" ├ line1\n └ line2 (+ 3 more)"); + }); + + it("should truncate lines exceeding maxLineLength and append ellipsis", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + const longLine = "a".repeat(100); + const result = formatResultPreview(longLine, 80); + expect(result).toBe(` └ ${"a".repeat(80)}...`); + }); + + it("should not add ellipsis when line exactly fits maxLineLength", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + const exactLine = "a".repeat(80); + const result = formatResultPreview(exactLine, 80); + expect(result).toBe(` └ ${"a".repeat(80)}`); + expect(result).not.toContain("..."); + }); + + it("should handle Windows CRLF line endings without trailing \\r", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + expect(formatResultPreview("line1\r\nline2\r\n")).toBe(" ├ line1\n └ line2"); + expect(formatResultPreview("only\r\n")).toBe(" └ only"); + }); + + it("should skip blank lines when counting", async () => { + const { formatResultPreview } = await import("./log_parser_shared.cjs"); + + expect(formatResultPreview("\n\nfirst\n\nsecond\n\n")).toBe(" ├ first\n └ second"); + expect(formatResultPreview("\n\nonly\n\n")).toBe(" └ only"); + }); + }); + describe("formatSafeOutputsPreview", () => { it("should return empty string for empty content", async () => { const { formatSafeOutputsPreview } = await import("./log_parser_shared.cjs");