Skip to content

Commit fb8514b

Browse files
committed
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
1 parent 567be3d commit fb8514b

File tree

3 files changed

+95
-3
lines changed

3 files changed

+95
-3
lines changed

src/entrypoints/format-turns.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,46 @@ export function formatGroupedContent(groupedContent: GroupedContent[]): string {
412412
return markdown;
413413
}
414414

415+
// GitHub's hard limit for $GITHUB_STEP_SUMMARY content.
416+
// Using 1000k instead of 1024k to leave headroom for any existing content
417+
// that may have been appended by earlier steps in the same job.
418+
const STEP_SUMMARY_MAX_BYTES = 1000 * 1024;
419+
420+
/**
421+
* Truncate markdown to fit within GitHub's step summary size limit (1024k).
422+
* Cuts at the last complete section boundary (---) before the limit,
423+
* then appends a truncation notice.
424+
*/
425+
export function truncateStepSummary(
426+
markdown: string,
427+
maxBytes: number = STEP_SUMMARY_MAX_BYTES,
428+
): string {
429+
const encoded = new TextEncoder().encode(markdown);
430+
if (encoded.byteLength <= maxBytes) {
431+
return markdown;
432+
}
433+
434+
const truncationNotice =
435+
"\n\n---\n\n" +
436+
"> **Note:** This report was truncated to fit within GitHub's step summary size limit (1024k).\n" +
437+
"> To disable the report entirely, set `display_report: false` in your workflow.\n";
438+
439+
const noticeBytes = new TextEncoder().encode(truncationNotice).byteLength;
440+
const budget = maxBytes - noticeBytes;
441+
442+
// Decode back to string at the byte budget boundary
443+
const truncated = new TextDecoder().decode(encoded.slice(0, budget));
444+
445+
// Find the last section separator to cut at a clean boundary
446+
const lastSeparator = truncated.lastIndexOf("\n---\n");
447+
const cutPoint =
448+
lastSeparator > truncated.length * 0.5
449+
? lastSeparator
450+
: truncated.length;
451+
452+
return truncated.substring(0, cutPoint) + truncationNotice;
453+
}
454+
415455
export function formatTurnsFromData(data: Turn[]): string {
416456
// Group turns naturally
417457
const groupedContent = groupTurnsNaturally(data);

src/entrypoints/run.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { prepareAgentMode } from "../modes/agent";
2323
import { checkContainsTrigger } from "../github/validation/trigger";
2424
import { collectActionInputsPresence } from "./collect-inputs";
2525
import { updateCommentLink } from "./update-comment-link";
26-
import { formatTurnsFromData } from "./format-turns";
26+
import { formatTurnsFromData, truncateStepSummary } from "./format-turns";
2727
import type { Turn } from "./format-turns";
2828
// Base-action imports (used directly instead of subprocess)
2929
import { validateEnvironmentVariables } from "../../base-action/src/validate-env";
@@ -103,7 +103,7 @@ async function writeStepSummary(executionFile: string): Promise<void> {
103103
try {
104104
const fileContent = readFileSync(executionFile, "utf-8");
105105
const data: Turn[] = JSON.parse(fileContent);
106-
const markdown = formatTurnsFromData(data);
106+
const markdown = truncateStepSummary(formatTurnsFromData(data));
107107
await appendFile(summaryFile, markdown);
108108
console.log("Successfully formatted Claude Code report");
109109
} catch (error) {
@@ -116,7 +116,7 @@ async function writeStepSummary(executionFile: string): Promise<void> {
116116
fallback += "```json\n";
117117
fallback += readFileSync(executionFile, "utf-8");
118118
fallback += "\n```\n";
119-
await appendFile(summaryFile, fallback);
119+
await appendFile(summaryFile, truncateStepSummary(fallback));
120120
} catch {
121121
console.error("Failed to write raw output to step summary");
122122
}

test/format-turns.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
detectContentType,
99
formatResultContent,
1010
formatToolWithResult,
11+
truncateStepSummary,
1112
type Turn,
1213
type ToolUse,
1314
type ToolResult,
@@ -417,6 +418,57 @@ describe("formatTurnsFromData", () => {
417418
});
418419
});
419420

421+
describe("truncateStepSummary", () => {
422+
test("returns markdown unchanged when under the limit", () => {
423+
const small = "## Report\n\nSome content\n";
424+
expect(truncateStepSummary(small, 1024)).toBe(small);
425+
});
426+
427+
test("truncates markdown that exceeds the byte limit", () => {
428+
const large = "x".repeat(2000);
429+
const result = truncateStepSummary(large, 500);
430+
const resultBytes = new TextEncoder().encode(result).byteLength;
431+
expect(resultBytes).toBeLessThanOrEqual(500);
432+
expect(result).toContain("truncated");
433+
expect(result).toContain("display_report");
434+
});
435+
436+
test("cuts at last section separator when possible", () => {
437+
// Build content with section separators
438+
let content = "## Section 1\n\nContent A\n\n---\n\n";
439+
content += "## Section 2\n\nContent B\n\n---\n\n";
440+
content += "## Section 3\n\n" + "C".repeat(500);
441+
442+
// Set limit so it fits section 1+2 but not section 3
443+
const section12 = "## Section 1\n\nContent A\n\n---\n\n## Section 2\n\nContent B\n";
444+
const limit = new TextEncoder().encode(section12).byteLength + 300;
445+
446+
const result = truncateStepSummary(content, limit);
447+
// Should have cut at the separator after section 2
448+
expect(result).toContain("Section 1");
449+
expect(result).toContain("Section 2");
450+
expect(result).toContain("truncated");
451+
});
452+
453+
test("handles multi-byte characters without corruption", () => {
454+
// Unicode content with multi-byte chars
455+
const content = "## Report\n\n" + "\u{1F600}".repeat(300) + "\n\n---\n\n" + "end";
456+
const result = truncateStepSummary(content, 500);
457+
// Should not throw or produce invalid UTF-8
458+
expect(result).toContain("Report");
459+
expect(result).toContain("truncated");
460+
// Verify it's valid UTF-8 by encoding/decoding
461+
const roundTrip = new TextDecoder().decode(new TextEncoder().encode(result));
462+
expect(roundTrip).toBe(result);
463+
});
464+
465+
test("uses default limit matching GitHub's 1024k constraint", () => {
466+
// Just verify the default doesn't throw for small content
467+
const small = "## Report\n";
468+
expect(truncateStepSummary(small)).toBe(small);
469+
});
470+
});
471+
420472
describe("integration tests", () => {
421473
test("formats real conversation data correctly", () => {
422474
// Load the sample JSON data

0 commit comments

Comments
 (0)