mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
97fdee8421
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.
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|