mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
f1688e9f46
Two pieces of work landing together because they're interleaved in transform.ts and need to ship at the same time for the dashboard fit-wiring in the next commit to work. # R2 multi-column rendering (default `multiCol=2`) Single-col packing wastes ~68% of Anthropic's 1568×1568 image budget on real Claude Code tool docs (~38 chars/row). The row-aware break- even gate correctly rejects compression on those slabs — proxy ends up doing nothing on the heaviest requests. Multi-col packs N text columns side-by-side per image so each image covers `N×LINES_PER_IMAGE` wrapped lines, dropping image count by ~N and crossing break-even. - render.ts: new `renderTextToPngsMultiCol(text, cols, numCols)` with column-major layout (col 0 fills top-to-bottom, then col 1, …). Plus `multiColWidth` / `maxFittingCols` helpers. CLI auto-clamps numCols so the canvas can never exceed the 1568 px width cap. - transform.ts: `multiCol` option threaded through; gate, image-count estimator, paging budget, and all render paths take `numCols`. Intro text now describes column reading order when numCols>1 so the OCR pass doesn't read row-wise across columns. - Host wiring: node.ts (--multi-col / MULTI_COL=N, default 2), worker.ts (MULTI_COL env, default 2). Startup log shows multi_col=N. - numCols=1 stays a byte-identical passthrough to renderTextToPngs so flipping back can't regress the cache_control hit rate. # Empirical-cost instrumentation The dashboard's `OPUS_IMAGE_TOKEN_COST = 2500` and `CHARS_PER_TOKEN = 4` are stale pre-Opus-4.7 constants. Fact-checked: 4.7 changed the TEXT tokenizer only (~1.20× inflation on English, ~1.36× on TypeScript); image cost is unchanged for same-size images. Direction of error in the dashboard headline: it UNDERSTATES text savings. Ditched the "guess from one cold-miss" approach in favor of real instrumentation that pins both axes at once: - TransformInfo.imagePixels — Σ width×height across every image this request (static slab + reminders + tool_results + history collapse). Threaded through textToImageBlocks, collapseHistory. - TransformInfo.outgoingTextChars — every char of TEXT remaining in the transformed outgoing body, computed in one walk before serialization (countOutgoingTextChars). - Tracker event schema picks up image_pixels + outgoing_text_chars. - history.ts: HistoryCollapseInfo.collapsedImagePixels accumulates. The regression `tokens ≈ α·outgoing_text_chars + β·image_pixels` over N cold-miss events solves for empirical chars/token (α) and pixels/token (β) under the live model — no assumed constants. # Tests - 8 new render tests covering the multi-col passthrough contract (numCols=1 byte-identical to renderTextToPngs), canvas dimensions, image-count halving, determinism, charsRendered sum, estimator parity, CLI clamp, and CJK wide-glyph wrap. - paging.test.ts "counts multiple tool_results" threshold loosened from >770k to >700k to cover both single-col and multi-col budgets at the same maxImagesPerToolResult cap. - All 241 tests pass, typecheck + build clean.
474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
/**
|
||
* 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. The
|
||
// exact bound depends on renderer config: at multiCol=1 each image
|
||
// packs ~14k chars worst-case, multiCol=2 packs ~28k → less omitted
|
||
// at the same maxImagesPerToolResult cap. Threshold below covers both.
|
||
expect(info.omittedChars).toBeGreaterThan(700_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();
|
||
});
|
||
});
|