Skip to content

Commit b361d25

Browse files
authored
Improve action log step summary: 2-line tool previews and nicer agent messages (#24558)
1 parent dbfd553 commit b361d25

3 files changed

Lines changed: 157 additions & 54 deletions

File tree

actions/setup/js/log_parser_bootstrap.test.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe("log_parser_bootstrap.cjs", () => {
9898
(expect(summaryCall).toBeDefined(),
9999
expect(summaryCall[0]).toContain("```"),
100100
expect(summaryCall[0]).toContain("Conversation:"),
101-
expect(summaryCall[0]).toContain("Agent: Hello"),
101+
expect(summaryCall[0]).toContain(" Hello"),
102102
expect(summaryCall[0]).toContain("Statistics:"),
103103
expect(summaryCall[0]).toContain(" Turns: 2"),
104104
expect(summaryCall[0]).toContain(" Duration: 5s"),

actions/setup/js/log_parser_shared.cjs

Lines changed: 80 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,63 @@ function formatToolCallAsDetails(options) {
994994
return `<details>\n<summary>${fullSummary}</summary>\n\n${detailsContent}\n</details>\n\n`;
995995
}
996996

997+
/**
998+
* Formats a tool result content into a preview string showing the first 2 non-empty lines.
999+
* Uses tree-branch characters (├, └) for visual hierarchy in copilot-cli style.
1000+
*
1001+
* Examples:
1002+
* 1 line: " └ result text"
1003+
* 2 lines: " ├ line 1\n └ line 2"
1004+
* 3+ lines: " ├ line 1\n └ line 2 (+ 1 more)"
1005+
*
1006+
* @param {string} resultText - The result text to preview
1007+
* @param {number} [maxLineLength=80] - Maximum characters per preview line
1008+
* @returns {string} Formatted preview string, or empty string if no content
1009+
*/
1010+
function formatResultPreview(resultText, maxLineLength = 80) {
1011+
if (!resultText) return "";
1012+
1013+
// Scan line-by-line to avoid building a full array for large outputs.
1014+
// Normalize CRLF by stripping trailing \r from each line.
1015+
let firstLine = "";
1016+
let secondLine = "";
1017+
let nonEmptyLineCount = 0;
1018+
let start = 0;
1019+
1020+
while (start <= resultText.length) {
1021+
const newlineIndex = resultText.indexOf("\n", start);
1022+
const end = newlineIndex === -1 ? resultText.length : newlineIndex;
1023+
// Strip trailing \r to handle Windows CRLF line endings
1024+
const rawLine = resultText.substring(start, end).replace(/\r$/, "");
1025+
1026+
if (rawLine.trim()) {
1027+
nonEmptyLineCount += 1;
1028+
if (nonEmptyLineCount === 1) {
1029+
const truncated = rawLine.substring(0, maxLineLength);
1030+
firstLine = rawLine.length > maxLineLength ? truncated + "..." : truncated;
1031+
} else if (nonEmptyLineCount === 2) {
1032+
const truncated = rawLine.substring(0, maxLineLength);
1033+
secondLine = rawLine.length > maxLineLength ? truncated + "..." : truncated;
1034+
}
1035+
}
1036+
1037+
if (newlineIndex === -1) {
1038+
break;
1039+
}
1040+
start = newlineIndex + 1;
1041+
}
1042+
1043+
if (nonEmptyLineCount === 0) return "";
1044+
if (nonEmptyLineCount === 1) {
1045+
return ` └ ${firstLine}`;
1046+
}
1047+
if (nonEmptyLineCount === 2) {
1048+
return ` ├ ${firstLine}\n └ ${secondLine}`;
1049+
}
1050+
1051+
return ` ├ ${firstLine}\n └ ${secondLine} (+ ${nonEmptyLineCount - 2} more)`;
1052+
}
1053+
9971054
/**
9981055
* Generates a lightweight plain text summary optimized for raw text rendering.
9991056
* This is designed for console output (core.info) instead of markdown step summaries.
@@ -1065,14 +1122,15 @@ function generatePlainTextSummary(logEntries, options = {}) {
10651122
displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`;
10661123
}
10671124

1068-
// Split into lines and add Agent prefix
1125+
// Split into lines: first line gets "◆ " prefix, continuation lines are indented
10691126
const textLines = displayText.split("\n");
1070-
for (const line of textLines) {
1127+
for (let i = 0; i < textLines.length; i++) {
10711128
if (conversationLineCount >= MAX_CONVERSATION_LINES) {
10721129
conversationTruncated = true;
10731130
break;
10741131
}
1075-
lines.push(`Agent: ${line}`);
1132+
const prefix = i === 0 ? "◆ " : " ";
1133+
lines.push(`${prefix}${textLines[i]}`);
10761134
conversationLineCount++;
10771135
}
10781136
lines.push(""); // Add blank line after agent response
@@ -1100,38 +1158,28 @@ function generatePlainTextSummary(logEntries, options = {}) {
11001158
const cmd = formatBashCommand(input.command || "");
11011159
displayName = `$ ${cmd}`;
11021160

1103-
// Show result preview if available
1161+
// Show first 2 lines of result using copilot-cli tree-branch style
11041162
if (toolResult && toolResult.content) {
11051163
const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content);
1106-
const resultLines = resultText.split("\n").filter(l => l.trim());
1107-
if (resultLines.length > 0) {
1108-
const previewLine = resultLines[0].substring(0, 80);
1109-
if (resultLines.length > 1) {
1110-
resultPreview = ` └ ${resultLines.length} lines...`;
1111-
} else if (previewLine) {
1112-
resultPreview = ` └ ${previewLine}`;
1113-
}
1114-
}
1164+
resultPreview = formatResultPreview(resultText);
11151165
}
11161166
} else if (toolName.startsWith("mcp__")) {
11171167
// Format MCP tool names like github-list_pull_requests
11181168
const formattedName = formatMcpName(toolName).replace("::", "-");
11191169
displayName = formatToolDisplayName(formattedName, input);
11201170

1121-
// Show result preview if available
1171+
// Show first 2 lines of result using copilot-cli tree-branch style
11221172
if (toolResult && toolResult.content) {
11231173
const resultText = typeof toolResult.content === "string" ? toolResult.content : JSON.stringify(toolResult.content);
1124-
const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText;
1125-
resultPreview = ` └ ${truncated}`;
1174+
resultPreview = formatResultPreview(resultText);
11261175
}
11271176
} else {
11281177
displayName = formatToolDisplayName(toolName, input);
11291178

1130-
// Show result preview if available
1179+
// Show first 2 lines of result using copilot-cli tree-branch style
11311180
if (toolResult && toolResult.content) {
11321181
const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content);
1133-
const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText;
1134-
resultPreview = ` └ ${truncated}`;
1182+
resultPreview = formatResultPreview(resultText);
11351183
}
11361184
}
11371185

@@ -1140,7 +1188,7 @@ function generatePlainTextSummary(logEntries, options = {}) {
11401188

11411189
if (resultPreview) {
11421190
lines.push(resultPreview);
1143-
conversationLineCount++;
1191+
conversationLineCount += resultPreview.split("\n").length;
11441192
}
11451193

11461194
lines.push(""); // Add blank line after tool execution
@@ -1279,14 +1327,15 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) {
12791327
displayText = displayText.substring(0, MAX_AGENT_TEXT_LENGTH) + `... [truncated: showing first ${MAX_AGENT_TEXT_LENGTH} of ${text.length} chars]`;
12801328
}
12811329

1282-
// Split into lines and add Agent prefix
1330+
// Split into lines: first line gets "◆ " prefix, continuation lines are indented
12831331
const textLines = displayText.split("\n");
1284-
for (const line of textLines) {
1332+
for (let i = 0; i < textLines.length; i++) {
12851333
if (conversationLineCount >= MAX_CONVERSATION_LINES) {
12861334
conversationTruncated = true;
12871335
break;
12881336
}
1289-
lines.push(`Agent: ${line}`);
1337+
const prefix = i === 0 ? "◆ " : " ";
1338+
lines.push(`${prefix}${textLines[i]}`);
12901339
conversationLineCount++;
12911340
}
12921341
lines.push(""); // Add blank line after agent response
@@ -1314,38 +1363,28 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) {
13141363
const cmd = formatBashCommand(input.command || "");
13151364
displayName = `$ ${cmd}`;
13161365

1317-
// Show result preview if available
1366+
// Show first 2 lines of result using copilot-cli tree-branch style
13181367
if (toolResult && toolResult.content) {
13191368
const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content);
1320-
const resultLines = resultText.split("\n").filter(l => l.trim());
1321-
if (resultLines.length > 0) {
1322-
const previewLine = resultLines[0].substring(0, 80);
1323-
if (resultLines.length > 1) {
1324-
resultPreview = ` └ ${resultLines.length} lines...`;
1325-
} else if (previewLine) {
1326-
resultPreview = ` └ ${previewLine}`;
1327-
}
1328-
}
1369+
resultPreview = formatResultPreview(resultText);
13291370
}
13301371
} else if (toolName.startsWith("mcp__")) {
13311372
// Format MCP tool names like github-list_pull_requests
13321373
const formattedName = formatMcpName(toolName).replace("::", "-");
13331374
displayName = formatToolDisplayName(formattedName, input);
13341375

1335-
// Show result preview if available
1376+
// Show first 2 lines of result using copilot-cli tree-branch style
13361377
if (toolResult && toolResult.content) {
13371378
const resultText = typeof toolResult.content === "string" ? toolResult.content : JSON.stringify(toolResult.content);
1338-
const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText;
1339-
resultPreview = ` └ ${truncated}`;
1379+
resultPreview = formatResultPreview(resultText);
13401380
}
13411381
} else {
13421382
displayName = formatToolDisplayName(toolName, input);
13431383

1344-
// Show result preview if available
1384+
// Show first 2 lines of result using copilot-cli tree-branch style
13451385
if (toolResult && toolResult.content) {
13461386
const resultText = typeof toolResult.content === "string" ? toolResult.content : String(toolResult.content);
1347-
const truncated = resultText.length > 80 ? resultText.substring(0, 80) + "..." : resultText;
1348-
resultPreview = ` └ ${truncated}`;
1387+
resultPreview = formatResultPreview(resultText);
13491388
}
13501389
}
13511390

@@ -1354,7 +1393,7 @@ function generateCopilotCliStyleSummary(logEntries, options = {}) {
13541393

13551394
if (resultPreview) {
13561395
lines.push(resultPreview);
1357-
conversationLineCount++;
1396+
conversationLineCount += resultPreview.split("\n").length;
13581397
}
13591398

13601399
lines.push(""); // Add blank line after tool execution
@@ -1628,6 +1667,7 @@ module.exports = {
16281667
formatToolUse,
16291668
parseLogEntries,
16301669
formatToolCallAsDetails,
1670+
formatResultPreview,
16311671
generatePlainTextSummary,
16321672
generateCopilotCliStyleSummary,
16331673
wrapAgentLogInSection,

actions/setup/js/log_parser_shared.test.cjs

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1742,9 +1742,9 @@ describe("log_parser_shared.cjs", () => {
17421742
const result = generatePlainTextSummary(logEntries, { parserName: "Agent" });
17431743

17441744
expect(result).toContain("Conversation:");
1745-
expect(result).toContain("Agent: I'll help you with that task.");
1745+
expect(result).toContain(" I'll help you with that task.");
17461746
expect(result).toContain("✓ $ echo hello");
1747-
expect(result).toContain("Agent: The command executed successfully!");
1747+
expect(result).toContain(" The command executed successfully!");
17481748
});
17491749

17501750
it("should truncate long agent responses", async () => {
@@ -1764,7 +1764,7 @@ describe("log_parser_shared.cjs", () => {
17641764

17651765
const result = generatePlainTextSummary(logEntries, { parserName: "Agent" });
17661766

1767-
expect(result).toContain("Agent: " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]");
1767+
expect(result).toContain(" " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]");
17681768
expect(result).not.toContain("a".repeat(2001));
17691769
});
17701770

@@ -1784,9 +1784,9 @@ describe("log_parser_shared.cjs", () => {
17841784

17851785
const result = generatePlainTextSummary(logEntries, { parserName: "Agent" });
17861786

1787-
expect(result).toContain("Agent: Line 1");
1788-
expect(result).toContain("Agent: Line 2");
1789-
expect(result).toContain("Agent: Line 3");
1787+
expect(result).toContain(" Line 1");
1788+
expect(result).toContain(" Line 2");
1789+
expect(result).toContain(" Line 3");
17901790
});
17911791
});
17921792

@@ -1827,11 +1827,12 @@ describe("log_parser_shared.cjs", () => {
18271827
expect(result).toContain("Conversation:");
18281828

18291829
// Check for Agent message
1830-
expect(result).toContain("Agent: I'll help you explore the repository structure first.");
1830+
expect(result).toContain(" I'll help you explore the repository structure first.");
18311831

1832-
// Check for tool execution with success icon
1832+
// Check for tool execution with success icon and first 2 lines of output
18331833
expect(result).toContain("✓ $ ls -la");
1834-
expect(result).toContain(" └ 3 lines...");
1834+
expect(result).toContain(" ├ file1.txt");
1835+
expect(result).toContain(" └ file2.txt (+ 1 more)");
18351836

18361837
// Check for Statistics section
18371838
expect(result).toContain("Statistics:");
@@ -1884,7 +1885,7 @@ describe("log_parser_shared.cjs", () => {
18841885

18851886
const result = generateCopilotCliStyleSummary(logEntries, { parserName: "Agent" });
18861887

1887-
expect(result).toContain("Agent: " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]");
1888+
expect(result).toContain(" " + "a".repeat(2000) + "... [truncated: showing first 2000 of 2100 chars]");
18881889
});
18891890

18901891
it("should skip internal file operation tools", async () => {
@@ -1940,9 +1941,9 @@ describe("log_parser_shared.cjs", () => {
19401941

19411942
const result = generateCopilotCliStyleSummary(logEntries, { parserName: "Agent" });
19421943

1943-
expect(result).toContain("Agent: Line 1");
1944-
expect(result).toContain("Agent: Line 2");
1945-
expect(result).toContain("Agent: Line 3");
1944+
expect(result).toContain(" Line 1");
1945+
expect(result).toContain(" Line 2");
1946+
expect(result).toContain(" Line 3");
19461947
});
19471948

19481949
it("should truncate conversation when it exceeds max lines", async () => {
@@ -1970,6 +1971,68 @@ describe("log_parser_shared.cjs", () => {
19701971
});
19711972
});
19721973

1974+
describe("formatResultPreview", () => {
1975+
it("should return empty string for empty or falsy input", async () => {
1976+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
1977+
1978+
expect(formatResultPreview("")).toBe("");
1979+
expect(formatResultPreview(null)).toBe("");
1980+
expect(formatResultPreview(undefined)).toBe("");
1981+
expect(formatResultPreview(" \n \n ")).toBe("");
1982+
});
1983+
1984+
it("should format single non-empty line with └", async () => {
1985+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
1986+
1987+
expect(formatResultPreview("hello")).toBe(" └ hello");
1988+
expect(formatResultPreview("\nhello\n")).toBe(" └ hello");
1989+
});
1990+
1991+
it("should format exactly two non-empty lines with ├ and └", async () => {
1992+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
1993+
1994+
expect(formatResultPreview("line1\nline2")).toBe(" ├ line1\n └ line2");
1995+
});
1996+
1997+
it("should show (+ N more) for three or more non-empty lines", async () => {
1998+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
1999+
2000+
expect(formatResultPreview("line1\nline2\nline3")).toBe(" ├ line1\n └ line2 (+ 1 more)");
2001+
expect(formatResultPreview("line1\nline2\nline3\nline4\nline5")).toBe(" ├ line1\n └ line2 (+ 3 more)");
2002+
});
2003+
2004+
it("should truncate lines exceeding maxLineLength and append ellipsis", async () => {
2005+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
2006+
2007+
const longLine = "a".repeat(100);
2008+
const result = formatResultPreview(longLine, 80);
2009+
expect(result).toBe(` └ ${"a".repeat(80)}...`);
2010+
});
2011+
2012+
it("should not add ellipsis when line exactly fits maxLineLength", async () => {
2013+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
2014+
2015+
const exactLine = "a".repeat(80);
2016+
const result = formatResultPreview(exactLine, 80);
2017+
expect(result).toBe(` └ ${"a".repeat(80)}`);
2018+
expect(result).not.toContain("...");
2019+
});
2020+
2021+
it("should handle Windows CRLF line endings without trailing \\r", async () => {
2022+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
2023+
2024+
expect(formatResultPreview("line1\r\nline2\r\n")).toBe(" ├ line1\n └ line2");
2025+
expect(formatResultPreview("only\r\n")).toBe(" └ only");
2026+
});
2027+
2028+
it("should skip blank lines when counting", async () => {
2029+
const { formatResultPreview } = await import("./log_parser_shared.cjs");
2030+
2031+
expect(formatResultPreview("\n\nfirst\n\nsecond\n\n")).toBe(" ├ first\n └ second");
2032+
expect(formatResultPreview("\n\nonly\n\n")).toBe(" └ only");
2033+
});
2034+
});
2035+
19732036
describe("formatSafeOutputsPreview", () => {
19742037
it("should return empty string for empty content", async () => {
19752038
const { formatSafeOutputsPreview } = await import("./log_parser_shared.cjs");

0 commit comments

Comments
 (0)