From 36ac1faa96d93ee8ec3f9f970f1d612d86e75422 Mon Sep 17 00:00:00 2001 From: Steven Chong <25894545+teamchong@users.noreply.github.com> Date: Tue, 26 May 2026 13:15:54 -0400 Subject: [PATCH] fix(render): reduce dense page size --- src/core/history.ts | 53 ------------------------------------------- src/core/render.ts | 2 +- tests/history.test.ts | 39 ++++++++++++------------------- tests/paging.test.ts | 23 +++++++++++++++++++ 4 files changed, 39 insertions(+), 78 deletions(-) diff --git a/src/core/history.ts b/src/core/history.ts index d3fdd17..0918b65 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -113,47 +113,6 @@ export interface HistoryCollapseInfo { droppedCodepoints: Map; } -const MAX_VERBATIM_GIT_STATUS_CHARS = 8000; - -function toolResultTextContent(block: ToolResultBlock): string { - const inner = block.content; - if (typeof inner === 'string') return inner; - if (!Array.isArray(inner)) return ''; - const parts: string[] = []; - for (const sub of inner) { - if (sub && (sub as TextBlock).type === 'text') parts.push((sub as TextBlock).text); - } - return parts.join('\n'); -} - -function isGitStatusShortOutput(text: string): boolean { - if (!text || text.length > MAX_VERBATIM_GIT_STATUS_CHARS) return false; - const lines = text.split(/\r?\n/).filter((line) => line.length > 0); - if (lines.length === 0) return false; - return lines.every((line) => /^(?:[ MADRCUT?!]{2}|[?!]{2}) .+/.test(line)); -} - -function collectVerbatimGitStatusOutputs( - messages: Message[], - upToExclusive: number, -): string[] { - const out: string[] = []; - let used = 0; - const limit = Math.min(upToExclusive, messages.length); - for (let i = 0; i < limit; i++) { - const msg = messages[i]!; - if (!Array.isArray(msg.content)) continue; - for (const blk of msg.content) { - if (!blk || (blk as { type?: string }).type !== 'tool_result') continue; - const text = toolResultTextContent(blk as ToolResultBlock); - if (!isGitStatusShortOutput(text)) continue; - if (used + text.length > MAX_VERBATIM_GIT_STATUS_CHARS) return out; - out.push(text); - used += text.length; - } - } - return out; -} /** * Compute the largest index `i*` in `[0..messages.length - keepTail - 1]` @@ -409,21 +368,9 @@ export async function collapseHistory( } } // Build the synthetic user message. - const verbatimGitStatusOutputs = collectVerbatimGitStatusOutputs(messages, collapseLen); - const verbatimBlocks: TextBlock[] = verbatimGitStatusOutputs.length > 0 - ? [{ - type: 'text', - text: [ - '[Exact git status --short output preserved as text. Use this block as the source of truth over the rendered history image.]', - ...verbatimGitStatusOutputs.map((out, i) => `--- git status --short #${i + 1} ---\n${out}`), - '[End exact git status --short output.]', - ].join('\n\n'), - }] - : []; const syntheticContent: ContentBlock[] = [ { type: 'text', text: '[Earlier in this conversation:]' }, ...imageBlocks, - ...verbatimBlocks, { type: 'text', text: '[End of earlier context.]' }, ]; const syntheticUser: Message = { diff --git a/src/core/render.ts b/src/core/render.ts index 90272ba..1d0cef7 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -43,7 +43,7 @@ export const READABLE_CHARS_PER_IMAGE = 50000; * and collapsed history). The 50k canvas-max page is token-efficient but too * dense for OCR on lockfiles/JSON/code. Keep those blocks paged into smaller * images so the model can read them reliably. */ -export const DENSE_CONTENT_CHARS_PER_IMAGE = 6000; +export const DENSE_CONTENT_CHARS_PER_IMAGE = 5000; export const DENSE_CONTENT_COLS = 180; export const DENSE_RENDER_STYLE: RenderStyle = { cellWBonus: 2, cellHBonus: 2, aa: true }; /** Default columns per row. 1568 px / 5 px-per-cell = 313 cells. We render diff --git a/tests/history.test.ts b/tests/history.test.ts index 4ee1be6..8daf6b9 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -24,6 +24,7 @@ import { HISTORY_DEFAULTS, } from '../src/core/history.js'; import { transformRequest, isCompressionProfitable } from '../src/core/transform.js'; +import { DENSE_CONTENT_CHARS_PER_IMAGE } from '../src/core/render.js'; import type { Message } from '../src/core/types.js'; // A tiny helper so test fixtures are readable. @@ -312,23 +313,12 @@ describe('collapseHistory', () => { expect(out[2]).toBe(msgs[11]); }); - it('preserves git status --short tool output as exact text alongside history images', async () => { - const status = [ - 'M package-lock.json', - 'M package.json', - 'M packages/capnweb-typecheck/DESIGN.md', - 'M packages/capnweb-typecheck/src/index.ts', - ' M packages/capnweb-typecheck/src/plugin.ts', - 'M tsdown.config.ts', - '?? handoff.md', - '?? packages/capnweb-typecheck/src/transform/', - ].join('\n'); - const msgs: Message[] = [ - usr('run git status'), - asst([{ type: 'tool_use', id: 'bash-1', name: 'bash', input: { command: 'git status --short' } }]), - usr([{ type: 'tool_result', tool_use_id: 'bash-1', content: status }]), - asst('noted'), - ]; + it('splits dense collapsed history into readable image pages without plaintext side channels', async () => { + const body = Array.from( + { length: 180 }, + (_, i) => `line ${i}: ${'x'.repeat(80)}`, + ).join('\n'); + const msgs: Message[] = [usr(body), asst(body), usr(body)]; const { messages: out, info } = await collapseHistory(msgs, () => true, { keepTail: 0, @@ -337,14 +327,15 @@ describe('collapseHistory', () => { }); expect(info.reason).toBe(undefined); - const content = out[0]!.content as Array>; - expect(content.some((c) => c.type === 'image')).toBe(true); - const exact = content.find( - (c) => c.type === 'text' && typeof c.text === 'string' && c.text.includes('source of truth'), + expect(info.collapsedImages).toBeGreaterThanOrEqual( + Math.ceil(info.collapsedChars / DENSE_CONTENT_CHARS_PER_IMAGE), ); - expect(exact).toBeTruthy(); - expect((exact!.text as string)).toContain(status); - for (const line of status.split('\n')) expect((exact!.text as string)).toContain(line); + const content = out[0]!.content as Array>; + expect(content.filter((c) => c.type === 'text')).toEqual([ + { type: 'text', text: '[Earlier in this conversation:]' }, + { type: 'text', text: '[End of earlier context.]' }, + ]); + expect(content.filter((c) => c.type === 'image')).toHaveLength(info.collapsedImages); }); it('preserves a tool_use sequence that straddles the live-tail boundary', async () => { diff --git a/tests/paging.test.ts b/tests/paging.test.ts index 5ced7c8..a70a33f 100644 --- a/tests/paging.test.ts +++ b/tests/paging.test.ts @@ -92,6 +92,29 @@ describe('dense readable render profile', () => { expect(imgs[0]!.width).toBeGreaterThan(1000); expect(imgs[0]!.width).toBeLessThanOrEqual(1568); }); + + it('pages diff-shaped tool output at the dense readable budget', async () => { + const diffish = Array.from( + { length: 160 }, + (_, i) => `${i % 2 === 0 ? '+' : '-'}const value${i} = ${i}; // ${'changed '.repeat(8)}`, + ).join('\n'); + expect(diffish.length).toBeGreaterThan(DENSE_CONTENT_CHARS_PER_IMAGE * 2); + + const imgs = await renderTextToPngsWithCharLimit( + diffish, + DENSE_CONTENT_COLS, + DENSE_CONTENT_CHARS_PER_IMAGE, + DENSE_RENDER_STYLE, + ); + + expect(imgs.length).toBeGreaterThanOrEqual( + Math.ceil(diffish.length / DENSE_CONTENT_CHARS_PER_IMAGE), + ); + for (const img of imgs) { + expect(img.width).toBeLessThanOrEqual(1568); + expect(img.height).toBeLessThanOrEqual(1568); + } + }); }); describe('classifyContent', () => {