llm-switcher 1.6.0 → 1.6.3

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.
@@ -1,328 +1,282 @@
1
- // Idle compaction.
2
- //
3
- // The part worth testing hard is the session file, because nothing in any API guarantees it: the
4
- // shape was read off a real session Claude Code wrote itself, and the only proof it works is that
5
- // Claude Code reads it back. The test that matters is TestResumeReadsTheBoundary, which runs the
6
- // real binary.
7
1
  import { test } from 'node:test';
8
2
  import assert from 'node:assert/strict';
9
3
  import fs from 'node:fs';
10
4
  import os from 'node:os';
11
5
  import path from 'node:path';
12
- import { execFile, execFileSync } from 'node:child_process';
13
- import { promisify } from 'node:util';
14
-
15
- // Async, never execFileSync: the probe server that answers Claude Code runs in this same process,
16
- // and a blocking call would stop the event loop from ever answering it.
17
- const run = promisify(execFile);
18
- import http from 'node:http';
19
- import crypto from 'node:crypto';
20
-
21
- import {
22
- idleCompactPolicy, conversationKey, idleFor, noteConversation, compactIR,
23
- summaryMessages, clampSummary, SUMMARY_INSTRUCTION,
24
- } from '../idlecompact.mjs';
25
- import { sessionFileFor, claudeSessionId, writeCompaction, readLeaf, claudeConfigDir } from '../claudesession.mjs';
26
-
27
- const long = 'X'.repeat(4000);
28
-
29
- function agentIR(turns = 10, fill = 4000) {
30
- const messages = [{ role: 'user', content: [{ type: 'text', text: 'fix the failing test' }] }];
31
- for (let i = 0; i < turns; i++) {
32
- messages.push({ role: 'assistant', content: [
33
- { type: 'text', text: `step ${i}` },
34
- { type: 'tool_use', id: `t${i}`, name: 'Bash', input: { command: 'ls' } },
35
- ] });
36
- messages.push({ role: 'user', content: [
37
- { type: 'tool_result', tool_use_id: `t${i}`, content: `turn${i}:` + 'Y'.repeat(fill) },
38
- ] });
39
- }
40
- return { system: 'be brief', messages, tools: [], model: 'claude-x' };
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; } };
41
30
  }
42
31
 
43
- function policy(over = {}) {
44
- return idleCompactPolicy({ idleCompact: { enabled: true, idleMinutes: 15, minBytes: 1024, ...over } });
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);
45
34
  }
46
35
 
47
- // ---- the policy ----
48
-
49
- test('the feature is off until it is asked for', () => {
36
+ test('config accepts all supported fields and rejects unknown, wrong type, zero, and fractional counts', () => {
50
37
  assert.equal(idleCompactPolicy({}).enabled, false);
51
- assert.equal(idleCompactPolicy({ idleCompact: { enabled: true } }).enabled, true);
52
- });
53
-
54
- test('a nonsense setting falls back to the default instead of breaking', () => {
55
- const p = idleCompactPolicy({ idleCompact: { idleMinutes: -3, minBytes: 'x', keepRecent: 0 } });
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 } });
56
49
  assert.equal(p.idleMinutes, 15);
57
- assert.ok(p.minBytes > 0);
58
- assert.equal(p.keepRecent, 6);
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);
59
54
  });
60
55
 
61
- test('a conversation that was never here is not idle', () => {
62
- assert.equal(idleFor('nope'), 0);
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
63
  });
64
64
 
65
- test('the time since the last request is what the pause means', () => {
66
- const now = 1_000_000;
67
- noteConversation('k', now - 20 * 60000);
68
- assert.ok(Math.abs(idleFor('k', now) - 20 * 60000) < 50);
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: {} }), '');
69
75
  });
70
76
 
71
- // ---- which conversation ----
72
-
73
- test('the session the client names wins over its content', () => {
74
- const k = conversationKey('claude', { headers: { 'x-claude-code-session-id': 'abc' } }, agentIR());
75
- assert.equal(k, 'h:abc');
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}/);
76
86
  });
77
87
 
