diff --git a/src/core/render.ts b/src/core/render.ts index 4fbab25..3832309 100644 --- a/src/core/render.ts +++ b/src/core/render.ts @@ -22,10 +22,15 @@ import { } from './atlas.js'; import { encodeGrayPng } from './png.js'; -const MAX_HEIGHT_PX = 1568; +/** Vertical pixel budget per rendered PNG. Bounded by Anthropic's 1568×1568 + * image cap. Exported so the break-even gate in transform.ts can derive + * CHARS_PER_IMAGE from the same constants the renderer actually uses. */ +export const MAX_HEIGHT_PX = 1568; const DEFAULT_COLS = 100; const PAD_X = 4; -const PAD_Y = 4; +/** Vertical padding inside the rendered PNG (top + bottom each). Exported + * for the same reason as MAX_HEIGHT_PX. */ +export const PAD_Y = 4; export interface RenderedImage { /** Raw PNG bytes. */ diff --git a/src/core/tracker.ts b/src/core/tracker.ts index c117c4a..582e19d 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -39,6 +39,11 @@ export interface TrackEvent { reminder_imgs?: number; /** Image count attributable to compressing tool_result content. */ tool_result_imgs?: number; + /** Number of tool_result blocks where the source text exceeded the + * per-tool_result image budget and was truncated before rendering. */ + truncated_tool_results?: number; + /** Total chars elided by paging across all tool_results this request. */ + omitted_chars?: number; /** Codepoints rendered into images that weren't in the glyph atlas. A * spike here means users are typing glyphs we don't ship — consider * switching ATLAS_PROFILE to `full-bmp`. */ @@ -150,6 +155,12 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { if (info.dynamicBlockCount !== undefined) out.dynamic_block_count = info.dynamicBlockCount; if (info.reminderImgs !== undefined) out.reminder_imgs = info.reminderImgs; if (info.toolResultImgs !== undefined) out.tool_result_imgs = info.toolResultImgs; + if (info.truncatedToolResults !== undefined && info.truncatedToolResults > 0) { + out.truncated_tool_results = info.truncatedToolResults; + } + if (info.omittedChars !== undefined && info.omittedChars > 0) { + out.omitted_chars = info.omittedChars; + } if (info.droppedChars !== undefined && info.droppedChars > 0) { out.dropped_chars = info.droppedChars; } diff --git a/src/core/transform.ts b/src/core/transform.ts index 8933cc6..50d72c3 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -19,8 +19,9 @@ import type { ToolDef, ToolResultBlock, } from './types.js'; -import { renderTextToPngs } from './render.js'; +import { renderTextToPngs, MAX_HEIGHT_PX, PAD_Y } from './render.js'; import { bytesToBase64 } from './png.js'; +import { ATLAS_CELL_H } from './atlas.js'; export interface TransformOptions { /** Master switch — false makes this a no-op pass-through. */ @@ -49,6 +50,11 @@ export interface TransformOptions { placement?: 'system' | 'user'; /** Soft-wrap column count. */ cols?: number; + /** Hard upper bound on images emitted per single tool_result. Above this, + * the source text is truncated (head + paging marker + tail) BEFORE + * rendering so the request stays under Anthropic's 100-image-per-request + * cap even when a single tool dumps a huge log. Default 10. */ + maxImagesPerToolResult?: number; } const DEFAULTS: Required = { @@ -71,6 +77,11 @@ const DEFAULTS: Required = { // into a user message instead. placement: 'user', cols: 100, + // Cap at 10 images per tool_result. With ~14k chars/image at current cell, + // a single tool_result can grow to ~140k chars before paging kicks in. A + // `find` over a big tree or `grep -r` can easily exceed this; the paging + // marker tells the model what was elided. Tuneable per session. + maxImagesPerToolResult: 10, }; // --- per-block break-even check --- @@ -87,13 +98,6 @@ const DEFAULTS: Required = { // threshold (5k) was wide of the break-even point (10k) and let net-loss // compressions through. The check below is the real gate. -/** Characters per rendered image at the current renderer config. Derived - * from `cols × floor((MAX_HEIGHT_PX − 2·PAD_Y) / cell_height)` with the - * shipping values (cols=100, cell=5×11, MAX=1568, PAD=4): - * 100 × floor(1560 / 11) = 100 × 141 = 14,100 - * Re-derive if cell dimensions or column count ever change. */ -const CHARS_PER_IMAGE = 14_100; - /** English ~4 chars per token average. Holds well enough for code + prose * mix; tool_result content is typically code-shaped. */ const CHARS_PER_TOKEN = 4; @@ -103,11 +107,39 @@ const CHARS_PER_TOKEN = 4; * to keep `src/core/` free of dashboard imports — that's a one-way edge. */ const TOKENS_PER_IMAGE = 2500; +/** Characters per rendered image at the current renderer config. Derived + * at runtime from `ATLAS_CELL_H` (cell height) and the render canvas + * dimensions imported from render.ts — single source of truth. + * + * Formula: `cols × floor((MAX_HEIGHT_PX − 2·PAD_Y) / ATLAS_CELL_H)` + * + * At the shipping config (Unifont, cell 5×11, cols=100): + * 100 × floor((1568 − 8) / 11) = 100 × 141 = 14,100 + * + * When the atlas swaps (e.g. Cozette 4×7, cell H=7), this auto-updates: + * 100 × floor(1560 / 7) = 100 × 222 = 22,200 + * …and the break-even threshold drops accordingly. Without this, the + * hardcoded 14,100 would silently let net-loss compressions through on + * every smaller-cell atlas. */ +/** Visual rows per image at the current atlas cell. Derived once at module + * load. Auto-updates when gen-atlas regenerates with a different font/size. */ +export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / ATLAS_CELL_H)); + +export function maxCharsPerImage(cols: number): number { + return cols * LINES_PER_IMAGE; +} + /** Returns true iff image-compressing a text block of `textLen` chars would * actually save tokens vs leaving it as text. Used as the gate before every - * image-encoding decision in transformRequest. */ -export function isCompressionProfitable(textLen: number): boolean { - const estImages = Math.max(1, Math.ceil(textLen / CHARS_PER_IMAGE)); + * image-encoding decision in transformRequest. + * + * `cols` defaults to `DEFAULTS.cols` (100) so existing callers and unit + * tests that pass only `textLen` keep working byte-identically at the + * current atlas. New call sites should pass `o.cols` so a runtime + * `--cols` override flows into the break-even math too. */ +export function isCompressionProfitable(textLen: number, cols: number = DEFAULTS.cols): boolean { + const charsPerImage = maxCharsPerImage(cols); + const estImages = Math.max(1, Math.ceil(textLen / charsPerImage)); const imageTokensCost = estImages * TOKENS_PER_IMAGE; const textTokensEquivalent = textLen / CHARS_PER_TOKEN; return imageTokensCost < textTokensEquivalent; @@ -196,6 +228,11 @@ export interface TransformInfo { * returned false (image cost ≥ text cost at current cell config) * Only emitted when at least one counter is > 0. */ passthroughReasons?: { below_threshold?: number; not_profitable?: number }; + /** Number of tool_result blocks where the source text exceeded the + * per-tool_result image budget and was truncated before rendering. */ + truncatedToolResults?: number; + /** Total chars elided by paging across all tool_results this request. */ + omittedChars?: number; } // --- helpers --------------------------------------------------------------- @@ -647,6 +684,204 @@ function makeImageBlock(pngB64: string, ephemeral = false): ImageBlock { * Also returns the total `droppedChars` across all rendered images plus the * merged codepoint→count map so the caller can fold both into the request's * `info.droppedChars` / `info.droppedCodepointsTop`. */ + +// --- paging / truncation ------------------------------------------------- +// +// Anthropic's API caps a request at 100 images. A single huge tool_result +// (find over a big tree, multi-MB log dump) can blow that cap by itself. +// To keep the request valid AND not waste tokens on dozens of bottom-of-log +// images, we truncate the source text before render with a marker that +// tells the model what was elided. + +/** Visual rows a single input line will consume after soft-wrap at `cols`. */ +function lineRows(line: string, cols: number): number { + return Math.max(1, Math.ceil(line.length / cols)); +} + +/** Count the visual rows `text` will consume after soft-wrap at `cols`. */ +function countVisualRows(text: string, cols: number): number { + let rows = 0; + let lineStart = 0; + const len = text.length; + for (let i = 0; i <= len; i++) { + if (i === len || text.charCodeAt(i) === 10 /* \n */) { + const lineLen = i - lineStart; + rows += Math.max(1, Math.ceil(lineLen / cols)); + lineStart = i + 1; + } + } + return rows; +} + +/** Estimate how many images `text` will render to at the given column width. + * Counts soft-wrapped visual rows, which is what render.ts actually budgets + * against. Exported for tests + the paging gate. */ +export function estimateImageCount(textOrLen: string | number, cols: number): number { + if (typeof textOrLen === 'number') { + // Back-compat shim — numeric arg gets the looser chars-based estimate. + return Math.max(1, Math.ceil(textOrLen / Math.max(1, maxCharsPerImage(cols)))); + } + const rows = countVisualRows(textOrLen, cols); + return Math.max(1, Math.ceil(rows / LINES_PER_IMAGE)); +} + +/** Classify content so we can pick a truncation strategy. Cheap heuristics on + * the first ~4 KiB. Returns: + * - `'structured'`: JSON/YAML/diff markers at the top. Truncate tail. + * - `'log'`: ≥30% of lines start with a log level or timestamp. Truncate middle. + * - `'other'`: prose, file dumps, etc. Truncate middle. + * Exported for tests. */ +export function classifyContent(text: string): 'structured' | 'log' | 'other' { + const head = text.slice(0, 4096); + const trimmed = head.trimStart(); + if (trimmed.startsWith('{') && /^\{\s*("|\})/.test(trimmed)) return 'structured'; + if (trimmed.startsWith('[') && /^\[\s*("|\{|\[|-?\d|true\b|false\b|null\b|\])/.test(trimmed)) + return 'structured'; + if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) return 'structured'; + if (trimmed.startsWith('diff --git ') || /^---\s+\S/.test(trimmed)) return 'structured'; + const lines = head.split('\n').slice(0, 40).filter((l) => l.length > 0); + if (lines.length < 4) return 'other'; + const LOG_LINE = + /^(\[?(DEBUG|INFO|WARN|WARNING|ERROR|TRACE|FATAL)\]?\b|\d{4}-\d{2}-\d{2}[T ]?|\d{2}:\d{2}:\d{2}\b)/; + let logHits = 0; + for (const line of lines) if (LOG_LINE.test(line)) logHits++; + if (logHits / lines.length >= 0.3) return 'log'; + return 'other'; +} + +/** Build the paging marker text. The model sees this verbatim INSIDE the + * rendered image so it can reason about what was elided. */ +function buildPagingMarker(args: { + originalChars: number; + originalLines: number; + originalEstImages: number; + shownHeadLines: number; + shownTailLines: number; + omittedLines: number; + omittedChars: number; +}): string { + const tailNote = + args.shownTailLines > 0 + ? ` Showing first ${args.shownHeadLines} lines and last ${args.shownTailLines} lines.` + : ` Showing first ${args.shownHeadLines} lines (tail elided).`; + return ( + `\n\n[ pixelpipe paging: omitted ${args.omittedLines.toLocaleString('en-US')} lines ` + + `(${args.omittedChars.toLocaleString('en-US')} chars) of content here. ` + + `Original length: ${args.originalChars.toLocaleString('en-US')} chars ` + + `(${args.originalLines.toLocaleString('en-US')} lines, ~${args.originalEstImages} images).` + + `${tailNote} ]\n\n` + ); +} + +/** Truncate `text` so it renders to roughly `maxImages` images at the given + * `cols`. Picks head/tail split based on `classifyContent`. Budget measured + * in visual rows (what render.ts actually slices on). Returns the truncated + * text (with paging marker embedded) and the count of chars omitted. If + * `text` already fits, returns unchanged with `omittedChars: 0`. Exported + * for tests. */ +export function truncateForBudget( + text: string, + maxImages: number, + cols: number, +): { text: string; omittedChars: number; truncated: boolean } { + const estImages = estimateImageCount(text, cols); + if (estImages <= maxImages) return { text, omittedChars: 0, truncated: false }; + const totalRowBudget = Math.max(8, maxImages * LINES_PER_IMAGE - 6); + const shape = classifyContent(text); + const lines = text.split('\n'); + const originalLines = lines.length; + const originalChars = text.length; + + if (shape === 'structured') { + let rows = 0; + let cut = 0; + for (let i = 0; i < lines.length; i++) { + const r = lineRows(lines[i]!, cols); + if (rows + r > totalRowBudget) break; + rows += r; + cut = i + 1; + } + if (cut === 0) cut = 1; + const head = lines.slice(0, cut).join('\n'); + const omitted = originalChars - head.length; + return { + text: + head + + buildPagingMarker({ + originalChars, + originalLines, + originalEstImages: estImages, + shownHeadLines: cut, + shownTailLines: 0, + omittedLines: originalLines - cut, + omittedChars: omitted, + }), + omittedChars: omitted, + truncated: true, + }; + } + + // log / other: 60% head, 40% tail. + const headRowBudget = Math.floor(totalRowBudget * 0.6); + const tailRowBudget = totalRowBudget - headRowBudget; + let headRows = 0; + let headCut = 0; + for (let i = 0; i < lines.length; i++) { + const r = lineRows(lines[i]!, cols); + if (headRows + r > headRowBudget) break; + headRows += r; + headCut = i + 1; + } + if (headCut === 0) headCut = 1; + let tailRows = 0; + let tailStart = lines.length; + for (let i = lines.length - 1; i >= headCut; i--) { + const r = lineRows(lines[i]!, cols); + if (tailRows + r > tailRowBudget) break; + tailRows += r; + tailStart = i; + } + if (tailStart <= headCut || tailStart >= lines.length) { + const head = lines.slice(0, headCut).join('\n'); + const omitted = originalChars - head.length; + return { + text: + head + + buildPagingMarker({ + originalChars, + originalLines, + originalEstImages: estImages, + shownHeadLines: headCut, + shownTailLines: 0, + omittedLines: originalLines - headCut, + omittedChars: omitted, + }), + omittedChars: omitted, + truncated: true, + }; + } + const headText = lines.slice(0, headCut).join('\n'); + const tailText = lines.slice(tailStart).join('\n'); + const shownChars = headText.length + tailText.length; + const omitted = originalChars - shownChars; + return { + text: + headText + + buildPagingMarker({ + originalChars, + originalLines, + originalEstImages: estImages, + shownHeadLines: headCut, + shownTailLines: lines.length - tailStart, + omittedLines: originalLines - headCut - (lines.length - tailStart), + omittedChars: omitted, + }) + + tailText, + omittedChars: omitted, + truncated: true, + }; +} + async function textToImageBlocks( text: string, cols: number, @@ -823,7 +1058,7 @@ export async function transformRequest( // usually 25-30 KB so it always passes (1 image @ 2500 tokens < 25000/4 = // 6250 text-equivalent tokens), but the check guards against the edge // case where a tiny tool docs + tiny static slab combine to <10k chars. - if (!isCompressionProfitable(combined.length)) { + if (!isCompressionProfitable(combined.length, o.cols)) { info.reason = `not_profitable (slab=${combined.length} chars)`; bumpPassthrough(info, 'not_profitable'); return { body, info }; @@ -917,7 +1152,7 @@ export async function transformRequest( processedExisting.push(blk); continue; } - if (!isCompressionProfitable(textLen)) { + if (!isCompressionProfitable(textLen, o.cols)) { // Above threshold but image cost ≥ text cost. Net loss to compress. bumpPassthrough(info, 'not_profitable'); processedExisting.push(blk); @@ -976,12 +1211,18 @@ export async function transformRequest( if (inner.length < o.minToolResultChars) { bumpPassthrough(info, 'below_threshold'); rewritten.push(blk); - } else if (!isCompressionProfitable(inner.length)) { + } else if (!isCompressionProfitable(inner.length, o.cols)) { bumpPassthrough(info, 'not_profitable'); rewritten.push(blk); } else { + // Paging: truncate before render if it would blow the image cap. + const paged = truncateForBudget(inner, o.maxImagesPerToolResult, o.cols); + if (paged.truncated) { + info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1; + info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars; + } const { blocks: imgs, droppedChars, droppedCodepoints: dcp } = - await textToImageBlocks(inner, o.cols); + await textToImageBlocks(paged.text, o.cols); for (const img of imgs) info.imageBytes += approxBlockBytes(img); info.toolResultImgs = (info.toolResultImgs ?? 0) + imgs.length; info.imageCount += imgs.length; @@ -1010,13 +1251,18 @@ export async function transformRequest( newInner.push(ib as TextBlock | ImageBlock); continue; } - if (!isCompressionProfitable(innerText.length)) { + if (!isCompressionProfitable(innerText.length, o.cols)) { bumpPassthrough(info, 'not_profitable'); newInner.push(ib as TextBlock | ImageBlock); continue; } + const paged = truncateForBudget(innerText, o.maxImagesPerToolResult, o.cols); + if (paged.truncated) { + info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1; + info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars; + } const { blocks: imgs, droppedChars, droppedCodepoints: dcp } = - await textToImageBlocks(innerText, o.cols); + await textToImageBlocks(paged.text, o.cols); for (const img of imgs) { newInner.push(img); info.imageBytes += approxBlockBytes(img); diff --git a/tests/paging.test.ts b/tests/paging.test.ts new file mode 100644 index 0000000..1364d74 --- /dev/null +++ b/tests/paging.test.ts @@ -0,0 +1,470 @@ +/** + * Tests for the per-tool_result paging / truncation slice (task #42). + * + * Strategy: + * - Unit-test the truncation helpers directly (`classifyContent`, + * `estimateImageCount`, `truncateForBudget`) — they're pure, no + * rendering needed. + * - End-to-end through `transformRequest` to verify the counters land + * in `info` (`truncatedToolResults`, `omittedChars`) and that the + * image budget is actually honored. + * + * The rendered PNGs themselves are opaque in tests (we don't have an OCR + * harness in this repo), but the truncated *source* text is what + * actually carries the paging marker into the image — so verifying the + * source string is the right level. + */ + +import { describe, expect, it } from 'vitest'; +import { + classifyContent, + estimateImageCount, + truncateForBudget, + transformRequest, +} from '../src/core/transform.js'; +import { toTrackEvent } from '../src/core/tracker.js'; +import type { ProxyEvent } from '../src/core/proxy.js'; + +// Default render config: cols=100, ~141 lines/img → ~14,100 chars/img if +// lines fully fill the width. For shorter lines, the budget is dominated +// by row count (each line takes ≥1 row regardless of length). +const COLS = 100; +const ROWS_PER_IMG = 141; // floor((1568 - 8) / 11) + +describe('estimateImageCount', () => { + it('returns 1 for empty / tiny text', () => { + expect(estimateImageCount('', COLS)).toBe(1); + expect(estimateImageCount('hello world', COLS)).toBe(1); + }); + + it('scales linearly with row count for short-line content', () => { + // 141 lines of "x" (1 char) = 141 rows = 1 image. + const oneImage = Array.from({ length: 141 }, () => 'x').join('\n'); + expect(estimateImageCount(oneImage, COLS)).toBe(1); + // 142 lines = 2 images (just over the line). + const justOver = Array.from({ length: 142 }, () => 'x').join('\n'); + expect(estimateImageCount(justOver, COLS)).toBe(2); + // 10 × 141 = 1410 lines → 10 images. + const tenImages = Array.from({ length: 1410 }, () => 'x').join('\n'); + expect(estimateImageCount(tenImages, COLS)).toBe(10); + }); + + it('accounts for soft-wrap of long lines', () => { + // A single 1000-char line wraps to ceil(1000/100) = 10 rows. + const wrapped = 'x'.repeat(1000); + expect(estimateImageCount(wrapped, COLS)).toBe(1); // 10 rows, fits in 1 img + // 14,100 chars on one line → 141 rows → 1 image. + const oneImg = 'x'.repeat(14_100); + expect(estimateImageCount(oneImg, COLS)).toBe(1); + // 14,101 chars → 142 rows → 2 images. + const twoImgs = 'x'.repeat(14_101); + expect(estimateImageCount(twoImgs, COLS)).toBe(2); + }); + + it('also accepts a numeric length (legacy chars-based estimate)', () => { + expect(estimateImageCount(0, COLS)).toBe(1); + expect(estimateImageCount(14_100, COLS)).toBe(1); + expect(estimateImageCount(14_101, COLS)).toBe(2); + }); +}); + +describe('classifyContent', () => { + it('flags JSON objects as structured', () => { + const json = JSON.stringify({ foo: 'bar', baz: [1, 2, 3] }, null, 2); + expect(classifyContent(json)).toBe('structured'); + }); + + it('flags JSON arrays of objects as structured', () => { + const json = JSON.stringify( + [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ], + null, + 2, + ); + expect(classifyContent(json)).toBe('structured'); + }); + + it('flags YAML frontmatter as structured', () => { + const yaml = '---\ntitle: foo\ndate: 2026-05-18\n---\n\nBody text here.'; + expect(classifyContent(yaml)).toBe('structured'); + }); + + it('flags unified diffs as structured', () => { + const diff = + 'diff --git a/foo.ts b/foo.ts\nindex 1234..5678 100644\n--- a/foo.ts\n+++ b/foo.ts\n@@ -1,3 +1,3 @@\n-old\n+new\n'; + expect(classifyContent(diff)).toBe('structured'); + }); + + it('flags ISO-timestamp lines as log', () => { + const log = Array.from( + { length: 20 }, + (_, i) => `2026-05-18T12:00:${String(i).padStart(2, '0')}Z some log line`, + ).join('\n'); + expect(classifyContent(log)).toBe('log'); + }); + + it('flags [LEVEL] prefix lines as log', () => { + const log = Array.from( + { length: 20 }, + (_, i) => `[INFO] line ${i} doing a thing`, + ).join('\n'); + expect(classifyContent(log)).toBe('log'); + }); + + it('flags bare HH:MM:SS prefix lines as log', () => { + const log = Array.from( + { length: 20 }, + (_, i) => `12:00:${String(i).padStart(2, '0')} event ${i}`, + ).join('\n'); + expect(classifyContent(log)).toBe('log'); + }); + + it('does NOT flag a stack trace that opens with [ERROR] alone as structured', () => { + // Only 4 lines — too few to log-classify cleanly, falls back to other. + const text = '[ERROR] something went wrong\n at foo()\n at bar()\n at baz()'; + // log-line threshold needs ≥30% of ≥4 non-empty lines to start with a + // log marker. Just the first line does → 1/4 = 25%, fails → other. + expect(classifyContent(text)).toBe('other'); + }); + + it('falls back to other for plain prose', () => { + const prose = + 'The quick brown fox jumps over the lazy dog.\n'.repeat(20); + expect(classifyContent(prose)).toBe('other'); + }); + + it('falls back to other for very short input (under 4 lines)', () => { + expect(classifyContent('one line')).toBe('other'); + expect(classifyContent('one\ntwo\nthree')).toBe('other'); + }); +}); + +describe('truncateForBudget', () => { + it('passes through text under the budget unchanged', () => { + const text = 'x'.repeat(1000); // way under 10-image budget + const { text: out, omittedChars, truncated } = truncateForBudget(text, 10, COLS); + expect(truncated).toBe(false); + expect(omittedChars).toBe(0); + expect(out).toBe(text); + }); + + it('truncates head+tail for log-shaped content over the budget', () => { + // 10k log lines, each ~32 chars → ~320k chars total. With short lines + // the row budget dominates: 10k rows >> 10 × 141 = 1410 row budget. + const lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push(`2026-05-18T12:00:${String(i % 60).padStart(2, '0')}Z entry ${i}`); + } + const log = lines.join('\n'); + expect(log.length).toBeGreaterThan(300_000); + + const { text: out, omittedChars, truncated } = truncateForBudget(log, 10, COLS); + expect(truncated).toBe(true); + expect(omittedChars).toBeGreaterThan(0); + // Output should fit in the 10-image budget (count visual rows). + expect(estimateImageCount(out, COLS)).toBeLessThanOrEqual(10); + // Marker present + expect(out).toContain('pixelpipe paging:'); + // Head + tail format: marker mentions both first and last lines + expect(out).toMatch(/Showing first \d+ lines and last \d+ lines/); + // Both ends visible: first log entry and last log entry survive + expect(out).toContain('entry 0\n'); // first line + expect(out).toContain(`entry ${9999}`); // last line (no newline after) + }); + + it('truncates tail-only for structured (JSON) content over the budget', () => { + // Build a huge JSON-shaped blob. + const items = Array.from({ length: 5000 }, (_, i) => ({ + id: i, + name: `item-${i}`, + payload: 'x'.repeat(100), + })); + const json = JSON.stringify(items, null, 2); + expect(json.length).toBeGreaterThan(500_000); + + const { text: out, omittedChars, truncated } = truncateForBudget(json, 10, COLS); + expect(truncated).toBe(true); + expect(omittedChars).toBeGreaterThan(0); + expect(estimateImageCount(out, COLS)).toBeLessThanOrEqual(10); + // Marker present + expect(out).toContain('pixelpipe paging:'); + // Tail-only format: marker says "tail elided", NOT head+tail + expect(out).toContain('tail elided'); + expect(out).not.toMatch(/Showing first \d+ lines and last \d+ lines/); + // Head preserved (the structure opens with `[`) + expect(out.trimStart().startsWith('[')).toBe(true); + // First few items present + expect(out).toContain('"item-0"'); + expect(out).toContain('"item-1"'); + // Last item is NOT present (it was the tail we dropped) + expect(out).not.toContain('"item-4999"'); + }); + + it('truncates head+tail for unclassified prose (default behavior)', () => { + // A blob with no log/JSON markers — random prose, repeated. + const para = + 'The quick brown fox jumps over the lazy dog and goes home for dinner.\n'; + const prose = para.repeat(8000); // ~550k chars + + const { text: out, omittedChars, truncated } = truncateForBudget(prose, 10, COLS); + expect(truncated).toBe(true); + expect(omittedChars).toBeGreaterThan(0); + expect(estimateImageCount(out, COLS)).toBeLessThanOrEqual(10); + expect(out).toContain('pixelpipe paging:'); + // Default prose gets head+tail (not tail-only) + expect(out).toMatch(/Showing first \d+ lines and last \d+ lines/); + }); + + it('marker reports accurate omitted-lines and original-size numbers', () => { + // Predictable shape: 10,000 lines of "logline N" — easy to count. + const lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push(`2026-05-18T12:00:00Z logline ${i} something`); + } + const log = lines.join('\n'); + const originalChars = log.length; + const originalLines = lines.length; + + const { text: out, omittedChars } = truncateForBudget(log, 10, COLS); + + // Pull the numbers out of the marker. + const omittedLinesMatch = out.match( + /omitted ([\d,]+) lines \(([\d,]+) chars\)/, + ); + const originalMatch = out.match(/Original length: ([\d,]+) chars \(([\d,]+) lines/); + expect(omittedLinesMatch).not.toBeNull(); + expect(originalMatch).not.toBeNull(); + + const parseNum = (s: string) => parseInt(s.replaceAll(',', ''), 10); + const reportedOmittedLines = parseNum(omittedLinesMatch![1]!); + const reportedOmittedChars = parseNum(omittedLinesMatch![2]!); + const reportedOriginalChars = parseNum(originalMatch![1]!); + const reportedOriginalLines = parseNum(originalMatch![2]!); + + // Original size numbers should match exactly. + expect(reportedOriginalChars).toBe(originalChars); + expect(reportedOriginalLines).toBe(originalLines); + // Omitted-chars number in marker should match the returned count. + expect(reportedOmittedChars).toBe(omittedChars); + // Omitted lines should be most-but-not-all of the original. + expect(reportedOmittedLines).toBeGreaterThan(0); + expect(reportedOmittedLines).toBeLessThan(originalLines); + }); + + 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); + const { text: out, truncated } = truncateForBudget(text, 10, COLS); + // No newlines means lines.length === 1, so "truncation" can only show + // that single line. Verify behavior is sane (doesn't crash, marker + // present somewhere if truncated). + if (truncated) { + expect(out).toContain('pixelpipe paging:'); + } + }); +}); + +// -- end-to-end through transformRequest ----------------------------------- + +function makeReq(toolResultText: string) { + return new TextEncoder().encode( + JSON.stringify({ + model: 'claude-3-5-sonnet', + // Force compression to fire: need a system slab past the per-block + // break-even (≥10k chars) so the main static-slab compression runs + // and `info.compressed` flips to true. Smaller slabs no-op out via + // isCompressionProfitable and the test wouldn't see compressed=true. + system: 'x'.repeat(60_000), + messages: [ + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_x', content: toolResultText }, + ], + }, + ], + }), + ); +} + +describe('paging end-to-end (transformRequest)', () => { + it('tool_result under cap renders normally (no truncation counters)', async () => { + // Above 10k break-even, well under the 10-image budget (~140k chars). + const text = 'x'.repeat(40_000); + const { info } = await transformRequest(makeReq(text)); + expect(info.compressed).toBe(true); + expect((info.toolResultImgs ?? 0)).toBeGreaterThan(0); + expect(info.truncatedToolResults ?? 0).toBe(0); + expect(info.omittedChars ?? 0).toBe(0); + }); + + it('tool_result over cap fires truncation, lands ≤ 10 images', async () => { + // ~500k char log → ~36 raw images, should clamp to ≤10. + const lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push(`2026-05-18T12:00:00Z entry ${i} payload content here`); + } + const log = lines.join('\n'); + expect(log.length).toBeGreaterThan(400_000); + + const { info } = await transformRequest(makeReq(log)); + expect(info.compressed).toBe(true); + expect(info.truncatedToolResults).toBe(1); + expect(info.omittedChars).toBeGreaterThan(0); + // Image count for this tool_result should be capped at the budget. + // (Allow 1-image slack for the marker / rounding.) + expect(info.toolResultImgs).toBeLessThanOrEqual(11); + }); + + it('respects a custom maxImagesPerToolResult option', async () => { + const lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push(`2026-05-18T12:00:00Z entry ${i} payload content here`); + } + const log = lines.join('\n'); + + // Tight budget of 2 images = ~28k chars. + const { info } = await transformRequest(makeReq(log), { + maxImagesPerToolResult: 2, + }); + expect(info.truncatedToolResults).toBe(1); + expect(info.toolResultImgs).toBeLessThanOrEqual(3); // 2 + slack + }); + + it('counts multiple tool_results that all exceed the budget', async () => { + const lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push(`2026-05-18T12:00:00Z entry ${i} payload content here`); + } + const log = lines.join('\n'); + + // Two big tool_results in one request. + const req = new TextEncoder().encode( + JSON.stringify({ + model: 'claude-3-5-sonnet', + system: 'x'.repeat(60_000), + messages: [ + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_a', content: log }, + { type: 'tool_result', tool_use_id: 'toolu_b', content: log }, + ], + }, + ], + }), + ); + const { info } = await transformRequest(req); + expect(info.truncatedToolResults).toBe(2); + // Both should have been truncated → omittedChars roughly doubled. + expect(info.omittedChars).toBeGreaterThan(800_000 - 30_000); + }); + + it('handles array-shaped tool_result content', async () => { + const lines: string[] = []; + for (let i = 0; i < 10_000; i++) { + lines.push(`2026-05-18T12:00:00Z entry ${i} payload content here`); + } + const log = lines.join('\n'); + + // Array shape: tool_result content is [{type: 'text', text: ...}] + const req = new TextEncoder().encode( + JSON.stringify({ + model: 'claude-3-5-sonnet', + system: 'x'.repeat(60_000), + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_x', + content: [{ type: 'text', text: log }], + }, + ], + }, + ], + }), + ); + const { info } = await transformRequest(req); + expect(info.truncatedToolResults).toBe(1); + expect(info.omittedChars).toBeGreaterThan(0); + expect(info.toolResultImgs).toBeLessThanOrEqual(11); + }); +}); + +// -- tracker wire-through --------------------------------------------------- + +describe('paging telemetry → TrackEvent', () => { + it('forwards truncated_tool_results and omitted_chars when set', () => { + const ev: ProxyEvent = { + method: 'POST', + path: '/v1/messages', + status: 200, + durationMs: 100, + info: { + compressed: true, + origChars: 500_000, + imageCount: 10, + imageBytes: 20_000, + staticChars: 0, + dynamicChars: 0, + dynamicBlockCount: 0, + truncatedToolResults: 2, + omittedChars: 350_000, + }, + }; + const out = toTrackEvent(ev); + expect(out.truncated_tool_results).toBe(2); + expect(out.omitted_chars).toBe(350_000); + }); + + it('omits the fields when no truncation fired (zero / undefined)', () => { + const ev: ProxyEvent = { + method: 'POST', + path: '/v1/messages', + status: 200, + durationMs: 100, + info: { + compressed: true, + origChars: 10_000, + imageCount: 1, + imageBytes: 2_000, + staticChars: 0, + dynamicChars: 0, + dynamicBlockCount: 0, + // truncatedToolResults: undefined + // omittedChars: undefined + }, + }; + const out = toTrackEvent(ev); + expect(out.truncated_tool_results).toBeUndefined(); + expect(out.omitted_chars).toBeUndefined(); + }); + + it('omits the fields when explicitly zero (no-op truncation pass)', () => { + const ev: ProxyEvent = { + method: 'POST', + path: '/v1/messages', + status: 200, + durationMs: 100, + info: { + compressed: true, + origChars: 10_000, + imageCount: 1, + imageBytes: 2_000, + staticChars: 0, + dynamicChars: 0, + dynamicBlockCount: 0, + truncatedToolResults: 0, + omittedChars: 0, + }, + }; + const out = toTrackEvent(ev); + // Skipped because the wire-through gate is `> 0`. + expect(out.truncated_tool_results).toBeUndefined(); + expect(out.omitted_chars).toBeUndefined(); + }); +}); diff --git a/tests/render.test.ts b/tests/render.test.ts index 1896ecd..1651045 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -6,7 +6,7 @@ import { minifyForRender, } from '../src/core/render.js'; import { encodeGrayPng, bytesToBase64 } from '../src/core/png.js'; -import { transformRequest, isCompressionProfitable } from '../src/core/transform.js'; +import { transformRequest, isCompressionProfitable, maxCharsPerImage } from '../src/core/transform.js'; import { atlasRank, ATLAS_CELL_H, @@ -1499,6 +1499,53 @@ describe('transform', () => { expect(isCompressionProfitable(40000)).toBe(true); }); + // --- Adaptive break-even: CHARS_PER_IMAGE derived from atlas cell, not hardcoded --- + // Brief: when font-rater swaps to a smaller cell (e.g. Cozette 4×7), more chars + // pack into one image, so the N-image break-even thresholds shift. Tests below + // verify both the regression case (current Unifont 5×11) AND that the formula + // responds to `cols` (which scales chars/image linearly the same way a smaller + // cell-H would). + + it('maxCharsPerImage: matches the historic 14,100 constant at the shipping config', () => { + // Unifont 5×11, cols=100 → floor((1568−8)/11) × 100 = 141 × 100 = 14,100. + // If this ever drifts, every break-even test downstream needs re-pinning. + expect(maxCharsPerImage(100)).toBe(14_100); + }); + + it('maxCharsPerImage: scales linearly with cols (same atlas)', () => { + expect(maxCharsPerImage(50)).toBe(7_050); + expect(maxCharsPerImage(200)).toBe(28_200); + }); + + it('isCompressionProfitable: doubling cols halves the 2-image break-even threshold', () => { + // At cols=100, CHARS_PER_IMAGE=14,100. 20,000 chars needs 2 images (cost + // 5000 tokens) vs 5000 text-tokens → tied, strict `<` returns false. + expect(isCompressionProfitable(20_000, 100)).toBe(false); + // At cols=200, CHARS_PER_IMAGE=28,200. 20,000 chars fits in 1 image + // (cost 2500 tokens) vs 5000 text-tokens → clear win. + expect(isCompressionProfitable(20_000, 200)).toBe(true); + }); + + it('isCompressionProfitable: tiny-cols config raises the break-even threshold', () => { + // Simulated narrow render: cols=20 → CHARS_PER_IMAGE=2820. A 10,001-char + // block needs ceil(10001/2820)=4 images (10,000 tokens) vs 2500 text → + // huge net loss. At cols=100 the same block was profitable. + expect(isCompressionProfitable(10_001, 100)).toBe(true); + expect(isCompressionProfitable(10_001, 20)).toBe(false); + }); + + it('isCompressionProfitable: smaller-cell atlas (Cozette-shape) would let 16k blocks become 1-image wins (cols proxy)', () => { + // True smaller-cell test would need to mock ATLAS_CELL_H. We use cols as + // a proxy since CHARS_PER_IMAGE = cols × floor((1568−8)/cell_H) — doubling + // cols at fixed cell_H is mathematically the same as halving cell_H at + // fixed cols. A Cozette 4×7 cell at cols=100 yields floor(1560/7)×100 = + // 22,200 chars/image, ~57% more than today. Equivalent: cols=157 at the + // current cell. A 16,000-char block needs 2 images today (2-image break- + // even fails); at the equivalent Cozette-shape config it fits in 1. + expect(isCompressionProfitable(16_000, 100)).toBe(false); // 2 imgs @ 5000 vs 4000 text + expect(isCompressionProfitable(16_000, 157)).toBe(true); // 1 img @ 2500 vs 4000 text + }); + it('break-even gate: 7000-char tool_result stays as text (below break-even)', async () => { // Above the old 5000 minToolResultChars cutoff but still net-loss to // image (image=2500 > text=7000/4=1750). The fast-path threshold (now