Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · semantic ver

### Fixed

- **fix(transform):** account for reflowed `↵` segments as packed inline
glyphs when truncating oversized tool results, so the renderer uses the
available page budget instead of discarding most short log lines before
imaging. (thanks @dex0shubham)
- **fix(anthropic):** preserve native-system provenance when session
configuration is rendered into user-role images. (thanks @alteixeira20)
- **fix(transform):** keep Claude Code automode rules, permissions, severity
Expand Down
22 changes: 19 additions & 3 deletions src/core/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1371,13 +1371,29 @@ export function truncateForBudget(
const originalLines = lines.length;
const originalChars = text.length;

// Reflow joins logical lines with the inline ↵ glyph. The renderer does not
// break on that sentinel, so many logical segments share one visual row.
// Charge only the packed-row delta; charging lineRows() per segment wastes
// most of the image budget on short reflowed log lines.
const reflowed = nlChar === NL_SENTINEL;
const packedRows = (chars: number): number =>
Math.ceil(chars / Math.max(1, cols));
const rowCost = (
segment: string,
priorChars: number,
addedChars: number,
): number =>
reflowed
? packedRows(priorChars + addedChars) - packedRows(priorChars)
: lineRows(segment, cols);

if (shape === 'structured') {
let rows = 0;
let chars = 0;
let cut = 0;
for (let i = 0; i < lines.length; i++) {
const r = lineRows(lines[i]!, cols);
const c = lines[i]!.length + (i > 0 ? 1 : 0);
const r = rowCost(lines[i]!, chars, c);
if (rows + r > totalRowBudget || chars + c > totalCharBudget) break;
rows += r;
chars += c;
Expand Down Expand Up @@ -1412,8 +1428,8 @@ export function truncateForBudget(
let headChars = 0;
let headCut = 0;
for (let i = 0; i < lines.length; i++) {
const r = lineRows(lines[i]!, cols);
const c = lines[i]!.length + (i > 0 ? 1 : 0);
const r = rowCost(lines[i]!, headChars, c);
if (headRows + r > headRowBudget || headChars + c > headCharBudget) break;
headRows += r;
headChars += c;
Expand All @@ -1424,8 +1440,8 @@ export function truncateForBudget(
let tailChars = 0;
let tailStart = lines.length;
for (let i = lines.length - 1; i >= headCut; i--) {
const r = lineRows(lines[i]!, cols);
const c = lines[i]!.length + (i < lines.length - 1 ? 1 : 0);
const r = rowCost(lines[i]!, tailChars, c);
if (tailRows + r > tailRowBudget || tailChars + c > tailCharBudget) break;
tailRows += r;
tailChars += c;
Expand Down
30 changes: 26 additions & 4 deletions tests/paging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
DENSE_CONTENT_CHARS_PER_IMAGE,
DENSE_CONTENT_COLS,
DENSE_RENDER_STYLE,
NL_SENTINEL,
READABLE_CHARS_PER_IMAGE,
renderTextToPngsWithCharLimit,
} from '../src/core/render.js';
Expand Down Expand Up @@ -331,6 +332,27 @@ describe('truncateForBudget', () => {
expect(reportedOmittedLines).toBeLessThan(originalLines);
});

it('does not over-truncate reflowed text whose sentinels pack into visual rows', () => {
const segments: string[] = [];
for (let i = 0; i < 10_000; i++) {
segments.push(`2026-05-18T12:00:00Z entry ${i} payload content here`);
}
const reflowed = segments.join(NL_SENTINEL);
expect(reflowed).not.toContain('\n');

const { text: out, truncated } = truncateForBudget(
reflowed,
10,
DENSE_CONTENT_COLS,
);

expect(truncated).toBe(true);
expect(out.length).toBeGreaterThan(150_000);
expect(out).toMatch(/Showing first \d+ lines and last \d+ lines/);
expect(out).toContain('entry 0');
expect(out).toContain('entry 9999');
});

it('always shows at least one head line even on degenerate input', () => {
// Single huge line — bigger than budget. Should still render with marker.
const text = 'x'.repeat(500_000);
Expand Down Expand Up @@ -460,10 +482,10 @@ describe('paging end-to-end (transformRequest)', () => {
);
const { info } = await transformRequest(req, { multiCol: 1, charsPerToken: 2 });
expect(info.truncatedToolResults).toBe(2);
// Both should have been truncated → omittedChars roughly doubled. The
// exact bound depends on renderer config: at multiCol=1 each image
// packs ~19.5k chars worst-case. Threshold below covers the single-col case.
expect(info.omittedChars).toBeGreaterThan(600_000);
// Both are truncated. With packed-row accounting each result now uses the
// image budget it was assigned instead of discarding roughly five sixths
// of the content before rendering, so about 500k chars remain omitted.
expect(info.omittedChars).toBeGreaterThan(400_000);
});

it('handles array-shaped tool_result content', async () => {
Expand Down