78
- test('a client that names nothing is keyed on its opening request', () => {
79
- const k1 = conversationKey('claude', { headers: {} }, agentIR());
80
- const k2 = conversationKey('claude', { headers: {} }, agentIR());
81
- assert.equal(k1, k2, 'the same conversation keys the same both times');
82
- const other = agentIR();
83
- other.messages[0].content[0].text = 'a different task entirely';
84
- assert.notEqual(k1, conversationKey('claude', { headers: {} }, other));
85
- });
86
-
87
- test('the key survives the compaction it causes', () => {
88
- // The opening request is the one message a compaction keeps, which is the whole reason the
89
- // fallback key is built from it and not from the whole body.
90
- const ir = agentIR();
91
- const before = conversationKey('claude', { headers: {} }, ir);
92
- const c = compactIR(ir, policy());
93
- ir.messages = c.messages;
94
- assert.equal(conversationKey('claude', { headers: {} }, ir), before);
95
- });
96
-
97
- // ---- the shortened history ----
98
-
99
- test('the opening request and the recent turns survive as bytes', () => {
100
- const ir = agentIR(10);
101
- const c = compactIR(ir, policy({ keepRecent: 6 }));
102
- assert.ok(c);
103
- assert.equal(c.messages[0].content[0].text, 'fix the failing test');
104
- const tail = JSON.stringify(c.messages.slice(-6));
105
- assert.ok(tail.includes('tool_use'), 'the recent tool calls are kept: the next turn reasons over them');
106
- });
107
-
108
- test('the bulky middle goes and its words stay', () => {
109
- // Each turn is tagged, so the test can tell an early turn from a recent one. The recent turns
110
- // keep their tool output on purpose: the next turn reasons over those results verbatim.
111
- const ir = agentIR(10);
112
- const c = compactIR(ir, policy({ keepRecent: 6 }));
113
- const text = JSON.stringify(c.messages);
114
- assert.ok(!text.includes('turn1:'), 'an early middle tool result came through whole');
115
- assert.ok(text.includes('turn9:'), 'a recent tool result was cut, so the next turn lost what it reasons over');
116
- assert.ok(text.includes('step 3'), 'a middle assistant message lost its text');
117
- assert.ok(c.dropped > 0);
118
- });
119
-
120
- test('no tool result survives without the call that made it', () => {
121
- // A result whose call was cut away is a request the provider refuses, so this is the invariant
122
- // that has to hold whatever the window lands on.
123
- for (const keep of [3, 4, 5, 6, 8]) {
124
- const c = compactIR(agentIR(12), policy({ keepRecent: keep }));
125
- assert.ok(c, `keepRecent=${keep} produced nothing`);
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));
126
96
  const calls = new Set();
127
- for (const m of c.messages) {
128
- for (const p of m.content || []) {
129
- if (p?.type === 'tool_use') calls.add(p.id);
130
- if (p?.type === 'tool_result') {
131
- assert.ok(calls.has(p.tool_use_id), `keepRecent=${keep}: result ${p.tool_use_id} has no call`);
132
- }
133
- }
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));
134
100
  }
135
101
  }
136
102
  });
137
103
 
138
- test('a conversation too short to be worth it is left alone', () => {
139
- assert.equal(compactIR(agentIR(2, 10), policy()), null);
140
- assert.equal(compactIR({ messages: [{ role: 'user', content: 'hi' }] }, policy()), null);
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);
141
114
  });
142
115
 
143
- // ---- the summary ----
144
-
145
- test('the summarizer is handed the history without its tool output', () => {
146
- const msgs = summaryMessages(agentIR(10), policy());
147
- assert.ok(msgs.length > 1);
148
- assert.match(msgs[0].content[0].text, /summarize/i);
149
- assert.ok(!JSON.stringify(msgs).includes('Y'.repeat(4000)));
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 })));
150
130
  });
151
131
 
152
- test('an over-long summary is cut at a boundary, not mid-word', () => {
153
- const s = clampSummary('A'.repeat(10) + '\n\n' + 'B'.repeat(30000), policy({ summaryMaxChars: 1000 }));
154
- assert.ok(s.length <= 1000);
155
- assert.ok(!s.includes('A'.repeat(11)));
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
+ }
156
137
  });
157
138
 
158
- test('a summary of nothing is nothing', () => {
159
- assert.equal(clampSummary('', policy()), '');
160
- assert.equal(clampSummary(null, policy()), '');
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
+ }
161
156
  });
162
157
 
