From 97fdee8421047400cc890133b148ea83323f53bf Mon Sep 17 00:00:00 2001 From: teamchong <25894545+teamchong@users.noreply.github.com> Date: Mon, 18 May 2026 22:46:13 -0400 Subject: [PATCH] history: Variant C closed-tool-sequence collapse + honest per-session savings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/core/history.ts | 325 ++++++++++++++++++++++++++++ src/core/transform.ts | 80 +++++++ src/sessions.ts | 20 +- tests/history.test.ts | 467 +++++++++++++++++++++++++++++++++++++++++ tests/sessions.test.ts | 47 +++-- 5 files changed, 920 insertions(+), 19 deletions(-) create mode 100644 src/core/history.ts create mode 100644 tests/history.test.ts diff --git a/src/core/history.ts b/src/core/history.ts new file mode 100644 index 0000000..8cc290d --- /dev/null +++ b/src/core/history.ts @@ -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; +} + +/** + * 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(); + 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 = {}, +): 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 }; +} diff --git a/src/core/transform.ts b/src/core/transform.ts index 50d72c3..2198aed 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -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 = { @@ -82,6 +99,13 @@ const DEFAULTS: Required = { // `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 diff --git a/src/sessions.ts b/src/sessions.ts index 30071c3..7773696 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -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; diff --git a/tests/history.test.ts b/tests/history.test.ts new file mode 100644 index 0000000..7b53cb6 --- /dev/null +++ b/tests/history.test.ts @@ -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>; + 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); + }); +}); diff --git a/tests/sessions.test.ts b/tests/sessions.test.ts index 6dec259..a50771e 100644 --- a/tests/sessions.test.ts +++ b/tests/sessions.test.ts @@ -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); }); });