llm-switcher 1.4.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ // Actual proxy -> HTTP mock upstream; no external provider or official account is used.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import http from 'node:http';
8
+ import { spawn } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
12
+ const listen = server => new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
13
+ async function unusedPort() {
14
+ const server = http.createServer(); await listen(server); const port = server.address().port;
15
+ await new Promise(resolve => server.close(resolve)); return port;
16
+ }
17
+ async function until(predicate, detail = 'condition', timeout = 5000) {
18
+ const deadline = Date.now() + timeout;
19
+ while (Date.now() < deadline) { if (await predicate()) return; await new Promise(resolve => setTimeout(resolve, 20)); }
20
+ throw new Error(`timeout waiting for ${detail}`);
21
+ }
22
+ function history() {
23
+ const messages = [{ role: 'user', content: 'task' }];
24
+ for (let i = 0; i < 12; i++) messages.push(
25
+ { role: 'assistant', content: [{ type: 'text', text: `DECISION_${i}` },
26
+ { type: 'tool_use', id: `t${i}`, name: 'Read', input: { file_path: '/test' } }] },
27
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: `t${i}`, content: `RESULT_${i} ` + 'X'.repeat(4000) }] });
28
+ return messages;
29
+ }
30
+
31
+ async function fixture(t, { model = '', codex = false } = {}) {
32
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-idle-e2e-'));
33
+ const received = []; const pendingSummaries = [];
34
+ const upstream = http.createServer((req, res) => {
35
+ let raw = '';
36
+ req.on('data', chunk => raw += chunk);
37
+ req.on('end', () => {
38
+ const body = raw ? JSON.parse(raw) : {};
39
+ received.push({ url: req.url, body });
40
+ res.setHeader('content-type', 'application/json');
41
+ if (req.method === 'GET') return res.end(JSON.stringify({ data: [],
42
+ bifrost_ua: req.url.includes('codex') ? 'codex_exec/' : '' }));
43
+ if (body.model === 'cheap-summary') {
44
+ pendingSummaries.push({ res, body }); return;
45
+ }
46
+ if (req.url.endsWith('/responses')) return res.end(JSON.stringify({ id: 'resp_test', object: 'response',
47
+ status: 'completed', model: body.model, output: [{ type: 'message', role: 'assistant',
48
+ content: [{ type: 'output_text', text: 'ok' }] }] }));
49
+ res.end(JSON.stringify({ id: 'msg_test', type: 'message', role: 'assistant', model: body.model,
50
+ content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn', usage: { input_tokens: 2, output_tokens: 1 } }));
51
+ });
52
+ });
53
+ await listen(upstream); const port = await unusedPort();
54
+ const cfg = { port, activeProfiles: {}, profiles: {
55
+ p1: { name: 'Offline', mode: 'hybrid', baseURL: `http://127.0.0.1:${upstream.address().port}/v1`, apiKey: 'fixture-only' },
56
+ }, idleCompact: { enabled: true, codex, model, minBytes: 8192, keepRecent: 6 } };
57
+ const config = path.join(dir, 'config.json'); fs.writeFileSync(config, JSON.stringify(cfg));
58
+ const nativeDir = path.join(dir, 'native'); fs.mkdirSync(nativeDir);
59
+ const nativeFiles = ['projects/test-project/session-one.jsonl', 'sessions/2026/10/08/rollout-A.jsonl',
60
+ 'sessions/2026/10/08/rollout-B.jsonl', 'thread_history_1.sqlite'];
61
+ for (const file of nativeFiles) {
62
+ fs.mkdirSync(path.dirname(path.join(nativeDir, file)), { recursive: true });
63
+ fs.writeFileSync(path.join(nativeDir, file), JSON.stringify({ type: 'user', uuid: 'leaf-one',
64
+ sessionId: 'session-one', message: { role: 'user', content: `UNCHANGED_${file}` } }) + '\n');
65
+ }
66
+ const nativeBefore = nativeFiles.map(file => fs.readFileSync(path.join(nativeDir, file)));
67
+ const nativeMtimes = nativeFiles.map(file => fs.statSync(path.join(nativeDir, file)).mtimeMs);
68
+ let child, logs = '';
69
+ const start = async () => {
70
+ child = spawn(process.execPath, [path.join(root, 'proxy.mjs'), '--port', String(port)], {
71
+ env: { ...process.env, LLM_SWITCHER_CONFIG: config, LLM_SWITCHER_STATE_DIR: dir,
72
+ LLM_SWITCHER_PORT: '', LLM_SWITCHER_REGISTRY_URL: 'http://127.0.0.1:9/',
73
+ CODEX_HOME: nativeDir, CLAUDE_CONFIG_DIR: nativeDir }, stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ child.stdout.on('data', chunk => logs += chunk); child.stderr.on('data', chunk => logs += chunk);
76
+ await until(async () => (await fetch(`http://127.0.0.1:${port}/health`).catch(() => null))?.ok, `proxy start: ${logs}`);
77
+ };
78
+ const stop = async () => {
79
+ if (!child || child.exitCode !== null) return;
80
+ const exited = new Promise(resolve => child.once('exit', resolve)); child.kill('SIGTERM');
81
+ await Promise.race([exited, new Promise(resolve => setTimeout(resolve, 2000))]);
82
+ if (child.exitCode === null) { child.kill('SIGKILL'); await exited; }
83
+ };
84
+ t.after(async () => { await stop(); upstream.closeAllConnections?.();
85
+ await new Promise(resolve => upstream.close(resolve)); fs.rmSync(dir, { recursive: true, force: true }); });
86
+ await start();
87
+ const send = async (body, { session = 'session-one', responses = false } = {}) => {
88
+ const response = await fetch(`http://127.0.0.1:${port}/v1/${responses ? 'responses' : 'messages'}`, {
89
+ method: 'POST', headers: { 'content-type': 'application/json', 'x-profile': 'p1',
90
+ ...(responses ? { 'thread-id': session, 'user-agent': 'codex_exec/0.160' } : { 'x-claude-code-session-id': session }) },
91
+ body: JSON.stringify(body) });
92
+ assert.equal(response.status, 200, await response.text());
93
+ return received.filter(entry => !entry.url.includes('/models') && entry.body.model !== 'cheap-summary').at(-1).body;
94
+ };
95
+ const age = () => {
96
+ const files = fs.readdirSync(path.join(dir, 'idle-compact')).filter(file => file.endsWith('.json'));
97
+ for (const file of files) { const location = path.join(dir, 'idle-compact', file);
98
+ const record = JSON.parse(fs.readFileSync(location)); record.seenAt = Date.now() - 16 * 60000;
99
+ fs.writeFileSync(location, JSON.stringify(record)); }
100
+ };
101
+ const finish = index => pendingSummaries[index].res.end(JSON.stringify({ choices: [{ message: { content: `SUMMARY_${index}` } }] }));
102
+ return { dir, config, cfg, received, pendingSummaries, send, age, finish,
103
+ restart: async () => { await stop(); await start(); },
104
+ assertNativeUntouched: () => nativeFiles.forEach((file, index) =>
105
+ { assert.deepEqual(fs.readFileSync(path.join(nativeDir, file)), nativeBefore[index]); assert.equal(fs.statSync(path.join(nativeDir, file)).mtimeMs, nativeMtimes[index]); }),
106
+ logs: () => logs, port };
107
+ }
108
+
109
+ test('proxy retains a stable compact prefix through appended turns and restart without model calls or client writes', { timeout: 20000 }, async t => {
110
+ const f = await fixture(t); const body = { model: 'claude-probe', stream: false, messages: history() };
111
+ const initial = await f.send(body); assert.match(JSON.stringify(initial), /RESULT_1 /);
112
+ f.age(); const shortened = await f.send(body);
113
+ assert.doesNotMatch(JSON.stringify(shortened), /RESULT_1 /); assert.match(JSON.stringify(shortened), /DECISION_1"/);
114
+ assert.ok(JSON.stringify(shortened.messages).length < JSON.stringify(body.messages).length / 2);
115
+ body.messages.push({ role: 'assistant', content: 'NEW_ANSWER' }, { role: 'user', content: 'NEW_REQUEST' });
116
+ const next = await f.send(body); assert.match(JSON.stringify(next), /NEW_ANSWER.*NEW_REQUEST/);
117
+ assert.deepEqual(next.messages.slice(0, shortened.messages.length), shortened.messages);
118
+ await f.restart(); assert.deepEqual((await f.send(body)).messages, next.messages);
119
+ const cachedFile = fs.readdirSync(path.join(f.dir, 'idle-compact')).find(file => file.endsWith('.json'));
120
+ fs.writeFileSync(path.join(f.dir, 'idle-compact', cachedFile), '{bad');
121
+ assert.match(JSON.stringify(await f.send(body)), /RESULT_1 /, 'corrupt cache forwards original history');
122
+ assert.equal(f.pendingSummaries.length, 0); f.assertNativeUntouched();
123
+ });
124
+
125
+ test('delayed summary preserves new suffix, maps its own protocol, and loses CAS to a newer idle generation', { timeout: 20000 }, async t => {
126
+ const f = await fixture(t, { model: 'cheap-summary' });
127
+ const body = { model: 'claude-probe', stream: false, messages: history() };
128
+ await f.send(body); f.age(); await f.send(body);
129
+ await until(() => f.pendingSummaries.length === 1, 'first summary');
130
+ assert.ok(f.received.some(entry => entry.url === '/v1/chat/completions' && entry.body.model === 'cheap-summary'));
131
+ assert.doesNotMatch(JSON.stringify(f.pendingSummaries[0].body), /RESULT_11/);
132
+ body.messages.push({ role: 'assistant', content: 'AFTER_SNAPSHOT_ANSWER' }, { role: 'user', content: 'AFTER_SNAPSHOT_USER' });
133
+ await f.send(body);
134
+ f.finish(0); await until(() => f.logs().includes('summary cached'), 'summary storage');
135
+ const resumed = await f.send(body); assert.match(JSON.stringify(resumed), /SUMMARY_0.*AFTER_SNAPSHOT_ANSWER.*AFTER_SNAPSHOT_USER/);
136
+ // Force a larger prefix during a subsequent idle window while an older generation is pending.
137
+ for (let i = 0; i < 8; i++) body.messages.push({ role: 'assistant', content: `NEW_WINDOW_${i} ` + 'Y'.repeat(1000) }, { role: 'user', content: `question ${i}` });
138
+ f.age(); await f.send(body); await until(() => f.pendingSummaries.length === 2, 'second summary');
139
+ for (let i = 0; i < 8; i++) body.messages.push({ role: 'assistant', content: `THIRD_WINDOW_${i}` }, { role: 'user', content: `third question ${i}` });
140
+ f.age(); await f.send(body); await until(() => f.pendingSummaries.length === 3, 'third summary');
141
+ f.finish(2); await until(() => f.logs().split('summary cached').length >= 3, 'newest summary cached');
142
+ f.finish(1); await until(() => f.logs().includes('discarded after a newer generation'), 'stale summary rejection');
143
+ const latest = JSON.stringify(await f.send(body)); assert.match(latest, /SUMMARY_2/); assert.doesNotMatch(latest, /SUMMARY_1/);
144
+ f.assertNativeUntouched();
145
+ });
146
+
147
+ test('Responses opt-in compacts actual forwarded input, keeps assistant output_text and both native threads untouched', { timeout: 20000 }, async t => {
148
+ const f = await fixture(t, { codex: true, model: 'cheap-summary' });
149
+ const input = [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'task' }] }];
150
+ for (let i = 0; i < 12; i++) input.push(
151
+ { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: `DECISION_${i}` }] },
152
+ { type: 'function_call', call_id: `c${i}`, name: 'read_file', arguments: '{}' },
153
+ { type: 'function_call_output', call_id: `c${i}`, output: `OUTPUT_${i} ` + 'X'.repeat(4000) });
154
+ const body = { model: 'codex/probe', input, stream: false, store: false };
155
+ await f.send(body, { responses: true }); f.age();
156
+ const shortened = await f.send(body, { responses: true });
157
+ assert.doesNotMatch(JSON.stringify(shortened.input), /OUTPUT_1 /); assert.match(JSON.stringify(shortened.input), /DECISION_1/);
158
+ assert.equal(shortened.input.find(item => item.role === 'assistant').content[0].type, 'output_text');
159
+ await until(() => f.pendingSummaries.length === 1, 'Codex configured summary');
160
+ f.finish(0); await until(() => f.logs().includes('summary cached'), 'Codex summary storage');
161
+ body.input.push({ role: 'user', content: 'NEW_CODEX_USER' });
162
+ await f.restart(); assert.match(JSON.stringify(await f.send(body, { responses: true })), /NEW_CODEX_USER/);
163
+ f.assertNativeUntouched();
164
+ });
165
+
166
+ test('native compaction and configuration changes each invalidate an in-flight summary', { timeout: 20000 }, async t => {
167
+ const f = await fixture(t, { model: 'cheap-summary' });
168
+ const body = { model: 'claude-probe', messages: history(), stream: false };
169
+ await f.send(body); f.age(); await f.send(body);
170
+ await until(() => f.pendingSummaries.length === 1, 'summary before native compact');
171
+ const native = { ...body, messages: [{ role: 'user', content: 'NATIVE_CONTEXT' },
172
+ { role: 'assistant', content: 'NATIVE_ANSWER' }, { role: 'user', content: 'NEW_TURN' }] };
173
+ assert.match(JSON.stringify(await f.send(native)), /NATIVE_CONTEXT.*NEW_TURN/);
174
+ f.finish(0); await until(() => f.logs().includes('discarded after a newer generation'), 'native compact discards late summary');
175
+ assert.doesNotMatch(JSON.stringify(await f.send(native)), /SUMMARY_0/);
176
+ // The larger raw history replaces the native prefix and starts its own idle window.
177
+ await f.send(body); f.age(); await f.send(body);
178
+ await until(() => f.pendingSummaries.length === 2, 'summary before config change');
179
+ const cfg = JSON.parse(fs.readFileSync(f.config)); cfg.idleCompact.model = null;
180
+ fs.writeFileSync(f.config, JSON.stringify(cfg));
181
+ f.finish(1); await until(() => f.logs().includes('discarded after configuration change'), 'configuration guard');
182
+ assert.doesNotMatch(JSON.stringify(await f.send(body)), /SUMMARY_1/);
183
+ f.assertNativeUntouched();
184
+ });
185
+
186
+ test('API rejects invalid patch without writing config, accepts all settings, and anonymous calls remain unmodified', { timeout: 20000 }, async t => {
187
+ const f = await fixture(t); const token = fs.readFileSync(path.join(f.dir, 'admin.token'), 'utf8').trim();
188
+ const update = patch => fetch(`http://127.0.0.1:${f.port}/api/idle-compact`, { method: 'POST',
189
+ headers: { 'content-type': 'application/json', 'x-llm-switcher-token': token }, body: JSON.stringify(patch) });
190
+ const before = fs.readFileSync(f.config);
191
+ for (const patch of [{ unknown: 1 }, { userChars: 0 }, { codex: 'yes' }, { keepRecent: 0.5 }]) {
192
+ const response = await update(patch); assert.equal(response.status, 400); await response.text();
193
+ assert.deepEqual(fs.readFileSync(f.config), before);
194
+ }
195
+ const valid = await update({ codex: true, model: null, userChars: 128, summaryMaxChars: 256, sessionLookbackHours: 24 });
196
+ assert.equal(valid.status, 200); const settings = await valid.json(); assert.equal(settings.idleCompact.codex, true);
197
+ const body = { model: 'claude-probe', messages: history(), stream: false };
198
+ const response = await fetch(`http://127.0.0.1:${f.port}/v1/messages`, { method: 'POST', headers: {
199
+ 'content-type': 'application/json', 'x-profile': 'p1' }, body: JSON.stringify(body) });
200
+ assert.equal(response.status, 200); await response.text();
201
+ assert.match(JSON.stringify(f.received.at(-1).body), /RESULT_1 /);
202
+ assert.equal(fs.existsSync(path.join(f.dir, 'idle-compact')), false);
203
+ });
@@ -0,0 +1,282 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { parseToIR } from '../formats.mjs';
7
+ import { idleCompactPolicy, validateIdleCompactPatch, conversationKey, compactIR,
8
+ compactRawHistory, compactResponsesInput, summaryMessages, summaryReplacement, clampSummary } from '../idlecompact.mjs';
9
+ import { IdlePrefixCache, compactCacheKey } from '../idlecache.mjs';
10
+ import { askSummary } from '../idlecall.mjs';
11
+
12
+ const policy = (over = {}) => idleCompactPolicy({ idleCompact: { enabled: true, ...over } });
13
+ const fixture = () => {
14
+ const messages = [{ role: 'user', content: 'Fix the failing test' }];
15
+ for (let i = 0; i < 12; i++) messages.push(
16
+ { role: 'assistant', content: [{ type: 'text', text: `DECISION_${i}: preserve the contract` },
17
+ { type: 'tool_use', id: `call_${i}`, name: 'Read', input: { file_path: `file_${i}` } }] },
18
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: `call_${i}`, content: `RESULT_${i}:` + 'X'.repeat(4000) }] });
19
+ return { model: 'claude-probe', system: 'Be concise', messages, stream: false };
20
+ };
21
+ const req = (session = 'one', token = 'owner') => ({ headers: { 'x-claude-code-session-id': session, authorization: `Bearer ${token}` } });
22
+ const profile = { baseURL: 'http://127.0.0.1:1234', apiKey: 'PRIVATE_PROVIDER_KEY', outFormat: 'anthropic' };
23
+
24
+ function cacheFixture(t, options = {}) {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-prefix-'));
26
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
27
+ let now = 100000000;
28
+ const cache = new IdlePrefixCache(dir, { now: () => now, ...options });
29
+ return { dir, cache, advance(ms) { now += ms; } };
30
+ }
31
+
32
+ function keyFor(body = fixture(), request = req(), prof = profile, p = policy(), name = 'profile') {
33
+ return compactCacheKey('anthropic', request, body, parseToIR('anthropic', body), prof, name, p);
34
+ }
35
+
36
+ test('config accepts all supported fields and rejects unknown, wrong type, zero, and fractional counts', () => {
37
+ assert.equal(idleCompactPolicy({}).enabled, false);
38
+ assert.equal(idleCompactPolicy({}).codex, false);
39
+ assert.equal(validateIdleCompactPatch({ enabled: true, codex: true, userChars: 128,
40
+ summaryMaxChars: 256, sessionLookbackHours: 168 }), '');
41
+ assert.equal(validateIdleCompactPatch({ model: null }), '');
42
+ assert.equal(idleCompactPolicy({ idleCompact: { model: null } }).model, null);
43
+ for (const patch of [{ unknown: 1 }, { codex: 'yes' }, { userChars: 0 },
44
+ { keepRecent: 2.5 }, { minBytes: 8191 }, { idleMinutes: Infinity }, { model: {} }]) {
45
+ assert.notEqual(validateIdleCompactPatch(patch), '', JSON.stringify(patch));
46
+ }
47
+ const p = idleCompactPolicy({ idleCompact: { idleMinutes: '4', minBytes: -5,
48
+ keepRecent: 10000, userChars: 1, unknown: 9 } });
49
+ assert.equal(p.idleMinutes, 15);
50
+ assert.equal(p.minBytes, 65536);
51
+ assert.equal(p.keepRecent, 100);
52
+ assert.equal(p.userChars, 128);
53
+ assert.equal('unknown' in p, false);
54
+ });
55
+
56
+ test('only explicit session identifiers establish cache identity', () => {
57
+ const ir = parseToIR('anthropic', fixture());
58
+ assert.equal(conversationKey('anthropic', { headers: {} }, ir), '');
59
+ assert.equal(conversationKey('anthropic', req(), ir), 'h:one');
60
+ assert.equal(conversationKey('responses', { headers: { 'thread-id': 'thread-A' } }, {}), 'h:thread-A');
61
+ assert.equal(conversationKey('responses', { headers: { 'x-codex-turn-metadata': '{"session_id":"thread-B"}' } }, {}), 'h:thread-B');
62
+ assert.equal(conversationKey('responses', { headers: {} }, { promptCacheKey: 'thread-C' }), 'p:thread-C');
63
+ });
64
+
65
+ test('scope separates sessions, callers, profiles, models, system prompts, tools, and policies', () => {
66
+ const body = fixture();
67
+ const base = keyFor(body);
68
+ const others = [keyFor(body, req('two')), keyFor(body, req('one', 'other-owner')),
69
+ keyFor(body, req(), { ...profile, baseURL: 'http://other' }), keyFor(body, req(), profile, policy(), 'other-profile'),
70
+ keyFor({ ...body, model: 'other-model' }), keyFor({ ...body, system: 'other-system' }),
71
+ keyFor({ ...body, tools: [{ name: 'Write' }] }), keyFor(body, req(), profile, policy({ model: 'summary' }))];
72
+ assert.ok(others.every(key => key !== base));
73
+ assert.match(base, /^[a-f0-9]{64}$/);
74
+ assert.equal(keyFor(body, { headers: {} }), '');
75
+ });
76
+
77
+ test('actual parsed assistant tool call keeps its accompanying decision text', () => {
78
+ const body = fixture();
79
+ const ir = parseToIR('anthropic', body);
80
+ assert.ok(ir.messages[1].toolCalls);
81
+ const compacted = compactIR(ir, policy());
82
+ assert.match(JSON.stringify(compacted.messages), /DECISION_1:/);
83
+ assert.doesNotMatch(JSON.stringify(compacted.messages), /RESULT_1:/);
84
+ assert.match(JSON.stringify(summaryMessages(ir, policy())), /DECISION_1:/);
85
+ assert.doesNotMatch(JSON.stringify(summaryMessages(ir, policy())), /X{4000}/);
86
+ });
87
+
88
+ test('raw compact prefix keeps exact opening and suffix without dangling tool results', () => {
89
+ const body = fixture();
90
+ for (const keepRecent of [1, 2, 3, 6, 9]) {
91
+ const p = policy({ keepRecent });
92
+ const compacted = compactRawHistory(body.messages, 'anthropic', p);
93
+ assert.ok(compacted);
94
+ assert.deepEqual(compacted.history[0], body.messages[0]);
95
+ assert.deepEqual(compacted.history.slice(compacted.replacement.length), body.messages.slice(compacted.prefixCount));
96
+ const calls = new Set();
97
+ for (const message of compacted.history) for (const block of Array.isArray(message.content) ? message.content : []) {
98
+ if (block.type === 'tool_use') calls.add(block.id);
99
+ if (block.type === 'tool_result') assert.ok(calls.has(block.tool_use_id));
100
+ }
101
+ }
102
+ });
103
+
104
+ test('a result deeper in the tail keeps its older call, and malformed orphan input is forwarded', () => {
105
+ const body = fixture();
106
+ body.messages[20] = { role: 'user', content: 'result still pending' };
107
+ body.messages.push({ role: 'user', content: 'An intervening question' },
108
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_9', content: 'delayed result' }] });
109
+ const compacted = compactRawHistory(body.messages, 'anthropic', policy({ keepRecent: 2 }));
110
+ assert.ok(compacted.prefixCount <= 19);
111
+ const orphan = structuredClone(body.messages);
112
+ orphan.at(-1).content[0].tool_use_id = 'missing';
113
+ assert.equal(compactRawHistory(orphan, 'anthropic', policy({ keepRecent: 2 })), null);
114
+ });
115
+
116
+ test('multiple calls pair correctly, mixed result text survives, and missing or duplicate IDs are rejected', () => {
117
+ const body = fixture();
118
+ body.messages[2].content.push({ type: 'text', text: 'USER_DECISION_KEEP' });
119
+ const good = compactRawHistory(body.messages, 'anthropic', policy());
120
+ assert.match(JSON.stringify(good.replacement), /USER_DECISION_KEEP/);
121
+ for (const change of [messages => delete messages[1].content[1].id,
122
+ messages => messages[3].content[1].id = 'call_0',
123
+ messages => messages[4].content[0].tool_use_id = 'call_0']) {
124
+ const messages = structuredClone(body.messages); change(messages);
125
+ assert.equal(compactRawHistory(messages, 'anthropic', policy()), null);
126
+ }
127
+ body.messages.at(-2).content.push({ type: 'tool_use', id: 'concurrent', name: 'Bash', input: {} });
128
+ body.messages.at(-1).content.push({ type: 'tool_result', tool_use_id: 'concurrent', content: 'parallel result' });
129
+ assert.ok(compactRawHistory(body.messages, 'anthropic', policy({ keepRecent: 1 })));
130
+ });
131
+
132
+ test('unknown Anthropic server tools and tool references prevent shortening', () => {
133
+ for (const type of ['server_tool_use', 'web_search_tool_result', 'bash_code_execution_tool_result', 'tool_reference']) {
134
+ const body = fixture(); body.messages.at(-1).content.push({ type, id: 'server-id', content: 'result' });
135
+ assert.equal(compactRawHistory(body.messages, 'anthropic', policy()), null, type);
136
+ }
137
+ });
138
+
139
+ test('Responses assistant text uses output_text and deeper custom tool results retain calls', () => {
140
+ const input = [{ role: 'developer', content: 'policy' }, { role: 'user', content: 'task' }];
141
+ for (let i = 0; i < 10; i++) input.push(
142
+ { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: `decision ${i}` }] },
143
+ { type: 'custom_tool_call', call_id: `c${i}`, name: 'shell', input: 'ls' },
144
+ { type: 'custom_tool_call_output', call_id: `c${i}`, output: 'X'.repeat(4000) });
145
+ input[25] = { type: 'message', role: 'user', content: 'output pending' };
146
+ input.push({ role: 'user', content: 'new turn' }, { type: 'custom_tool_call_output', call_id: 'c7', output: 'delayed' });
147
+ const compacted = compactRawHistory(input, 'responses', policy({ keepRecent: 2 }));
148
+ assert.ok(compacted);
149
+ const assistant = compacted.replacement.find(item => item.role === 'assistant');
150
+ assert.equal(assistant.content[0].type, 'output_text');
151
+ const calls = new Set();
152
+ for (const item of compactResponsesInput(input, policy({ keepRecent: 2 }))) {
153
+ if (item.type === 'custom_tool_call') calls.add(item.call_id);
154
+ if (item.type === 'custom_tool_call_output') assert.ok(calls.has(item.call_id));
155
+ }
156
+ });
157
+
158
+ test('stable cached prefix preserves every new suffix turn and survives a gateway restart', t => {
159
+ const { cache, dir, advance } = cacheFixture(t);
160
+ const body = fixture(); const p = policy(); const key = keyFor(body);
161
+ assert.equal(cache.visit(key, body.messages, p).history, null);
162
+ advance(16 * 60000);
163
+ assert.equal(cache.visit(key, body.messages, p).idleMs, 16 * 60000);
164
+ const compacted = compactRawHistory(body.messages, 'anthropic', p);
165
+ assert.ok(cache.create(key, body.messages, compacted.prefixCount, compacted.replacement, p));
166
+ const suffix = [{ role: 'assistant', content: 'NEW_ANSWER' }, { role: 'user', content: 'NEW_REQUEST' }];
167
+ const resumed = new IdlePrefixCache(dir, { now: cache.now });
168
+ const result = resumed.visit(key, [...body.messages, ...suffix], p);
169
+ assert.deepEqual(result.history, [...compacted.replacement, ...body.messages.slice(compacted.prefixCount), ...suffix]);
170
+ assert.deepEqual(resumed.visit(key, [...body.messages, ...suffix], p).history, result.history);
171
+ const contents = fs.readFileSync(path.join(dir, 'idle-compact', `${key}.json`), 'utf8');
172
+ assert.doesNotMatch(contents, /PRIVATE_PROVIDER_KEY|Bearer owner|RESULT_1:/);
173
+ if (process.platform !== 'win32') {
174
+ assert.equal(fs.statSync(path.join(dir, 'idle-compact')).mode & 0o777, 0o700);
175
+ assert.equal(fs.statSync(path.join(dir, 'idle-compact', `${key}.json`)).mode & 0o777, 0o600);
176
+ }
177
+ });
178
+
179
+ test('native client compact or edited history invalidates cached prefix and rejects late summary', t => {
180
+ const { cache } = cacheFixture(t); const body = fixture(); const p = policy(); const key = keyFor(body);
181
+ const compacted = compactRawHistory(body.messages, 'anthropic', p);
182
+ const original = cache.create(key, body.messages, compacted.prefixCount, compacted.replacement, p);
183
+ assert.equal(cache.visit(key, [{ role: 'user', content: 'NATIVE SUMMARY' }], p).history, null);
184
+ assert.equal(cache.upgrade(key, original.generation, summaryReplacement(compacted, 'OLD'), p), false);
185
+ const second = cache.create(key, body.messages, compacted.prefixCount, compacted.replacement, p);
186
+ assert.equal(cache.upgrade(key, original.generation, summaryReplacement(compacted, 'OLD'), p), false);
187
+ assert.equal(cache.upgrade(key, second.generation, summaryReplacement(compacted, 'CURRENT'), p), true);
188
+ const suffix = [{ role: 'user', content: 'AFTER SUMMARY REQUEST' }];
189
+ assert.deepEqual(cache.visit(key, [...body.messages, ...suffix], p).history.at(-1), suffix[0]);
190
+ });
191
+
192
+ test('corrupt, oversized, expired, and unavailable cache state safely forwards original history', t => {
193
+ const { cache, dir, advance } = cacheFixture(t); const p = policy(); const key = keyFor(); const body = fixture();
194
+ cache.visit(key, body.messages, p);
195
+ const file = path.join(dir, 'idle-compact', `${key}.json`);
196
+ fs.writeFileSync(file, '{bad');
197
+ assert.equal(cache.visit(key, body.messages, p).history, null);
198
+ fs.writeFileSync(file, 'X'.repeat(2 * 1024 * 1024 + 1));
199
+ assert.equal(cache.visit(key, body.messages, p).history, null);
200
+ advance(73 * 3600000);
201
+ assert.equal(cache.visit(key, body.messages, p).idleMs, 0);
202
+ const impossible = path.join(dir, 'file'); fs.writeFileSync(impossible, 'x');
203
+ assert.equal(new IdlePrefixCache(impossible).visit(key, body.messages, p).history, null);
204
+ });
205
+
206
+ test('parseable corruption and a symlinked cache directory cannot replace history or delete external files', t => {
207
+ const { cache, dir } = cacheFixture(t); const p = policy(); const body = fixture(); const key = keyFor(body);
208
+ const compacted = compactRawHistory(body.messages, 'anthropic', p);
209
+ const record = cache.create(key, body.messages, compacted.prefixCount, compacted.replacement, p);
210
+ const file = path.join(dir, 'idle-compact', `${key}.json`);
211
+ fs.writeFileSync(file, JSON.stringify({ ...record, replacement: [{ role: 'user', content: 'CORRUPT' }] }));
212
+ assert.equal(cache.visit(key, body.messages, p).history, null);
213
+ if (process.platform === 'win32') return;
214
+ fs.rmSync(path.join(dir, 'idle-compact'), { recursive: true });
215
+ const outside = path.join(dir, 'outside'); fs.mkdirSync(outside);
216
+ const external = path.join(outside, `${key}.json`); fs.writeFileSync(external, JSON.stringify(record));
217
+ fs.symlinkSync(outside, path.join(dir, 'idle-compact'), 'dir');
218
+ const before = fs.readFileSync(external);
219
+ assert.equal(cache.visit(key, body.messages, p).history, null);
220
+ assert.deepEqual(fs.readFileSync(external), before);
221
+ });
222
+
223
+ test('cache expires by configured lookback and never touches Claude transcripts or either Codex rollout/SQLite file', async t => {
224
+ const { cache, dir, advance } = cacheFixture(t);
225
+ const clientDir = path.join(dir, 'clients'); fs.mkdirSync(clientDir);
226
+ const files = ['claude.jsonl', 'thread-A.jsonl', 'thread-B.jsonl', 'thread_history_1.sqlite'];
227
+ for (const file of files) fs.writeFileSync(path.join(clientDir, file), `original ${file}`);
228
+ const before = files.map(file => fs.readFileSync(path.join(clientDir, file)));
229
+ const p = policy(); const body = fixture(); const compacted = compactRawHistory(body.messages, 'anthropic', p);
230
+ for (let i = 0; i < 8; i++) { advance(1); cache.create(keyFor(body, req(`session-${i}`)), body.messages, compacted.prefixCount, compacted.replacement, p); }
231
+ assert.equal(fs.readdirSync(path.join(dir, 'idle-compact')).length, 8);
232
+ advance(p.sessionLookbackHours * 3600000 + 1);
233
+ cache.prune(p);
234
+ assert.equal(fs.readdirSync(path.join(dir, 'idle-compact')).length, 0);
235
+ files.forEach((file, i) => assert.deepEqual(fs.readFileSync(path.join(clientDir, file)), before[i]));
236
+ });
237
+
238
+ test('empty summary model calls neither builder nor provider', async () => {
239
+ let called = false;
240
+ assert.equal(await askSummary({ ir: parseToIR('anthropic', fixture()), policy: policy(), profile,
241
+ model: 'official-expensive-model', build() { called = true; }, fetchImpl() { called = true; } }), '');
242
+ assert.equal(called, false);
243
+ });
244
+
245
+ test('summary builder exceptions, HTTP failures, tool-only output, and ignored abort are contained with diagnostics', async () => {
246
+ const ir = parseToIR('anthropic', fixture()); const p = policy({ model: 'free-summary' });
247
+ const diagnostics = []; const base = { ir, policy: p, profile, model: 'mapped-summary', onDiagnostic: e => diagnostics.push(e.code) };
248
+ assert.equal(await askSummary({ ...base, build() { throw new Error('bad route'); } }), '');
249
+ const build = () => ({ url: 'http://unused', headers: {}, upBody: {} });
250
+ assert.equal(await askSummary({ ...base, build, fetchImpl: async () => new Response('', { status: 429 }) }), '');
251
+ assert.equal(await askSummary({ ...base, build, fetchImpl: async () => new Response(JSON.stringify({ content: [{ type: 'tool_use', id: 'c', name: 'Read' }] })) }), '');
252
+ assert.equal(await askSummary({ ...base, build, timeoutMs: 10, fetchImpl: () => new Promise(() => {}) }), '');
253
+ assert.deepEqual(diagnostics, ['summary-call-failed', 'summary-http-failed', 'summary-empty-output', 'summary-timeout']);
254
+ });
255
+
256
+ test('configured summary uses supplied mapped model, stripped tools, and bounded text', async () => {
257
+ const p = policy({ model: 'summary-alias', summaryMaxChars: 256 }); let sent;
258
+ const result = await askSummary({ ir: parseToIR('anthropic', fixture()), policy: p, profile, model: 'mapped-cheap',
259
+ build(_profile, model, ir) { assert.equal(model, 'mapped-cheap'); assert.equal(ir.model, model);
260
+ assert.deepEqual(ir.tools, []); return { url: 'http://mock', headers: {}, upBody: { model, messages: ir.messages } }; },
261
+ fetchImpl: async (_url, options) => { sent = JSON.parse(options.body);
262
+ return new Response(JSON.stringify({ choices: [{ message: { content: 'SUMMARY ' + 'X'.repeat(1000) } }] })); } });
263
+ assert.ok(result.length <= 256);
264
+ assert.equal(sent.model, 'mapped-cheap');
265
+ assert.match(JSON.stringify(sent.messages), /DECISION_1:/);
266
+ assert.equal(clampSummary(null, p), '');
267
+ });
268
+
269
+ test('invalid legacy numeric text budgets preserve complete text while wrong types default', () => {
270
+ for (const value of [0, -1, NaN, Infinity]) {
271
+ const p = policy({ userChars: value, summaryMaxChars: value });
272
+ const text = '完整原文'.repeat(10000);
273
+ assert.equal(clampSummary(text, p), text);
274
+ const msgs = summaryMessages({messages:[{role:'user', content:text}]}, p);
275
+ assert.equal(JSON.stringify(msgs).includes(text), true);
276
+ }
277
+ assert.equal(clampSummary('X'.repeat(50000), policy({summaryMaxChars:1})).length, 50000);
278
+ for (const value of [null, [], {}, '0', true]) {
279
+ const p = policy({userChars:value,summaryMaxChars:value});
280
+ assert.equal(p.userChars, 3000); assert.equal(p.summaryMaxChars, 24000);
281
+ }
282
+ });