import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { fingerprintBefore, fingerprintAfter, checkout, checkin, poolStats, poolClear, invalidateFor } from '../src/conversation-pool.js'; describe('fingerprintBefore', () => { it('returns null for single-message conversations', () => { assert.equal(fingerprintBefore([{ role: 'user', content: 'hi' }]), null); }); it('produces stable hash for same messages', () => { const msgs = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi there' }, { role: 'user', content: 'how are you' }, ]; assert.equal(fingerprintBefore(msgs), fingerprintBefore(msgs)); }); it('changes when prior user messages change', () => { const msgs1 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'next' }, ]; const msgs2 = [ { role: 'user', content: 'different' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'next' }, ]; assert.notEqual(fingerprintBefore(msgs1), fingerprintBefore(msgs2)); }); it('changes when prior assistant text changes (v2.0.25 semantic key)', () => { const msgs1 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'response A' }, { role: 'user', content: 'next' }, ]; const msgs2 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'completely different response' }, { role: 'user', content: 'next' }, ]; // v2.0.25: assistant divergence must miss — silently resuming a cascade // when our local view of the assistant's prior reply differs from the // upstream's would yield stale state. Pre-v2.0.25 these collided and // produced wrong-context replies on the next turn. assert.notEqual(fingerprintBefore(msgs1), fingerprintBefore(msgs2)); }); it('changes when prior assistant tool_calls change (v2.0.25 semantic key)', () => { const msgs1 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: '', tool_calls: [{ function: { name: 'get_weather', arguments: '{"city":"SF"}' } }] }, { role: 'tool', tool_call_id: 't1', content: '60F' }, { role: 'user', content: 'next' }, ]; const msgs2 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: '', tool_calls: [{ function: { name: 'get_weather', arguments: '{"city":"NYC"}' } }] }, { role: 'tool', tool_call_id: 't1', content: '60F' }, { role: 'user', content: 'next' }, ]; assert.notEqual(fingerprintBefore(msgs1), fingerprintBefore(msgs2)); }); it('canonicalizes assistant whitespace so reformatting still hits', () => { const msgs1 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'response\n\n A' }, { role: 'user', content: 'next' }, ]; const msgs2 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'response A' }, { role: 'user', content: 'next' }, ]; assert.equal(fingerprintBefore(msgs1), fingerprintBefore(msgs2)); }); it('strips system-reminder meta tags before hashing', () => { const msgs1 = [ { role: 'user', content: 'hello some state' }, { role: 'user', content: 'next' }, ]; const msgs2 = [ { role: 'user', content: 'hello different state' }, { role: 'user', content: 'next' }, ]; assert.equal(fingerprintBefore(msgs1), fingerprintBefore(msgs2)); }); it('includes model key in fingerprint', () => { const msgs = [ { role: 'user', content: 'hello' }, { role: 'user', content: 'next' }, ]; assert.notEqual( fingerprintBefore(msgs, 'gpt-4o'), fingerprintBefore(msgs, 'claude-4.5-sonnet') ); }); it('does not learn user XML tags into the global fingerprint stripper', () => { poolClear(); const caller = 'api:shared'; const model = 'claude-opus-4.7'; // Warmup: attacker fp stored under fp(strip(attacker)). const stored = fingerprintAfter( [{ role: 'user', content: 'attacker' }], model, caller ); // After the warmup, victim's continuation must still produce a different fp. const victim = fingerprintBefore([ { role: 'user', content: 'victim' }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'continue' }, ], model, caller); assert.notEqual(stored, victim); }); it('changes when system prompt changes (v2.0.25 default-on)', () => { const base = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'next' }, ]; const fpA = fingerprintBefore([{ role: 'system', content: 'be helpful' }, ...base]); const fpB = fingerprintBefore([{ role: 'system', content: 'be terse' }, ...base]); assert.notEqual(fpA, fpB); }); it('changes when image_url changes between turns (v2.0.25 stable media digest)', () => { const buildMsgs = (url) => [ { role: 'user', content: [{ type: 'text', text: 'describe' }, { type: 'image_url', image_url: { url } }] }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'next' }, ]; assert.notEqual( fingerprintBefore(buildMsgs('https://example.com/a.png')), fingerprintBefore(buildMsgs('https://example.com/b.png')) ); }); it('disables reuse (returns null) for image content with no stable id', () => { const msgs = [ { role: 'user', content: [{ type: 'image_url', image_url: { /* no url, no source */ } }] }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'next' }, ]; assert.equal(fingerprintBefore(msgs), null); }); it('changes when tool schema changes for emulated requests (v2.0.25 MED-1)', () => { const msgs = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'next' }, ]; const tools1 = [{ function: { name: 'get_weather', description: 'get weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } }]; const tools2 = [{ function: { name: 'get_weather', description: 'get weather', parameters: { type: 'object', properties: { country: { type: 'string' } } } } }]; assert.notEqual( fingerprintBefore(msgs, 'm', 'c', { emulateTools: true, tools: tools1 }), fingerprintBefore(msgs, 'm', 'c', { emulateTools: true, tools: tools2 }) ); }); it('object key order in mixed content is stable', () => { const msgs1 = [ { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://x/a.png', detail: 'auto' } }] }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'next' }, ]; const msgs2 = [ { role: 'user', content: [{ type: 'image_url', image_url: { detail: 'auto', url: 'https://x/a.png' } }] }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'next' }, ]; assert.equal(fingerprintBefore(msgs1), fingerprintBefore(msgs2)); }); }); describe('fingerprintAfter', () => { it('produces a hash for single-message conversations', () => { const fp = fingerprintAfter([{ role: 'user', content: 'hi' }]); assert.ok(typeof fp === 'string' && fp.length === 64); }); it('differs from fingerprintBefore on same messages', () => { const msgs = [ { role: 'user', content: 'hello' }, { role: 'user', content: 'next' }, ]; assert.notEqual(fingerprintBefore(msgs), fingerprintAfter(msgs)); }); it('after(turn1) matches before(turn1+assistant+turn2) — round-trip across turns', () => { // Simulate what chat.js does: // turn 1 finishes with messages=[u1, assistantWeProduced] // client comes back with [u1, a1, u2] for turn 2 // The fpAfter of turn 1 should equal fpBefore of turn 2 so the next // request finds the cascade we just stored. const turn1 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi there' }, ]; const turn2 = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi there' }, { role: 'user', content: 'next' }, ]; assert.equal( fingerprintAfter(turn1, 'm', 'c'), fingerprintBefore(turn2, 'm', 'c') ); }); }); describe('checkout / checkin', () => { it('isolates identical prompt fingerprints by caller', () => { poolClear(); const msgs = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'next' }, ]; const fpA = fingerprintBefore(msgs, 'claude-opus-4.6', 'caller-a'); const fpB = fingerprintBefore(msgs, 'claude-opus-4.6', 'caller-b'); assert.notEqual(fpA, fpB); checkin(fpA, { cascadeId: 'c-a', sessionId: 's-a', lsPort: 42100, apiKey: 'key-a' }, 'caller-a'); assert.equal(checkout(fpB, 'caller-b'), null); assert.equal(checkout(fpA, 'caller-a')?.cascadeId, 'c-a'); }); it('reuses for the same caller and prompt trajectory', () => { poolClear(); const msgs = [ { role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }, { role: 'user', content: 'next' }, ]; const fp = fingerprintBefore(msgs, 'claude-opus-4.6', 'caller-a'); checkin(fp, { cascadeId: 'c-same', sessionId: 's-same', lsPort: 42100, apiKey: 'key-a' }, 'caller-a'); assert.equal(checkout(fp, 'caller-a')?.cascadeId, 'c-same'); }); it('returns null on miss', () => { assert.equal(checkout('nonexistent-fp'), null); }); it('round-trips an entry', () => { const entry = { cascadeId: 'c1', sessionId: 's1', lsPort: 42100, apiKey: 'key1' }; checkin('fp-test-1', entry); const got = checkout('fp-test-1'); assert.ok(got); assert.equal(got.cascadeId, 'c1'); assert.equal(got.lsPort, 42100); }); it('removes entry on checkout (mutual exclusion)', () => { const entry = { cascadeId: 'c2', sessionId: 's2', lsPort: 42100, apiKey: 'key2' }; checkin('fp-test-2', entry); checkout('fp-test-2'); assert.equal(checkout('fp-test-2'), null); }); it('rejects checkout with mismatched expected owner (v2.0.25 MED-3)', () => { poolClear(); const fp = 'fp-owner-test'; checkin(fp, { cascadeId: 'c-own', sessionId: 's', lsPort: 42100, apiKey: 'key-A', lsGeneration: 'gen1' }); assert.equal(checkout(fp, '', { apiKey: 'key-B' }), null, 'apiKey mismatch should miss'); // Re-store since checkout removed it on the first attempt. checkin(fp, { cascadeId: 'c-own', sessionId: 's', lsPort: 42100, apiKey: 'key-A', lsGeneration: 'gen1' }); assert.equal(checkout(fp, '', { apiKey: 'key-A', lsPort: 42999 }), null, 'lsPort mismatch should miss'); checkin(fp, { cascadeId: 'c-own', sessionId: 's', lsPort: 42100, apiKey: 'key-A', lsGeneration: 'gen1' }); assert.equal(checkout(fp, '', { apiKey: 'key-A', lsPort: 42100, lsGeneration: 'gen2' }), null, 'lsGeneration mismatch should miss'); checkin(fp, { cascadeId: 'c-own', sessionId: 's', lsPort: 42100, apiKey: 'key-A', lsGeneration: 'gen1' }); const ok = checkout(fp, '', { apiKey: 'key-A', lsPort: 42100, lsGeneration: 'gen1' }); assert.equal(ok?.cascadeId, 'c-own', 'matching owner should hit'); }); }); describe('invalidateFor', () => { it('drops entries by apiKey', () => { poolClear(); checkin('fp-A', { cascadeId: 'cA', sessionId: 's', lsPort: 1, apiKey: 'key-A' }); checkin('fp-B', { cascadeId: 'cB', sessionId: 's', lsPort: 1, apiKey: 'key-B' }); assert.equal(invalidateFor({ apiKey: 'key-A' }), 1); assert.ok(checkout('fp-B')); }); it('drops entries by lsPort but spares same-port entries with newer lsGeneration', () => { poolClear(); checkin('fp-old', { cascadeId: 'cold', sessionId: 's', lsPort: 100, apiKey: 'k', lsGeneration: 'g1' }); checkin('fp-new', { cascadeId: 'cnew', sessionId: 's', lsPort: 100, apiKey: 'k', lsGeneration: 'g2' }); // Restart from g1 → only g1's entry is dropped, g2's survives because the // generation tag tells us the new LS is independent of the old one. assert.equal(invalidateFor({ lsPort: 100, lsGeneration: 'g1' }), 1); assert.ok(checkout('fp-new')); }); }); describe('poolStats', () => { it('returns stats object with expected keys', () => { const s = poolStats(); assert.ok('size' in s); assert.ok('hits' in s); assert.ok('misses' in s); assert.ok('hitRate' in s); }); }); describe('TTL hint inheritance (v2.0.25 MED-2)', () => { it('clears inherited 1h hint when checkin explicitly passes 0', () => { poolClear(); const fp1 = 'fp-ttl-1'; checkin(fp1, { cascadeId: 'c', sessionId: 's', lsPort: 1, apiKey: 'k', ttlHintMs: 60 * 60 * 1000 }); const got = checkout(fp1); assert.equal(got?.ttlHintMs, 60 * 60 * 1000); // Restore with explicit 0 → hint should be cleared, entry uses default TTL. checkin(fp1, got, '', 0); const got2 = checkout(fp1); assert.equal(got2?.ttlHintMs, undefined, 'explicit 0 should drop the inherited 1h hint'); }); });