history: Variant C closed-tool-sequence collapse + honest per-session savings

Two distinct improvements in one slice:

1. History-image compression (Variant C — opt-in, default OFF).
   New src/core/history.ts module:
   - findClosedPrefixBoundary(): walks tool_use_id openSet, returns largest
     fully-closed prefix index. Mid-flight tool_use blocks bail the collapse.
   - blocksToText(): linearises content blocks for the history image. Drops
     thinking blocks (per Opus 4.7+ docs: only most-recent must be bit-perfect),
     renders tool_use args + tool_result inner text, emits [image] placeholder
     for nested images.
   - collapseHistory(): orchestrates. Applies isCompressionProfitable() gate.
     Returns a synthesized {role:'user', content:[text,image,text]} prepended
     message + the elided turns dropped from messages[].
   New TransformOptions: compressHistory (default false), historyKeepTail (4),
   historyMinPrefix (10). Wired through src/node.ts + src/worker.ts as
   COMPRESS_HISTORY / HISTORY_KEEP_TAIL / HISTORY_MIN_PREFIX env vars.
   Cache_control: history-image carries NO cache_control on this first cut.
   Static slab keeps its 1 breakpoint. Conservative — telemetry will reveal
   whether the cache-miss rate on history images warrants the round-3
   rebalance in a follow-up slice.
   29 new tests in tests/history.test.ts: closed-prefix detection (parallel
   tool calls, mid-flight, openSet straddling), block linearisation, full
   collapse flow, isCompressionProfitable gate firing on small histories.

