llm-switcher 1.6.0 → 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.
- package/CHANGELOG.md +26 -0
- package/README.md +42 -34
- package/README.vi.md +38 -33
- package/docs/session-2026-10-07-compaction.md +224 -0
- package/formats.mjs +85 -25
- package/idlecache.mjs +159 -0
- package/idlecall.mjs +46 -54
- package/idlecompact.mjs +171 -238
- package/package.json +1 -1
- package/proxy.mjs +111 -135
- package/shim.mjs +29 -0
- package/state.mjs +48 -2
- package/switch.mjs +12 -5
- package/tests/certs.test.mjs +7 -1
- package/tests/codex-daemon.test.mjs +57 -3
- package/tests/compact-cli.test.mjs +34 -0
- package/tests/dashboard.e2e.test.mjs +7 -1
- package/tests/formats.test.mjs +44 -1
- package/tests/idlecompact.e2e.test.mjs +203 -0
- package/tests/idlecompact.test.mjs +233 -279
- package/ui.html +449 -81
- package/claudesession.mjs +0 -196
- package/codexsession.mjs +0 -141
|
@@ -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 {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
44
|
-
return
|
|
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
|
-
|
|
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({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
58
|
-
assert.equal(p.keepRecent,
|
|
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('
|
|
62
|
-
|
|
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('
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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('
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
assert.
|
|
148
|
-
|
|
149
|
-
|
|
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('
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
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('
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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('
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
assert.equal(
|
|
202
|
-
assert.
|
|
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('
|
|
206
|
-
const {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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('
|
|
213
|
-
const {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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('
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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('
|
|
231
|
-
|
|
232
|
-
assert.equal(
|
|
233
|
-
|
|
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('
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
assert.equal(
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
}
|