From fb8514bad900856bae8c416a60c1f984c3e287cc Mon Sep 17 00:00:00 2001 From: Sunny Patel Date: Wed, 11 Mar 2026 19:06:14 -0400 Subject: [PATCH 1/2] fix: truncated step summary to stay under GitHub's 1024k limit - added truncateStepSummary() that checks byte size against GitHub's hard limit and cuts at the last section boundary (---) when possible - uses 1000k budget (not 1024k) to leave headroom for content from earlier steps in the same job - appends a truncation notice pointing users to display_report: false - handles multi-byte characters correctly via TextEncoder/TextDecoder - applied to both the formatted report and the raw JSON fallback path - added 5 tests covering: under-limit passthrough, over-limit truncation, section boundary cutting, multi-byte character safety, default limit fixes #927 --- src/entrypoints/format-turns.ts | 40 +++++++++++++++++++++++++ src/entrypoints/run.ts | 6 ++-- test/format-turns.test.ts | 52 +++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts index 324174594..1a6fa39cc 100755 --- a/src/entrypoints/format-turns.ts +++ b/src/entrypoints/format-turns.ts @@ -412,6 +412,46 @@ export function formatGroupedContent(groupedContent: GroupedContent[]): string { return markdown; } +// GitHub's hard limit for $GITHUB_STEP_SUMMARY content. +// Using 1000k instead of 1024k to leave headroom for any existing content +// that may have been appended by earlier steps in the same job. +const STEP_SUMMARY_MAX_BYTES = 1000 * 1024; + +/** + * Truncate markdown to fit within GitHub's step summary size limit (1024k). + * Cuts at the last complete section boundary (---) before the limit, + * then appends a truncation notice. + */ +export function truncateStepSummary( + markdown: string, + maxBytes: number = STEP_SUMMARY_MAX_BYTES, +): string { + const encoded = new TextEncoder().encode(markdown); + if (encoded.byteLength <= maxBytes) { + return markdown; + } + + const truncationNotice = + "\n\n---\n\n" + + "> **Note:** This report was truncated to fit within GitHub's step summary size limit (1024k).\n" + + "> To disable the report entirely, set `display_report: false` in your workflow.\n"; + + const noticeBytes = new TextEncoder().encode(truncationNotice).byteLength; + const budget = maxBytes - noticeBytes; + + // Decode back to string at the byte budget boundary + const truncated = new TextDecoder().decode(encoded.slice(0, budget)); + + // Find the last section separator to cut at a clean boundary + const lastSeparator = truncated.lastIndexOf("\n---\n"); + const cutPoint = + lastSeparator > truncated.length * 0.5 + ? lastSeparator + : truncated.length; + + return truncated.substring(0, cutPoint) + truncationNotice; +} + export function formatTurnsFromData(data: Turn[]): string { // Group turns naturally const groupedContent = groupTurnsNaturally(data); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 8a42776de..178fbcfa6 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -23,7 +23,7 @@ import { prepareAgentMode } from "../modes/agent"; import { checkContainsTrigger } from "../github/validation/trigger"; import { collectActionInputsPresence } from "./collect-inputs"; import { updateCommentLink } from "./update-comment-link"; -import { formatTurnsFromData } from "./format-turns"; +import { formatTurnsFromData, truncateStepSummary } from "./format-turns"; import type { Turn } from "./format-turns"; // Base-action imports (used directly instead of subprocess) import { validateEnvironmentVariables } from "../../base-action/src/validate-env"; @@ -103,7 +103,7 @@ async function writeStepSummary(executionFile: string): Promise { try { const fileContent = readFileSync(executionFile, "utf-8"); const data: Turn[] = JSON.parse(fileContent); - const markdown = formatTurnsFromData(data); + const markdown = truncateStepSummary(formatTurnsFromData(data)); await appendFile(summaryFile, markdown); console.log("Successfully formatted Claude Code report"); } catch (error) { @@ -116,7 +116,7 @@ async function writeStepSummary(executionFile: string): Promise { fallback += "```json\n"; fallback += readFileSync(executionFile, "utf-8"); fallback += "\n```\n"; - await appendFile(summaryFile, fallback); + await appendFile(summaryFile, truncateStepSummary(fallback)); } catch { console.error("Failed to write raw output to step summary"); } diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts index bb26f2e57..7b8d8a0d9 100644 --- a/test/format-turns.test.ts +++ b/test/format-turns.test.ts @@ -8,6 +8,7 @@ import { detectContentType, formatResultContent, formatToolWithResult, + truncateStepSummary, type Turn, type ToolUse, type ToolResult, @@ -417,6 +418,57 @@ describe("formatTurnsFromData", () => { }); }); +describe("truncateStepSummary", () => { + test("returns markdown unchanged when under the limit", () => { + const small = "## Report\n\nSome content\n"; + expect(truncateStepSummary(small, 1024)).toBe(small); + }); + + test("truncates markdown that exceeds the byte limit", () => { + const large = "x".repeat(2000); + const result = truncateStepSummary(large, 500); + const resultBytes = new TextEncoder().encode(result).byteLength; + expect(resultBytes).toBeLessThanOrEqual(500); + expect(result).toContain("truncated"); + expect(result).toContain("display_report"); + }); + + test("cuts at last section separator when possible", () => { + // Build content with section separators + let content = "## Section 1\n\nContent A\n\n---\n\n"; + content += "## Section 2\n\nContent B\n\n---\n\n"; + content += "## Section 3\n\n" + "C".repeat(500); + + // Set limit so it fits section 1+2 but not section 3 + const section12 = "## Section 1\n\nContent A\n\n---\n\n## Section 2\n\nContent B\n"; + const limit = new TextEncoder().encode(section12).byteLength + 300; + + const result = truncateStepSummary(content, limit); + // Should have cut at the separator after section 2 + expect(result).toContain("Section 1"); + expect(result).toContain("Section 2"); + expect(result).toContain("truncated"); + }); + + test("handles multi-byte characters without corruption", () => { + // Unicode content with multi-byte chars + const content = "## Report\n\n" + "\u{1F600}".repeat(300) + "\n\n---\n\n" + "end"; + const result = truncateStepSummary(content, 500); + // Should not throw or produce invalid UTF-8 + expect(result).toContain("Report"); + expect(result).toContain("truncated"); + // Verify it's valid UTF-8 by encoding/decoding + const roundTrip = new TextDecoder().decode(new TextEncoder().encode(result)); + expect(roundTrip).toBe(result); + }); + + test("uses default limit matching GitHub's 1024k constraint", () => { + // Just verify the default doesn't throw for small content + const small = "## Report\n"; + expect(truncateStepSummary(small)).toBe(small); + }); +}); + describe("integration tests", () => { test("formats real conversation data correctly", () => { // Load the sample JSON data From d5b55aef6b8a6f5437a8697a691c90ba62643fe1 Mon Sep 17 00:00:00 2001 From: Sunny Patel Date: Wed, 11 Mar 2026 19:12:02 -0400 Subject: [PATCH 2/2] fix: addressed review feedback on truncation logic - guarded against budget going negative when maxBytes < notice size - added re-verification loop for multi-byte boundary edge cases where TextDecoder replacement chars could inflate encoded size - tightened section boundary test to assert Section 3 is excluded - renamed default limit test to reflect 1000k headroom, not 1024k - added edge case test for maxBytes smaller than notice --- src/entrypoints/format-turns.ts | 15 +++++++++++++-- test/format-turns.test.ts | 29 ++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts index 1a6fa39cc..0b00b8947 100755 --- a/src/entrypoints/format-turns.ts +++ b/src/entrypoints/format-turns.ts @@ -439,8 +439,19 @@ export function truncateStepSummary( const noticeBytes = new TextEncoder().encode(truncationNotice).byteLength; const budget = maxBytes - noticeBytes; - // Decode back to string at the byte budget boundary - const truncated = new TextDecoder().decode(encoded.slice(0, budget)); + // If maxBytes is smaller than the notice itself, return just the notice + // trimmed to fit (extremely unlikely in practice, but handles the edge case) + if (budget <= 0) { + return truncationNotice.substring(0, maxBytes); + } + + // Decode back to string at the byte budget boundary. + // TextDecoder replaces incomplete multi-byte sequences at the boundary with + // U+FFFD, so re-verify the encoded size and trim if needed. + let truncated = new TextDecoder().decode(encoded.slice(0, budget)); + while (new TextEncoder().encode(truncated).byteLength > budget && truncated.length > 0) { + truncated = truncated.substring(0, truncated.length - 1); + } // Find the last section separator to cut at a clean boundary const lastSeparator = truncated.lastIndexOf("\n---\n"); diff --git a/test/format-turns.test.ts b/test/format-turns.test.ts index 7b8d8a0d9..4a6cab920 100644 --- a/test/format-turns.test.ts +++ b/test/format-turns.test.ts @@ -435,18 +435,21 @@ describe("truncateStepSummary", () => { test("cuts at last section separator when possible", () => { // Build content with section separators - let content = "## Section 1\n\nContent A\n\n---\n\n"; - content += "## Section 2\n\nContent B\n\n---\n\n"; - content += "## Section 3\n\n" + "C".repeat(500); + const section1 = "## Section 1\n\nContent A\n\n---\n\n"; + const section2 = "## Section 2\n\nContent B\n\n---\n\n"; + const section3 = "## Section 3\n\n" + "C".repeat(500); + const content = section1 + section2 + section3; - // Set limit so it fits section 1+2 but not section 3 - const section12 = "## Section 1\n\nContent A\n\n---\n\n## Section 2\n\nContent B\n"; - const limit = new TextEncoder().encode(section12).byteLength + 300; + // Set limit so the truncation notice + sections 1+2 fit, but section 3 doesn't. + // The notice is ~180 bytes, so we need section1+section2+notice to fit but not section3. + const s12Bytes = new TextEncoder().encode(section1 + section2).byteLength; + const limit = s12Bytes + 200; // enough for s1+s2+notice, but not s3 const result = truncateStepSummary(content, limit); - // Should have cut at the separator after section 2 expect(result).toContain("Section 1"); expect(result).toContain("Section 2"); + expect(result).not.toContain("Section 3"); + expect(result).not.toContain("C".repeat(50)); expect(result).toContain("truncated"); }); @@ -462,11 +465,19 @@ describe("truncateStepSummary", () => { expect(roundTrip).toBe(result); }); - test("uses default limit matching GitHub's 1024k constraint", () => { - // Just verify the default doesn't throw for small content + test("uses default limit with headroom below GitHub's 1024k constraint", () => { + // Default budget is 1000k (leaving 24k headroom for earlier steps) const small = "## Report\n"; expect(truncateStepSummary(small)).toBe(small); }); + + test("handles edge case where maxBytes is smaller than truncation notice", () => { + const content = "x".repeat(100); + // Very small limit that can't even fit the notice + const result = truncateStepSummary(content, 50); + const resultBytes = new TextEncoder().encode(result).byteLength; + expect(resultBytes).toBeLessThanOrEqual(50); + }); }); describe("integration tests", () => {