Files
pxpipe/tests/anthropic-cache-align.test.ts
teamchong f024730261 fix(transform): drop scope:"global" from relocated cache_control markers
Claude Code marks its system prompt with cache_control
{type:"ephemeral", ttl:"1h", scope:"global"}. When pxpipe relocates
that marker onto a rendered image, the new position is never a valid
global prefix (slab pages 1..N-1 and other injected blocks carry no
marker), so Anthropic rejects the whole request with 400
invalid_request_error — making pxpipe unusable in front of Claude Code.

Demote relocated markers to plain ephemeral by stripping the scope key
(type/ttl preserved) at all four relocation sites: the slab->history
anchor move, the static-system marker, and the reminder / tool_result
image renders. Markers without scope pass through untouched, so
byte-stability of existing traffic is unaffected. pxpipe still never
adds markers of its own.

Closes #95
2026-07-16 13:00:46 -04:00

211 lines
8.9 KiB
TypeScript

/**
* Anthropic cache-control CONTRACT (TDD).
*
* Encodes the invariants we agreed on for the Anthropic /v1/messages path.
* Some of these pin behaviour that already holds; some are EXPECTED-FAIL today
* and define the work (append-only byte-stability + mark alignment).
*
* Run just this file: pnpm vitest run tests/anthropic-cache-align.test.ts
*/
import { describe, expect, it } from 'vitest';
import { collapseHistory } from '../src/core/history.js';
import { transformRequest } from '../src/core/transform.js';
import { countCacheControlMarkers } from '../src/core/measurement.js';
import type { Message } from '../src/core/types.js';
const yes = () => true;
const big = (n: number) => 'x'.repeat(n);
function asst(content: Message['content']): Message {
return { role: 'assistant', content };
}
function usr(content: Message['content']): Message {
return { role: 'user', content };
}
function enc(obj: unknown): Uint8Array {
return new TextEncoder().encode(JSON.stringify(obj));
}
function dec(b: Uint8Array): any {
return JSON.parse(new TextDecoder().decode(b));
}
function imagesOf(msgs: Message[]): any[] {
// the synthetic history message holds the image blocks
const out: any[] = [];
for (const m of msgs) {
if (Array.isArray(m.content)) {
for (const b of m.content as any[]) if (b?.type === 'image') out.push(b);
}
}
return out;
}
/** N closed plain turns, each `chars` long. */
function convo(n: number, chars = 3500): Message[] {
const out: Message[] = [];
for (let i = 0; i < n; i++) {
const body = `turn ${i}: ` + big(chars);
out.push(i % 2 === 0 ? usr(body) : asst(body));
}
return out;
}
describe('Anthropic cache contract — invariants that should already hold', () => {
it('never adds a cache_control marker (out count <= in count)', async () => {
const msgs = convo(15);
const body = enc({
model: 'claude-3-5-sonnet',
system: [{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral' } }],
messages: msgs,
});
const inMarks = countCacheControlMarkers(body);
const { body: out } = await transformRequest(body);
const outMarks = countCacheControlMarkers(out);
expect(outMarks).toBeLessThanOrEqual(inMarks);
});
it('relocates the single slab marker onto an IMAGE block (not lost, not duplicated)', async () => {
const msgs = convo(15);
const body = enc({
model: 'claude-3-5-sonnet',
system: [{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral' } }],
messages: msgs,
});
const { body: out } = await transformRequest(body);
expect(countCacheControlMarkers(out)).toBe(1); // exactly one, conserved
});
it('keeps the last 4 turns as live text (keepTail)', async () => {
const msgs = convo(15);
const { messages } = await collapseHistory(msgs, yes, { protectedPrefix: 1 });
// tail = last 4 original turns, preserved verbatim at the end
const tail = messages.slice(-4);
expect(tail.map((m) => m.content)).toEqual(msgs.slice(-4).map((m) => m.content));
});
it('never splits a tool_use from its tool_result across the image boundary', async () => {
// Put an OPEN tool_use right at the would-be boundary; closed prefix must
// pull back so the pair is never separated.
const msgs: Message[] = [];
for (let i = 0; i < 11; i++) msgs.push(i % 2 ? asst(big(3500)) : usr(big(3500)));
msgs.push(asst([{ type: 'tool_use', id: 'OPEN', name: 't', input: {} } as any]));
msgs.push(usr('still flowing, no tool_result yet'));
const { info } = await collapseHistory(msgs, yes, { protectedPrefix: 1, keepTail: 0, collapseChunk: 0 });
// boundary stops before the open tool_use → it stays in the live tail
expect(info.collapsedTurns).toBeLessThanOrEqual(11);
});
it('is deterministic — same input twice = identical image bytes', async () => {
const a = await collapseHistory(convo(20), yes, { protectedPrefix: 1 });
const b = await collapseHistory(convo(20), yes, { protectedPrefix: 1 });
expect(JSON.stringify(imagesOf(a.messages))).toBe(JSON.stringify(imagesOf(b.messages)));
});
});
describe('Anthropic cache contract — our agreed model (EXPECTED FAIL today)', () => {
it('APPEND-ONLY: earlier history image stays byte-identical as the conversation grows past a chunk boundary', async () => {
// Conversation P, then P + more turns that advance the collapse boundary.
// The frozen earlier chunk's FIRST image must be byte-identical in both —
// otherwise old cached prefix re-writes every time the boundary moves.
const p = await collapseHistory(convo(20), yes, { protectedPrefix: 1 });
const pPlus = await collapseHistory(convo(70), yes, { protectedPrefix: 1 });
const firstP = imagesOf(p.messages)[0];
const firstPlus = imagesOf(pPlus.messages)[0];
expect(firstP).toBeDefined();
expect(firstPlus).toBeDefined();
expect(JSON.stringify(firstPlus)).toBe(JSON.stringify(firstP));
});
it('ALIGN: a caller cache_control marker placed mid-history is preserved (image boundary aligns to the mark)', async () => {
const msgs = convo(15);
// caller marks the END of an early segment (index 6) — a roaming breakpoint
(msgs[6] as any).content = [
{ type: 'text', text: (msgs[6].content as string), cache_control: { type: 'ephemeral' } },
];
const body = enc({
model: 'claude-3-5-sonnet',
system: [{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral' } }],
messages: msgs,
});
const inMarks = countCacheControlMarkers(body); // 2: slab + mid-history
const { body: out } = await transformRequest(body);
// Contract: the mid-history mark is not silently dropped — both segments
// remain independently cacheable, so the count is conserved (== 2), and the
// image set has a boundary at that mark.
expect(countCacheControlMarkers(out)).toBe(inMarks);
});
});
describe('Anthropic cache contract — gate never produces negative savings', () => {
it('tiny prefix is not profitable (no image emitted)', async () => {
const msgs = convo(15, 30); // 30 chars/turn → trivially below the gate
const { info } = await collapseHistory(msgs, (t) => t.length > 8000, { protectedPrefix: 1 });
expect(info.reason).toBe('not_profitable');
expect(info.collapsedImages ?? 0).toBe(0);
});
it('large prefix is profitable (image emitted)', async () => {
const msgs = convo(15, 3500);
const { info } = await collapseHistory(msgs, (t) => t.length > 8000, { protectedPrefix: 1 });
// collapseHistory leaves `reason` undefined on success (the 'collapsed'
// sentinel is applied later in transform.ts). Success = images emitted.
expect(info.reason).toBeUndefined();
expect(info.collapsedImages ?? 0).toBeGreaterThanOrEqual(1);
});
});
describe('Anthropic cache contract — #95 scope:"global" must not survive relocation', () => {
function emittedMarkers(req: any): any[] {
const out: any[] = [];
const visit = (blocks: unknown) => {
if (!Array.isArray(blocks)) return;
for (const b of blocks as any[]) {
if (b && typeof b === 'object' && b.cache_control != null) out.push(b.cache_control);
if (b && typeof b === 'object' && Array.isArray(b.content)) visit(b.content); // tool_result inner blocks
}
};
visit(req.system);
for (const m of req.messages ?? []) visit(m.content);
return out;
}
it('multi-page slab: relocated marker drops scope, keeps type/ttl, never adds markers', async () => {
const body = enc({
model: 'claude-3-5-sonnet',
system: [
{
type: 'text',
text: big(300_000),
cache_control: { type: 'ephemeral', ttl: '1h', scope: 'global' },
},
],
messages: convo(15),
});
const inMarks = countCacheControlMarkers(body);
const { body: out } = await transformRequest(body);
expect(countCacheControlMarkers(out)).toBeLessThanOrEqual(inMarks);
const req = dec(out);
const marks = emittedMarkers(req);
// Pages 1..N-1 of a slab run are unmarked, so any surviving scope:"global"
// violates Anthropic's every-preceding-block-globally-scoped rule → 400.
for (const cc of marks) expect(cc.scope).toBeUndefined();
// The marker itself is conserved (relocated, not dropped) with type/ttl intact.
expect(marks.length).toBeGreaterThanOrEqual(1);
expect(marks).toContainEqual({ type: 'ephemeral', ttl: '1h' });
});
it('marker without scope passes through relocation unchanged (identity)', async () => {
const body = enc({
model: 'claude-3-5-sonnet',
system: [
{ type: 'text', text: big(80_000), cache_control: { type: 'ephemeral', ttl: '1h' } },
],
messages: convo(15),
});
const { body: out } = await transformRequest(body);
const marks = emittedMarkers(dec(out));
expect(marks).toContainEqual({ type: 'ephemeral', ttl: '1h' });
for (const cc of marks) expect(cc.scope).toBeUndefined();
});
});