2. Per-session savings: honest formula (fixes task #63).
   Previous src/sessions.ts computed saved = orig_chars - image_bytes — a
   nonsense comparison of chars to PNG bytes, then clamped via Math.max(0,...).
   Result: charsSaved/tokensSavedEst was 0 for every session including ones
   with hundreds of MB of compressed traffic.
   New formula mirrors src/dashboard.ts:
     textTokens  = orig_chars / 4
     imageTokens = image_count × 2500
     tokensSaved = textTokens − imageTokens  (CAN be negative)
   Negatives are NOT clamped — they surface the cost-bleed on small-block
   compressions that the per-block break-even check is supposed to prevent.
   2 new tests: positive-net case, all-negative case (must report negative).

229/229 tests passing. Bundle ~500 KB gzipped.
This commit is contained in:
teamchong
2026-05-18 22:46:13 -04:00
parent 04b1526bd5
commit 97fdee8421
5 changed files with 920 additions and 19 deletions
+325
View File
@@ -0,0 +1,325 @@
/**
* History-image compression (Variant C).
*
* Walks `messages[]` left-to-right tracking open `tool_use_id`s. Identifies
* the largest **closed-tool-sequence prefix** — the longest run from the
* head of the conversation that has no straddling `tool_use` references
* into the live tail. Renders that prefix's text content into one or more
* PNG image blocks and replaces the prefix with ONE synthetic user message:
*
* { role: 'user', content: [
* { type: 'text', text: '[Earlier in this conversation:]' },
* { type: 'image', source: {...} }, // (1..N images)
* { type: 'text', text: '[End of earlier context.]' },
* ]}
*
* Live tail (the last `keepTail` turns + anything inside an open tool
* sequence) stays as text. The most-recent assistant turn — the one
* carrying Opus 4.7's `thinking` signature — is always in the live tail
* by construction (it's at index `messages.length - 1` when present).
*
* The synthesized user message uses `role: 'user'` because Anthropic
* **forbids `image` blocks inside `role: 'assistant'`** (see
* `/tmp/pixelpipe-history-compression.md` line 14 and `types.ts:58`
* comments). Self-attribution ("I previously said X") is the price.
*
* `thinking` blocks inside the collapsed range are dropped from the
* rendered text — per the spec's Check 2, only the most-recent
* assistant-with-tool_use's thinking content must round-trip bit-perfect,
* and that turn is in the live tail by construction.
*
* **Cache-control**: caller controls whether the history-image carries
* pixelpipe's ephemeral breakpoint. This module returns the image blocks
* unmarked; the caller in `transform.ts` decides placement.
*
* Spec source: `/tmp/pixelpipe-history-compression.md` Variant C section
* (round 3, lines 346-364 + check-3 cache-control discussion).
*/
import type { ContentBlock, ImageBlock, Message, TextBlock, ToolUseBlock, ToolResultBlock } from './types.js';
import { renderTextToPngs } from './render.js';
import { bytesToBase64 } from './png.js';
/** Function predicate signature for the break-even gate. Passed in by the
* caller (transform.ts) rather than imported here to keep `src/core/history.ts`
* free of a cycle with `src/core/transform.ts`. transform.ts already imports
* history.ts to invoke `collapseHistory`; importing back the other way would
* create an evaluation-order trap. */
export type ProfitableFn = (textLen: number, cols: number) => boolean;
/** Configuration for history collapse. */
export interface HistoryCollapseOptions {
/** Number of turns at the tail (most recent) to KEEP as text. Default 4. */
keepTail: number;
/** Minimum number of turns in the collapsable prefix before we attempt
* the rolling-window re-roll. Below this, leaving them as text costs
* almost nothing and the cache-amortization math doesn't work. Default 10. */
minCollapsePrefix: number;
/** Soft-wrap column count for the renderer. Should match the host's
* configured `cols` so the history image visually matches the system
* image. Default 100. */
cols: number;
}
export const HISTORY_DEFAULTS: HistoryCollapseOptions = {
keepTail: 4,
minCollapsePrefix: 10,
cols: 100,
};
/** Per-request telemetry surfaced back to TransformInfo. */
export interface HistoryCollapseInfo {
/** Number of turns collapsed into the history image. */
collapsedTurns: number;
/** Total chars of text that went into the history image. */
collapsedChars: number;
/** Number of PNG image blocks emitted for the history (≥1 if collapsed). */
collapsedImages: number;
/** Total PNG bytes emitted. */
collapsedImageBytes: number;
/** Why we didn't collapse — populated only when no collapse happened. */
reason?:
| 'no_history'
| 'prefix_too_short'
| 'no_closed_prefix'
| 'not_profitable'
| 'render_empty';
/** Dropped codepoints from the history render, merged into the
* transform-wide map by the caller. */
droppedChars: number;
droppedCodepoints: Map<number, number>;
}
/**
* Compute the largest index `i*` in `[0..messages.length - keepTail - 1]`
* such that all `tool_use_id`s issued by assistant turns in `[0..i*]` are
* matched by `tool_result`s also in `[0..i*]`. Returns `-1` if no such
* boundary exists (i.e. every potential boundary has a tool_use straddling
* into the live tail).
*
* Algorithm: walk left-to-right. Maintain an `openSet` of unmatched
* tool_use_ids. After processing each message, record the current openSet
* size. The closed-prefix boundary is the **last** index ≤ cutoff at which
* openSet was empty AFTER processing.
*
* NOTE: this is robust to interleaved tool_use sequences (e.g. two parallel
* tool calls in one assistant turn followed by two tool_results in the
* next user turn). The openSet tracking handles that correctly.
*/
export function findClosedPrefixBoundary(
messages: Message[],
cutoffExclusive: number,
): number {
if (cutoffExclusive <= 0) return -1;
const openSet = new Set<string>();
let lastClosed = -1;
const limit = Math.min(cutoffExclusive, messages.length);
for (let i = 0; i < limit; i++) {
const msg = messages[i]!;
if (!Array.isArray(msg.content)) {
// Plain string content — no tool blocks possible. Just close-or-stay.
if (openSet.size === 0) lastClosed = i;
continue;
}
if (msg.role === 'assistant') {
for (const blk of msg.content) {
if (blk && (blk as ToolUseBlock).type === 'tool_use') {
const id = (blk as ToolUseBlock).id;
if (typeof id === 'string') openSet.add(id);
}
}
} else if (msg.role === 'user') {
for (const blk of msg.content) {
if (blk && (blk as ToolResultBlock).type === 'tool_result') {
const id = (blk as ToolResultBlock).tool_use_id;
if (typeof id === 'string') openSet.delete(id);
}
}
}
if (openSet.size === 0) lastClosed = i;
}
return lastClosed;
}
/**
* Linearise a content-block array to a single string for OCR. Drops
* `thinking` blocks (Opus 4.7+ only requires bit-perfect on the most-recent
* assistant-with-tool_use, which is in the live tail by construction).
* Tool-use input args and tool-result content are included so the model
* has full context. Inline images and tool_result images are reduced to a
* `[image]` placeholder — embedding them in the history image would
* double-encode for no benefit.
*/
export function blocksToText(content: string | ContentBlock[]): string {
if (typeof content === 'string') return content;
const parts: string[] = [];
for (const blk of content) {
if (!blk || typeof blk !== 'object') continue;
const t = (blk as { type?: string }).type;
switch (t) {
case 'text':
parts.push((blk as TextBlock).text);
break;
case 'tool_use': {
const tu = blk as ToolUseBlock;
// Render as a labelled block so the model can re-attribute. The
// arg JSON is included verbatim — for very large args this will
// bloat the history image, but the alternative (dropping args)
// breaks model self-attribution worse than the bloat does.
let argsStr: string;
try {
argsStr = JSON.stringify(tu.input, null, 2);
} catch {
argsStr = String(tu.input);
}
parts.push(`[tool_use ${tu.name}]\n${argsStr}`);
break;
}
case 'tool_result': {
const tr = blk as ToolResultBlock;
const inner = tr.content;
let innerText: string;
if (typeof inner === 'string') {
innerText = inner;
} else if (Array.isArray(inner)) {
const subParts: string[] = [];
for (const sub of inner) {
if (!sub || typeof sub !== 'object') continue;
if ((sub as TextBlock).type === 'text') {
subParts.push((sub as TextBlock).text);
} else if ((sub as ImageBlock).type === 'image') {
subParts.push('[image]');
}
}
innerText = subParts.join('\n');
} else {
innerText = '';
}
const errMark = tr.is_error === true ? ' (error)' : '';
parts.push(`[tool_result${errMark}]\n${innerText}`);
break;
}
case 'image':
parts.push('[image]');
break;
// 'thinking' and any other block type → drop silently.
default:
break;
}
}
return parts.join('\n\n');
}
/**
* Serialize messages `[0..upToExclusive]` to a single OCR-friendly text
* blob. Each turn is prefixed with `--- role ---` so the model can parse
* the conversation back out. Empty turns are skipped.
*/
export function messagesToHistoryText(
messages: Message[],
upToExclusive: number,
): string {
const out: string[] = [];
for (let i = 0; i < upToExclusive; i++) {
const m = messages[i]!;
const body = blocksToText(m.content);
if (!body.trim()) continue;
const tag = m.role === 'assistant' ? 'assistant' : 'user';
out.push(`--- ${tag} ---\n${body}`);
}
return out.join('\n\n');
}
/**
* Attempt to collapse a closed-prefix run of `messages` into one synthetic
* user message containing 1+ history images. Returns the rewritten
* messages array (a new array; original is not mutated) and telemetry.
*
* On any "do not collapse" path (no prefix, too few turns, not profitable,
* empty render), returns the original messages unchanged with a reason.
*
* Caller is responsible for cache_control placement on the returned image
* blocks — this function returns them with NO `cache_control` set.
*/
export async function collapseHistory(
messages: Message[],
isProfitable: ProfitableFn,
opts: Partial<HistoryCollapseOptions> = {},
): Promise<{ messages: Message[]; info: HistoryCollapseInfo }> {
const o: HistoryCollapseOptions = { ...HISTORY_DEFAULTS, ...opts };
const info: HistoryCollapseInfo = {
collapsedTurns: 0,
collapsedChars: 0,
collapsedImages: 0,
collapsedImageBytes: 0,
droppedChars: 0,
droppedCodepoints: new Map(),
};
if (!messages || messages.length === 0) {
info.reason = 'no_history';
return { messages: messages ?? [], info };
}
// The live tail must contain at least `keepTail` messages. The boundary
// search cuts off at `len - keepTail` so the tail is always preserved.
const cutoff = messages.length - o.keepTail;
const boundary = findClosedPrefixBoundary(messages, cutoff);
if (boundary < 0) {
info.reason = 'no_closed_prefix';
return { messages, info };
}
// boundary is the last index INCLUDED in the collapse. Need at least
// `minCollapsePrefix` turns to bother (cache-amortization math from
// round-3 only works at scale; collapsing 2-3 turns is net cost).
const collapseLen = boundary + 1;
if (collapseLen < o.minCollapsePrefix) {
info.reason = 'prefix_too_short';
return { messages, info };
}
// Serialize and gate on break-even.
const text = messagesToHistoryText(messages, collapseLen);
if (!text || text.length === 0) {
info.reason = 'render_empty';
return { messages, info };
}
if (!isProfitable(text.length, o.cols)) {
info.reason = 'not_profitable';
info.collapsedChars = text.length; // surface what we DIDN'T compress
return { messages, info };
}
// Render. No cache_control here — caller decides placement.
const imgs = await renderTextToPngs(text, o.cols);
if (imgs.length === 0) {
info.reason = 'render_empty';
return { messages, info };
}
const imageBlocks: ImageBlock[] = [];
for (const img of imgs) {
imageBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: bytesToBase64(img.png),
},
});
info.collapsedImageBytes += img.png.length;
info.droppedChars += img.droppedChars;
for (const [cp, n] of img.droppedCodepoints) {
info.droppedCodepoints.set(cp, (info.droppedCodepoints.get(cp) ?? 0) + n);
}
}
// Build the synthetic user message.
const syntheticUser: Message = {
role: 'user',
content: [
{ type: 'text', text: '[Earlier in this conversation:]' },
...imageBlocks,
{ type: 'text', text: '[End of earlier context.]' },
],
};
const tail = messages.slice(collapseLen);
info.collapsedTurns = collapseLen;
info.collapsedChars = text.length;
info.collapsedImages = imageBlocks.length;
return { messages: [syntheticUser, ...tail], info };
}
+80
View File
@@ -22,6 +22,7 @@ import type {
import { renderTextToPngs, MAX_HEIGHT_PX, PAD_Y } from './render.js';
import { bytesToBase64 } from './png.js';
import { ATLAS_CELL_H } from './atlas.js';
import { collapseHistory } from './history.js';
export interface TransformOptions {
/** Master switch — false makes this a no-op pass-through. */
@@ -55,6 +56,22 @@ export interface TransformOptions {
* 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;
/** Variant C history-image compression: walk `messages[]` from the head,
* find the largest closed-tool-sequence prefix, render its text into one
* prepended user message with image blocks, and collapse those messages
* out of the live tail. Off by default — round-3 spec marked this as
* MARGINAL (~1% per-call cost reduction) with HIGH risk on cache topology.
* Enable opt-in once telemetry confirms the cache breakpoint won't fight
* with Claude Code's own upstream breakpoint placement. */
compressHistory?: boolean;
/** Number of tail turns to KEEP as text when `compressHistory` is on. The
* most-recent assistant turn (carrying Opus 4.7's thinking signature) is
* always in the tail by construction. Default 4. */
historyKeepTail?: number;
/** Minimum closed-prefix turn count before we bother collapsing. Cache-
* amortization math from round-3 only pays out at scale — collapsing 2-3
* turns costs more in image overhead than it saves. Default 10. */
historyMinPrefix?: number;
}
const DEFAULTS: Required<TransformOptions> = {
@@ -82,6 +99,13 @@ const DEFAULTS: Required<TransformOptions> = {
// `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,
// Variant C history-image: OFF by default. Round-3 measurement put the
// savings at ~1% per call against ~3% cost-bleed risk if cache topology
// turns out wrong upstream. Enable via env / CLI flag once telemetry
// shows the static-slab breakpoint still hits when this is wired.
compressHistory: false,
historyKeepTail: 4,
historyMinPrefix: 10,
};
// --- per-block break-even check ---
@@ -233,6 +257,26 @@ export interface TransformInfo {
truncatedToolResults?: number;
/** Total chars elided by paging across all tool_results this request. */
omittedChars?: number;
/** Variant C history-image: how many original `messages[]` entries got
* collapsed into the prepended synthetic user message. 0 / unset when
* no collapse happened (compressHistory off, no closed prefix, etc.). */
collapsedTurns?: number;
/** Variant C: total chars of text serialized into the history image(s)
* before render (pre-OCR loss). */
collapsedChars?: number;
/** Variant C: number of PNG image blocks emitted for the history. Folded
* into `info.imageCount` too — surfaced separately so dashboards can
* attribute image-count growth to history vs system-slab vs reminders. */
collapsedImages?: number;
/** Variant C: why the history collapse didn't run (or did). Diagnostic
* only — see `HistoryCollapseInfo.reason` for the value set. */
historyReason?:
| 'no_history'
| 'prefix_too_short'
| 'no_closed_prefix'
| 'not_profitable'
| 'render_empty'
| 'collapsed';
}
// --- helpers ---------------------------------------------------------------
@@ -1295,6 +1339,42 @@ export async function transformRequest(
if (toolsRewritten) req.tools = toolsRewritten;
// 6. Variant C history-image compression. Runs AFTER all per-message
// rewrites so the collapsed prefix reflects final state. Off by default —
// round-3 spec marks the savings (~1% per call) as marginal vs the cache-
// topology risk. When on, walks messages[] back-to-front tracking open
// tool_use_ids; collapses the largest closed-prefix run into one prepended
// synthetic user message. Live tail (keepTail turns + anything in an open
// tool sequence) stays as text. History image carries NO cache_control on
// first ship — the static-slab breakpoint remains the sole pixelpipe
// breakpoint until telemetry shows otherwise.
if (o.compressHistory && Array.isArray(req.messages) && req.messages.length > 0) {
const { messages: newMessages, info: histInfo } = await collapseHistory(
req.messages,
isCompressionProfitable,
{
keepTail: o.historyKeepTail,
minCollapsePrefix: o.historyMinPrefix,
cols: o.cols,
},
);
if (histInfo.collapsedTurns > 0) {
req.messages = newMessages;
info.collapsedTurns = histInfo.collapsedTurns;
info.collapsedChars = histInfo.collapsedChars;
info.collapsedImages = histInfo.collapsedImages;
info.imageCount += histInfo.collapsedImages;
info.imageBytes += histInfo.collapsedImageBytes;
info.droppedChars = (info.droppedChars ?? 0) + histInfo.droppedChars;
for (const [cp, n] of histInfo.droppedCodepoints) {
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
}
info.historyReason = 'collapsed';
} else if (histInfo.reason) {
info.historyReason = histInfo.reason;
}
}
info.compressed = true;
// Serialize the top dropped codepoints (if any) as `U+HHHH` → count. Cap at
// 20 entries — that's enough to identify a misbehaving Unicode block
+13 -7
View File
@@ -160,14 +160,20 @@ export async function aggregateSessions(
if (
ev.compressed &&
typeof ev.orig_chars === 'number' &&
typeof ev.image_bytes === 'number'
typeof ev.image_count === 'number'
) {
const saved = ev.orig_chars - ev.image_bytes;
if (saved > 0) {
s.charsSaved += saved;
// 3.75 chars/token is Anthropic's published rule-of-thumb.
s.tokensSavedEst += Math.round(saved / 3.75);
}
// Honest savings math, mirrors src/dashboard.ts:
// textTokens = orig_chars / 4 (Anthropic ~4 chars/token rule)
// imageTokens = image_count × 2500 (empirical per-image cost)
// tokensSaved = textTokens − imageTokens (CAN be negative when
// small per-block compressions cost more than they save)
// Previous formula compared chars to PNG bytes (mismatched units) AND
// clamped via `if (saved > 0)` which hid the cost-bleed entirely.
const textTokens = ev.orig_chars / 4;
const imageTokens = ev.image_count * 2500;
const tokensSaved = textTokens - imageTokens;
s.tokensSavedEst += Math.round(tokensSaved);
s.charsSaved += Math.round(tokensSaved * 4);
}
if (typeof ev.cache_read_tokens === 'number') {
s.cacheReadTokens += ev.cache_read_tokens;
+467
View File
@@ -0,0 +1,467 @@
/**
* Tests for Variant C history-image compression (task #52).
*
* Coverage:
* - `findClosedPrefixBoundary`: parallel tool calls in one assistant turn,
* interleaved tool_use_id close ordering, mid-flight (open) tool_use
* straddling the cutoff, and the boring "all closed" case.
* - `blocksToText`: drops `thinking` blocks, serialises `tool_use` /
* `tool_result` with content, emits `[image]` placeholder for nested
* images.
* - `messagesToHistoryText`: `--- role ---` framing, skips empty turns.
* - `collapseHistory`: full pipeline — opts handling, break-even gate,
* synthetic user message shape (text+image+text), live-tail preservation.
* - `transformRequest` end-to-end: compressHistory off (default), on with
* enough turns to fire, off when no closed prefix exists.
*/
import { describe, expect, it } from 'vitest';
import {
findClosedPrefixBoundary,
blocksToText,
messagesToHistoryText,
collapseHistory,
HISTORY_DEFAULTS,
} from '../src/core/history.js';
import { transformRequest, isCompressionProfitable } from '../src/core/transform.js';
import type { Message } from '../src/core/types.js';
// A tiny helper so test fixtures are readable.
function asst(content: Message['content']): Message {
return { role: 'assistant', content };
}
function usr(content: Message['content']): Message {
return { role: 'user', content };
}
describe('findClosedPrefixBoundary', () => {
it('returns -1 for empty messages', () => {
expect(findClosedPrefixBoundary([], 0)).toBe(-1);
expect(findClosedPrefixBoundary([], 10)).toBe(-1);
});
it('returns -1 when cutoff is 0 (no prefix to scan)', () => {
const msgs: Message[] = [usr('hi'), asst('hello')];
expect(findClosedPrefixBoundary(msgs, 0)).toBe(-1);
});
it('returns last index for an all-plain (no tool calls) conversation', () => {
const msgs: Message[] = [usr('hi'), asst('hello'), usr('thanks')];
// Cutoff exclusive=3 → scan [0..2]. All plain → all closed. Last index = 2.
expect(findClosedPrefixBoundary(msgs, 3)).toBe(2);
});
it('returns the last-closed boundary, not the cutoff itself, when there are mid-flight tool_uses', () => {
const msgs: Message[] = [
usr('do thing'),
asst([{ type: 'tool_use', id: 'A', name: 't', input: {} }]),
usr([{ type: 'tool_result', tool_use_id: 'A', content: 'ok' }]),
// turn 3 opens 'B' but never closes it within our cutoff.
asst([{ type: 'tool_use', id: 'B', name: 't', input: {} }]),
usr('plain text'), // no tool_result for B → B is open
];
// Cutoff exclusive=5 → scan all 5 messages. After msg 2, openSet={}.
// After msg 3, openSet={B}. After msg 4 (plain user, no tool_result),
// openSet still {B}. Last closed index = 2.
expect(findClosedPrefixBoundary(msgs, 5)).toBe(2);
});
it('handles parallel tool calls in one assistant turn', () => {
const msgs: Message[] = [
usr('parallel work'),
asst([
{ type: 'tool_use', id: 'A', name: 't', input: {} },
{ type: 'tool_use', id: 'B', name: 't', input: {} },
]),
usr([
{ type: 'tool_result', tool_use_id: 'B', content: 'b' },
{ type: 'tool_result', tool_use_id: 'A', content: 'a' },
]),
asst('done'),
];
// After msg 1: openSet={A,B}. After msg 2: openSet={} (both closed in
// reverse order — order doesn't matter for openSet). After msg 3: still
// closed. Last index = 3.
expect(findClosedPrefixBoundary(msgs, 4)).toBe(3);
});
it('returns -1 when the very first message opens a tool_use that never closes', () => {
const msgs: Message[] = [
asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }]),
usr('plain text, no tool_result'),
];
// openSet={X} after msg 0 and msg 1 → never closes → no closed index.
expect(findClosedPrefixBoundary(msgs, 2)).toBe(-1);
});
it('respects cutoffExclusive — never scans into the live tail', () => {
const msgs: Message[] = [
usr('q1'),
asst('a1'),
usr('q2'),
asst('a2'),
usr('q3'),
];
// Cutoff exclusive=2 → only scan [0,1]. Last closed = 1.
expect(findClosedPrefixBoundary(msgs, 2)).toBe(1);
// Cutoff=1 → only scan [0]. Last closed = 0.
expect(findClosedPrefixBoundary(msgs, 1)).toBe(0);
});
});
describe('blocksToText', () => {
it('returns string content verbatim', () => {
expect(blocksToText('hello world')).toBe('hello world');
});
it('joins text blocks with double newline', () => {
expect(
blocksToText([
{ type: 'text', text: 'first paragraph' },
{ type: 'text', text: 'second paragraph' },
]),
).toBe('first paragraph\n\nsecond paragraph');
});
it('serialises tool_use with its name and JSON-pretty args', () => {
const out = blocksToText([
{
type: 'tool_use',
id: 'tx1',
name: 'Read',
input: { file_path: '/etc/hosts', limit: 50 },
},
]);
expect(out).toContain('[tool_use Read]');
expect(out).toContain('"file_path": "/etc/hosts"');
expect(out).toContain('"limit": 50');
});
it('serialises tool_result string content', () => {
expect(
blocksToText([
{ type: 'tool_result', tool_use_id: 'tx1', content: 'output line one' },
]),
).toContain('[tool_result]\noutput line one');
});
it('marks tool_result is_error with " (error)" suffix', () => {
expect(
blocksToText([
{
type: 'tool_result',
tool_use_id: 'tx1',
content: 'oops',
is_error: true,
},
]),
).toContain('[tool_result (error)]');
});
it('flattens array-shaped tool_result content (text + image)', () => {
const out = blocksToText([
{
type: 'tool_result',
tool_use_id: 'tx1',
content: [
{ type: 'text', text: 'caption' },
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
},
],
},
]);
expect(out).toContain('caption');
expect(out).toContain('[image]');
});
it('drops thinking blocks (Opus 4.7 only requires bit-perfect on most-recent turn)', () => {
const out = blocksToText([
// @ts-expect-error — `thinking` is a real Anthropic block but not in our
// pared-down types union. The blocksToText switch must silently drop it.
{ type: 'thinking', thinking: 'secret reasoning trace', signature: 'sig123' },
{ type: 'text', text: 'visible answer' },
]);
expect(out).toBe('visible answer');
expect(out).not.toContain('secret reasoning');
expect(out).not.toContain('sig123');
});
it('emits [image] placeholder for top-level image blocks', () => {
expect(
blocksToText([
{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
},
]),
).toBe('[image]');
});
});
describe('messagesToHistoryText', () => {
it('frames each turn with --- role --- and joins with blank line', () => {
const msgs: Message[] = [usr('hi'), asst('hello')];
const out = messagesToHistoryText(msgs, 2);
expect(out).toContain('--- user ---\nhi');
expect(out).toContain('--- assistant ---\nhello');
});
it('respects upToExclusive (does not include the live tail)', () => {
const msgs: Message[] = [usr('q1'), asst('a1'), usr('q2')];
const out = messagesToHistoryText(msgs, 2);
expect(out).toContain('q1');
expect(out).toContain('a1');
expect(out).not.toContain('q2');
});
it('skips empty turns', () => {
const msgs: Message[] = [
usr(''),
asst('answer'),
usr(' \n '), // whitespace-only
];
const out = messagesToHistoryText(msgs, 3);
expect(out).toContain('--- assistant ---\nanswer');
// Only one section header should appear.
expect(out.match(/^---/gm)?.length).toBe(1);
});
});
describe('collapseHistory', () => {
// The break-even predicate that ships with transform.ts. We pass it in so
// history.ts stays cycle-free.
const profitable = isCompressionProfitable;
it('bails with reason=no_history on empty input', async () => {
const { messages, info } = await collapseHistory([], profitable);
expect(messages).toEqual([]);
expect(info.reason).toBe('no_history');
expect(info.collapsedTurns).toBe(0);
});
it('bails with reason=prefix_too_short when the closed prefix < minCollapsePrefix', async () => {
const msgs: Message[] = [usr('q1'), asst('a1')];
const { messages, info } = await collapseHistory(msgs, profitable, {
keepTail: 0,
minCollapsePrefix: 5,
});
expect(messages).toBe(msgs); // unchanged reference
expect(info.reason).toBe('prefix_too_short');
});
it('bails with reason=not_profitable when collapsed text is small', async () => {
// 12 tiny turns, all plain prose. Each turn ~30 chars → ~400 chars total
// serialised. Well under the 10,000-char break-even.
const msgs: Message[] = [];
for (let i = 0; i < 12; i++) {
msgs.push(i % 2 === 0 ? usr(`q${i}`) : asst(`a${i}`));
}
const { info } = await collapseHistory(msgs, profitable, {
keepTail: 0,
minCollapsePrefix: 5,
});
expect(info.reason).toBe('not_profitable');
});
it('collapses a long all-plain conversation into one prepended user message', async () => {
// 12 turns, each ~1500 chars → ~18k chars total. Past break-even at cols=100.
const msgs: Message[] = [];
for (let i = 0; i < 12; i++) {
const body = `turn ${i}: ` + 'x'.repeat(2500);
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
}
const { messages: out, info } = await collapseHistory(msgs, profitable, {
keepTail: 2,
minCollapsePrefix: 5,
cols: 100,
});
expect(info.reason).toBe(undefined); // collapsed → no reason set
expect(info.collapsedTurns).toBe(10); // 12 - keepTail(2)
expect(info.collapsedImages).toBeGreaterThanOrEqual(1);
expect(out.length).toBe(1 + 2); // 1 synthetic + 2 tail turns
// Synthetic user message is at index 0
expect(out[0]!.role).toBe('user');
expect(Array.isArray(out[0]!.content)).toBe(true);
const content = out[0]!.content as Array<Record<string, unknown>>;
expect(content[0]).toMatchObject({ type: 'text', text: '[Earlier in this conversation:]' });
expect(content[content.length - 1]).toMatchObject({
type: 'text',
text: '[End of earlier context.]',
});
// Middle blocks are image
const imgBlocks = content.filter((c) => c.type === 'image');
expect(imgBlocks.length).toBe(info.collapsedImages);
// Last 2 turns are the live tail, byte-identical to the original
expect(out[1]).toBe(msgs[10]);
expect(out[2]).toBe(msgs[11]);
});
it('preserves a tool_use sequence that straddles the live-tail boundary', async () => {
// 14 turns: 10 closed turns, then an open tool_use at index 10 that closes at index 12.
const msgs: Message[] = [];
for (let i = 0; i < 10; i++) {
const body = `turn ${i}: ` + 'x'.repeat(2500);
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
}
msgs.push(asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }]));
msgs.push(usr([{ type: 'tool_result', tool_use_id: 'X', content: 'r' }]));
msgs.push(asst('after the tool')); // index 12, live tail
// keepTail=1 means cutoff=12. boundary search over [0..11]. msg 10 opens
// X, msg 11 closes it → openSet={} after msg 11. But wait, msg 11 is
// INSIDE the cutoff. Last-closed = 11. collapseLen = 12.
// That means msg 10+11 (the X open/close pair) WOULD be collapsed.
// That's fine — they're closed within the prefix.
const { messages: out, info } = await collapseHistory(msgs, profitable, {
keepTail: 1,
minCollapsePrefix: 5,
cols: 100,
});
expect(info.reason).toBe(undefined);
expect(info.collapsedTurns).toBe(12);
// 1 synthetic prepended + 1 tail msg
expect(out.length).toBe(2);
expect(out[1]).toBe(msgs[12]); // tail is the post-tool assistant turn
});
it('refuses to collapse when an open tool_use would straddle the live-tail boundary', async () => {
// 14 turns: 10 closed turns, then an open tool_use at index 10 that
// closes at index 13 — but keepTail=2 puts the close INSIDE the tail.
const msgs: Message[] = [];
for (let i = 0; i < 10; i++) {
const body = `turn ${i}: ` + 'x'.repeat(2500);
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
}
msgs.push(asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }])); // 10
msgs.push(usr('thinking out loud')); // 11
msgs.push(asst('more thinking')); // 12
msgs.push(usr([{ type: 'tool_result', tool_use_id: 'X', content: 'r' }])); // 13
// keepTail=3 → cutoff=11 → scan [0..10]. After msg 10 openSet={X}.
// boundary = last index where openSet was empty = 9.
// collapseLen = 10, ≥ minCollapsePrefix(5) → collapses turns 0..9.
// openSet={X} from msg 10 onward; that open turn stays in the live tail.
const { messages: out, info } = await collapseHistory(msgs, profitable, {
keepTail: 3,
minCollapsePrefix: 5,
cols: 100,
});
expect(info.reason).toBe(undefined);
expect(info.collapsedTurns).toBe(10);
// Live tail: msgs 10..13 = 4 messages. Plus 1 prepended.
expect(out.length).toBe(5);
// X open at out[1] (was msgs[10]), X close at out[4] (was msgs[13]) —
// both in the live tail so the tool_use_id linkage survives.
expect(out[1]).toBe(msgs[10]);
expect(out[4]).toBe(msgs[13]);
});
});
describe('transformRequest + compressHistory', () => {
function bigPlain(n: number): string {
return 'x'.repeat(n);
}
function mkBody(messages: Message[], systemText: string) {
return new TextEncoder().encode(
JSON.stringify({
model: 'claude-3-5-sonnet',
system: systemText,
messages,
}),
);
}
it('compressHistory:false (default) leaves messages array untouched', async () => {
const msgs: Message[] = [];
for (let i = 0; i < 12; i++) {
msgs.push(i % 2 === 0 ? usr(bigPlain(3000)) : asst(bigPlain(3000)));
}
const before = JSON.stringify(msgs);
const { body, info } = await transformRequest(mkBody(msgs, bigPlain(60_000)));
expect(info.collapsedTurns).toBeUndefined();
expect(info.historyReason).toBeUndefined();
// The returned body's messages count equals the original
const reparsed = JSON.parse(new TextDecoder().decode(body));
expect(reparsed.messages.length).toBe(msgs.length);
expect(JSON.stringify(msgs)).toBe(before); // input not mutated
});
it('compressHistory:true collapses an 8-closed + 2-live conversation', async () => {
// 12 turns total. With keepTail=2 + minPrefix=5, we expect 10 turns to
// collapse into 1 synthetic prepended user + 2 live tail = 3 total.
const msgs: Message[] = [];
for (let i = 0; i < 12; i++) {
const body = `turn ${i}: ` + bigPlain(2500);
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
}
const { body, info } = await transformRequest(mkBody(msgs, bigPlain(60_000)), {
compressHistory: true,
historyKeepTail: 2,
historyMinPrefix: 5,
});
expect(info.collapsedTurns).toBe(10);
expect(info.collapsedChars).toBeGreaterThan(0);
expect(info.collapsedImages).toBeGreaterThanOrEqual(1);
expect(info.historyReason).toBe('collapsed');
expect(info.imageCount).toBeGreaterThanOrEqual(1 + (info.collapsedImages ?? 0));
const reparsed = JSON.parse(new TextDecoder().decode(body));
expect(reparsed.messages.length).toBe(3); // 1 synthetic + 2 live tail
expect(reparsed.messages[0].role).toBe('user');
const content = reparsed.messages[0].content;
expect(Array.isArray(content)).toBe(true);
expect(content[0]).toMatchObject({ type: 'text', text: '[Earlier in this conversation:]' });
expect(content[content.length - 1]).toMatchObject({
type: 'text',
text: '[End of earlier context.]',
});
});
it('compressHistory:true sets historyReason when no closed prefix exists', async () => {
// First message opens a tool_use; nothing closes it.
const msgs: Message[] = [
asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }]),
usr('plain'),
asst('plain'),
usr('plain'),
];
const { info } = await transformRequest(mkBody(msgs, bigPlain(60_000)), {
compressHistory: true,
historyKeepTail: 1,
historyMinPrefix: 2,
});
expect(info.collapsedTurns).toBeUndefined();
expect(info.historyReason).toBe('no_closed_prefix');
});
it('history-image blocks carry NO cache_control (conservative first-cut)', async () => {
const msgs: Message[] = [];
for (let i = 0; i < 12; i++) {
const body = `turn ${i}: ` + bigPlain(2500);
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
}
const { body, info } = await transformRequest(mkBody(msgs, bigPlain(60_000)), {
compressHistory: true,
historyKeepTail: 2,
historyMinPrefix: 5,
});
expect(info.collapsedImages).toBeGreaterThanOrEqual(1);
const reparsed = JSON.parse(new TextDecoder().decode(body));
const synth = reparsed.messages[0];
const imgs = synth.content.filter((b: { type: string }) => b.type === 'image');
for (const img of imgs) {
expect(img.cache_control).toBeUndefined();
}
});
});
describe('HISTORY_DEFAULTS', () => {
it('defaults are conservative (keepTail=4, minCollapsePrefix=10, cols=100)', () => {
expect(HISTORY_DEFAULTS.keepTail).toBe(4);
expect(HISTORY_DEFAULTS.minCollapsePrefix).toBe(10);
expect(HISTORY_DEFAULTS.cols).toBe(100);
});
});
+35 -12
View File
@@ -135,32 +135,55 @@ describe('aggregateSessions', () => {
expect(sessions.get('aaaaaaaa')?.requestCount).toBe(2);
});
it('accumulates charsSaved only when compressed and saved>0', async () => {
it('accumulates tokens saved using honest formula (allows negative)', async () => {
writeEvents(tmp, [
// 100,000 chars compressed to 2 images.
// textTokens = 100000/4 = 25000
// imageTokens = 2 × 2500 = 5000
// tokensSaved = +20000 → win
ev({
first_user_sha8: 'aaaaaaaa',
compressed: true,
orig_chars: 1000,
image_bytes: 200,
}), // +800
orig_chars: 100_000,
image_count: 2,
}),
// 5,000 chars compressed to 1 image — NET LOSS.
// textTokens = 5000/4 = 1250
// imageTokens = 1 × 2500 = 2500
// tokensSaved = -1250 → loss; must NOT be clamped
ev({
first_user_sha8: 'aaaaaaaa',
compressed: true,
orig_chars: 100,
image_bytes: 500,
}), // negative -> skipped
orig_chars: 5_000,
image_count: 1,
}),
// Not compressed at all — skipped.
ev({
first_user_sha8: 'aaaaaaaa',
compressed: false,
orig_chars: 1000,
image_bytes: 0,
}), // not compressed -> skipped
image_count: 0,
}),
]);
const { sessions } = await aggregateSessions(tmp);
const s = sessions.get('aaaaaaaa')!;
expect(s.charsSaved).toBe(800);
// 800 / 3.75 ≈ 213 — round-trip through Math.round.
expect(s.tokensSavedEst).toBe(Math.round(800 / 3.75));
// 20000 + (-1250) = 18750 tokens net.
expect(s.tokensSavedEst).toBe(18_750);
expect(s.charsSaved).toBe(18_750 * 4);
});
it('reports negative tokensSavedEst when all compressions net-lose', async () => {
writeEvents(tmp, [
// 3 small blocks each net-lose ~1250 tokens.
ev({ first_user_sha8: 'bbbbbbbb', compressed: true, orig_chars: 5000, image_count: 1 }),
ev({ first_user_sha8: 'bbbbbbbb', compressed: true, orig_chars: 5000, image_count: 1 }),
ev({ first_user_sha8: 'bbbbbbbb', compressed: true, orig_chars: 5000, image_count: 1 }),
]);
const { sessions } = await aggregateSessions(tmp);
const s = sessions.get('bbbbbbbb')!;
// 3 × (1250 - 2500) = -3750 tokens.
expect(s.tokensSavedEst).toBe(-3750);
expect(s.charsSaved).toBe(-15_000);
});
});