diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2fc4a..112f70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/core/transform.ts b/src/core/transform.ts index ae001d6..e8246f9 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -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; @@ -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; @@ -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; diff --git a/tests/paging.test.ts b/tests/paging.test.ts index bbb8ae5..13de300 100644 --- a/tests/paging.test.ts +++ b/tests/paging.test.ts @@ -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'; @@ -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); @@ -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 () => {