import { describe, it, expect, beforeEach } from 'vitest'; import { selectPruneTargets, PROTECTED_TOKENS, PRUNE_TRIGGER_TOKENS, type PartForPrune, } from '../inference/prune.js'; // Test fixture: build a tool_result part whose payload size yields a known // token estimate (chars/4). The decision logic only cares about // JSON.stringify(payload).length, so a string payload of `4n` chars // produces exactly `n` tokens. let seq = 0; function part(tokens: number, createdAt: Date): PartForPrune { seq += 1; // JSON.stringify("xxx...") wraps in quotes (adds 2 chars), so subtract 2 // before multiplying. Math.ceil((len+2)/4) needs len ≈ 4*tokens - 2 so the // total stringified length is 4*tokens. Approximate by padding 4 chars per // token; the off-by-one from quotes is small and tests check totals, not // exact per-part counts. const text = 'x'.repeat(tokens * 4 - 2); return { id: `p${seq}`, payload: text, created_at: createdAt }; } const T_NOW = new Date('2026-05-22T12:00:00Z'); function ago(secondsBack: number): Date { return new Date(T_NOW.getTime() - secondsBack * 1000); } describe('selectPruneTargets', () => { beforeEach(() => { seq = 0; }); it('returns nothing when there are no parts', () => { expect(selectPruneTargets([], null)).toEqual({ ids: [], freedTokens: 0 }); }); it('returns nothing when total tokens are under the protection window', () => { const parts: PartForPrune[] = [ part(10_000, ago(10)), part(10_000, ago(20)), ]; // 20k total, all protected expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 }); }); it('returns nothing when candidate total is below the prune trigger', () => { // Protection fills with ~40k newest, candidates only ~5k. Below 20k trigger. const parts: PartForPrune[] = [ part(20_000, ago(10)), part(20_000, ago(20)), // Past protection; total ~5k won't trigger. part(5_000, ago(30)), ]; const result = selectPruneTargets(parts, null); expect(result.ids).toEqual([]); expect(result.freedTokens).toBe(0); }); it('hides candidates past protection when their total clears the trigger', () => { // Newest 40k protected; older 30k cleanly above the 20k trigger. const parts: PartForPrune[] = [ part(20_000, ago(10)), part(20_000, ago(20)), // Past protection, total ~30k freed. part(15_000, ago(30)), part(15_000, ago(40)), ]; const result = selectPruneTargets(parts, null); expect(result.ids).toEqual(['p3', 'p4']); expect(result.freedTokens).toBeGreaterThanOrEqual(PRUNE_TRIGGER_TOKENS); }); it('stops at the compaction summary boundary', () => { // Newest 30k protected (just under PROTECTED_TOKENS=40k); then 30k of // older parts. Boundary sits at ago(35), so the ago(40) part is // beyond it and gets skipped. const parts: PartForPrune[] = [ part(15_000, ago(10)), part(15_000, ago(20)), part(15_000, ago(30)), // crosses protection threshold; candidate part(15_000, ago(40)), // beyond summary boundary; skipped ]; const tailStart = ago(35); const result = selectPruneTargets(parts, tailStart); // ago(30) is the only candidate inside the window; 15k is below the // 20k trigger so we expect no hides. expect(result.ids).toEqual([]); }); it('does not prune when only protected parts exist (no candidates)', () => { // Exactly PROTECTED_TOKENS of newest parts; no older candidates. const parts: PartForPrune[] = [part(PROTECTED_TOKENS, ago(10))]; expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 }); }); });