163
- // ---- the session file ----
164
-
165
- function tmpSession(t) {
166
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-sess-'));
167
- t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
168
- const file = path.join(dir, 'sess.jsonl');
169
- return { file, dir };
170
- }
171
-
172
- function seed(file, entries) {
173
- fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
174
- }
175
-
176
- test('the compaction is written as the two entries Claude Code writes', (t) => {
177
- const { file } = tmpSession(t);
178
- seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
179
- const r = writeCompaction({ file, summary: 'the summary', sessionId: 's1', preTokens: 900, postTokens: 80 });
180
- assert.ok(r);
181
- const lines = fs.readFileSync(file, 'utf8').trim().split('\n').map(l => JSON.parse(l));
182
- const boundary = lines.at(-2);
183
- const summary = lines.at(-1);
184
- assert.equal(boundary.type, 'system');
185
- assert.equal(boundary.subtype, 'compact_boundary');
186
- assert.equal(boundary.logicalParentUuid, 'u1', 'the chain is cut at the entry before');
187
- assert.equal(boundary.compactMetadata.preTokens, 900);
188
- assert.equal(summary.type, 'user');
189
- assert.equal(summary.isCompactSummary, true);
190
- assert.match(summary.message.content, /the summary/);
191
- assert.match(summary.message.content, /continued from a previous conversation/);
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
+ }
192
177
  });
193
178
 
194
- test('the old entries stay, because a transcript is worth more than a small file', (t) => {
195
- // This is what Claude Code itself does: its own compaction drops ~949k tokens and leaves the
196
- // entries. The boundary makes them unreachable, not gone.
197
- const { file } = tmpSession(t);
198
- seed(file, Array.from({ length: 20 }, (_, i) => ({ type: 'user', uuid: `u${i}`, sessionId: 's1', message: { role: 'user', content: `m${i}` } })));
199
- writeCompaction({ file, summary: 'the summary', sessionId: 's1' });
200
- const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
201
- assert.equal(lines.length, 22);
202
- assert.ok(lines.some(l => l.includes('"m0"')), 'the earliest entry is still readable');
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]);
203
190
  });
204
191
 
205
- test('a session already compacted is not compacted twice', (t) => {
206
- const { file } = tmpSession(t);
207
- seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
208
- assert.ok(writeCompaction({ file, summary: 'first', sessionId: 's1' }));
209
- assert.equal(writeCompaction({ file, summary: 'second', sessionId: 's1' }), null);
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);
210
204
  });
211
205
 
212
- test('nothing is written where there is nothing to write', (t) => {
213
- const { file } = tmpSession(t);
214
- seed(file, [{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'a' } }]);
215
- assert.equal(writeCompaction({ file, summary: '', sessionId: 's1' }), null);
216
- assert.equal(writeCompaction({ file: path.join(path.dirname(file), 'gone.jsonl'), summary: 'x' }), null);
217
- assert.equal(readLeaf(file).uuid, 'u1');
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);
218
221
  });
219
222
 
220
- test('every appended line is valid json', (t) => {
221
- // A torn line makes Claude Code drop the rest of the file, which loses the transcript.
222
- const { file } = tmpSession(t);
223
- seed(file, [{ type: 'user', uuid: 'u1', sessionId: 's1', message: { role: 'user', content: 'a' } }]);
224
- writeCompaction({ file, summary: 'multi\nline\nsummary', sessionId: 's1' });
225
- for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
226
- if (line.trim()) JSON.parse(line);
227
- }
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]));
228
236
  });
229
237
 
230
- test('the session id comes from the header or the body Claude Code sends', () => {
231
- assert.equal(claudeSessionId({ headers: { 'x-claude-code-session-id': 'S1' } }, {}), 'S1');
232
- assert.equal(claudeSessionId({ headers: {} }, { metadata: { user_id: 'u_1_account_a_session_dead-beef' } }), 'dead-beef');
233
- assert.equal(claudeSessionId({ headers: {} }, {}), '');
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);
234
243
  });
235
244
 
236
- test('the session file sits where Claude Code puts it', () => {
237
- const { file } = sessionFileFor('abc', '/opt/hermes/proj', { CLAUDE_CONFIG_DIR: '/cfg' });
238
- assert.equal(file, '/cfg/projects/-opt-hermes-proj/abc.jsonl');
239
- assert.equal(claudeConfigDir({ CLAUDE_CONFIG_DIR: '/x' }), '/x');
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']);
240
254
  });
241
255
 
