Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/entrypoints/format-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,57 @@ 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;

// 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");
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);
Expand Down
6 changes: 3 additions & 3 deletions src/entrypoints/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,7 +103,7 @@ async function writeStepSummary(executionFile: string): Promise<void> {
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) {
Expand All @@ -116,7 +116,7 @@ async function writeStepSummary(executionFile: string): Promise<void> {
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");
}
Expand Down
63 changes: 63 additions & 0 deletions test/format-turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
detectContentType,
formatResultContent,
formatToolWithResult,
truncateStepSummary,
type Turn,
type ToolUse,
type ToolResult,
Expand Down Expand Up @@ -417,6 +418,68 @@ 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
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 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);
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");
});

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 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", () => {
test("formats real conversation data correctly", () => {
// Load the sample JSON data
Expand Down