242
- // ---- the real thing ----
243
-
244
- // This is the test that settles whether writing the file does anything at all. It runs the real
245
- // Claude Code binary against a local server and reads the request it sends.
246
- const probe = (() => {
247
- let last = null;
248
- const server = http.createServer((req, res) => {
249
- let b = '';
250
- req.on('data', c => (b += c));
251
- req.on('end', () => {
252
- try { last = JSON.parse(b); } catch { /* keep the last good one */ }
253
- res.writeHead(200, { 'Content-Type': 'application/json' });
254
- res.end(JSON.stringify({
255
- id: 'msg_probe', type: 'message', role: 'assistant', model: last?.model || 'x',
256
- content: [{ type: 'text', text: 'probe ok' }], stop_reason: 'end_turn',
257
- usage: { input_tokens: 1, output_tokens: 1 },
258
- }));
259
- });
260
- });
261
- return { server, get last() { return last; } };
262
- })();
263
-
264
- test('Claude Code resumes from the boundary and leaves the old turns out', { timeout: 240000, skip: !hasClaude() }, async () => {
265
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llmsw-cc-'));
266
- await new Promise(r => probe.server.listen(0, '127.0.0.1', r));
267
- const port = probe.server.address().port;
268
- const cfg = path.join(dir, 'cfg');
269
- const proj = path.join(dir, 'proj');
270
- fs.mkdirSync(proj, { recursive: true });
271
- fs.writeFileSync(path.join(proj, 'CLAUDE.md'), 'probe project\n');
272
- const env = {
273
- ...process.env,
274
- ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
275
- ANTHROPIC_API_KEY: 'probe-key',
276
- CLAUDE_CONFIG_DIR: cfg,
277
- };
278
-
279
- // One real turn, so the session file is written by Claude Code and has its own shape in it.
280
- await run('claude', ['-p', 'say ok', '--model', 'claude-probe-1'], { env, cwd: proj, timeout: 180000 });
281
- const sessions = path.join(cfg, 'projects', proj.replace(/[\\/:]/g, '-'));
282
- const file = fs.readdirSync(sessions).map(f => path.join(sessions, f))[0];
283
- const sid = path.basename(file, '.jsonl');
284
- const sidFromBody = JSON.parse(fs.readFileSync(file, 'utf8').split('\n')[0]).sessionId;
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
+ });
285
268
 
286
- // Twelve turns of history, then a compaction boundary before them.
287
- const entries = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
288
- const leaf = entries.filter(e => e.uuid).at(-1);
289
- const base = (u, parent) => ({
290
- parentUuid: parent, isSidechain: false, userType: 'external', cwd: proj,
291
- version: '2.1.292', sessionId: sidFromBody || sid, uuid: u, timestamp: new Date().toISOString(),
292
- });
293
- let parent = leaf.uuid;
294
- for (let i = 0; i < 12; i++) {
295
- const u = crypto.randomUUID();
296
- entries.push({ ...base(u, parent), type: 'user', message: { role: 'user', content: `turn ${i} question` } });
297
- parent = u;
298
- const a = crypto.randomUUID();
299
- entries.push({ ...base(a, parent), type: 'assistant', message: { role: 'assistant', content: [
300
- { type: 'text', text: `answer ${i}` },
301
- { type: 'tool_use', id: `toolu_${i}`, name: 'Read', input: { file_path: '/x/y.py' } }] } });
302
- parent = a;
303
- const t = crypto.randomUUID();
304
- entries.push({ ...base(t, parent), type: 'user', message: { role: 'user', content: [
305
- { type: 'tool_result', tool_use_id: `toolu_${i}`, content: long }] } });
306
- parent = t;
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);
307
281
  }
308
- fs.writeFileSync(file, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
309
- const beforeBytes = fs.statSync(file).size;
310
-
311
- const wrote = writeCompaction({ file, summary: 'PROBE_SUMMARY_MARKER_12345', sessionId: sid, cwd: proj });
312
- assert.ok(wrote, 'the boundary was written');
313
-
314
- await run('claude', ['-p', 'after compact, reply ok', '--model', 'claude-probe-1', '--resume', sid],
315
- { env, cwd: proj, timeout: 180000 });
316
-
317
- const sent = JSON.stringify(probe.last?.messages || []);
318
- assert.match(sent, /PROBE_SUMMARY_MARKER_12345/, 'the summary is what the request carries');
319
- assert.equal((sent.match(new RegExp(long, 'g')) || []).length, 0, 'none of the twelve turns came through');
320
- assert.ok(fs.statSync(file).size > beforeBytes, 'the entries are appended, not rewritten');
321
-
322
- await new Promise(r => probe.server.close(r));
323
- fs.rmSync(dir, { recursive: true, force: true });
324
282
  });
325
-
326
- function hasClaude() {
327
- try { execFileSync('claude', ['--version'], { stdio: 'ignore', timeout: 30000 }); return true; } catch { return false; }
328
- }