mixdog 0.9.40 → 0.9.41
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/package.json +2 -1
- package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
- package/scripts/compact-pressure-test.mjs +128 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/internal-comms-smoke.mjs +66 -25
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +222 -0
- package/scripts/provider-toolcall-test.mjs +32 -0
- package/src/agents/reviewer/AGENT.md +4 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/workflows/bench/WORKFLOW.md +51 -27
- package/src/workflows/default/WORKFLOW.md +29 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.41",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"test:shellhardening": "node --test scripts/shell-hardening-test.mjs",
|
|
58
58
|
"test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
|
|
59
59
|
"test:providers": "node --test scripts/provider-toolcall-test.mjs",
|
|
60
|
+
"test:anthropic-oauth-race": "node --test scripts/anthropic-oauth-refresh-race-test.mjs",
|
|
60
61
|
"test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
|
|
61
62
|
"test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs",
|
|
62
63
|
"test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import test, { after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-anthropic-race-'));
|
|
17
|
+
const credentialsPath = join(root, 'anthropic-oauth-credentials.json');
|
|
18
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
19
|
+
process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH = credentialsPath;
|
|
20
|
+
|
|
21
|
+
const credentialsModule = await import(
|
|
22
|
+
'../src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs'
|
|
23
|
+
);
|
|
24
|
+
const { AnthropicOAuthProvider } = await import(
|
|
25
|
+
'../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs'
|
|
26
|
+
);
|
|
27
|
+
const {
|
|
28
|
+
_saveCredentialsFile,
|
|
29
|
+
loadCredentials,
|
|
30
|
+
loadCredentialsFromPath,
|
|
31
|
+
preflightAnthropicOAuthCredentials,
|
|
32
|
+
refreshOAuthCredentials,
|
|
33
|
+
} = credentialsModule;
|
|
34
|
+
|
|
35
|
+
after(() => {
|
|
36
|
+
delete process.env.MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED;
|
|
37
|
+
rmSync(root, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function writeCredentials({ accessToken, refreshToken, expiresAt }, path = credentialsPath) {
|
|
41
|
+
_saveCredentialsFile(path, {
|
|
42
|
+
claudeAiOauth: {
|
|
43
|
+
accessToken,
|
|
44
|
+
refreshToken,
|
|
45
|
+
expiresAt,
|
|
46
|
+
scopes: ['user:inference'],
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('parallel host preflights refresh once and snapshot the leased generation', async () => {
|
|
52
|
+
writeCredentials({
|
|
53
|
+
accessToken: 'fixture-access-old',
|
|
54
|
+
refreshToken: 'fixture-refresh-old',
|
|
55
|
+
expiresAt: Date.now() + 1_000,
|
|
56
|
+
});
|
|
57
|
+
let refreshCalls = 0;
|
|
58
|
+
const refreshFn = async (current) => {
|
|
59
|
+
refreshCalls += 1;
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
61
|
+
const raw = JSON.parse(readFileSync(current.path, 'utf8'));
|
|
62
|
+
raw.claudeAiOauth.accessToken = 'fixture-access-new';
|
|
63
|
+
raw.claudeAiOauth.refreshToken = 'fixture-refresh-new';
|
|
64
|
+
raw.claudeAiOauth.expiresAt = Date.now() + 10 * 60_000;
|
|
65
|
+
_saveCredentialsFile(current.path, raw);
|
|
66
|
+
return loadCredentials();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const snapshots = Array.from(
|
|
70
|
+
{ length: 12 },
|
|
71
|
+
(_, index) => join(root, `container-${index}.json`),
|
|
72
|
+
);
|
|
73
|
+
await Promise.all(snapshots.map((snapshotPath) => (
|
|
74
|
+
preflightAnthropicOAuthCredentials({
|
|
75
|
+
minimumValidityMs: 5 * 60_000,
|
|
76
|
+
snapshotPath,
|
|
77
|
+
refreshFn,
|
|
78
|
+
})
|
|
79
|
+
)));
|
|
80
|
+
|
|
81
|
+
assert.equal(refreshCalls, 1);
|
|
82
|
+
for (const snapshotPath of snapshots) {
|
|
83
|
+
const oauth = JSON.parse(readFileSync(snapshotPath, 'utf8')).claudeAiOauth;
|
|
84
|
+
assert.equal(oauth.accessToken, 'fixture-access-new');
|
|
85
|
+
assert.equal(Object.hasOwn(oauth, 'refreshToken'), false);
|
|
86
|
+
assert.equal(Object.hasOwn(oauth, 'refresh_token'), false);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('explicit credential path is pinned over a newer default candidate', async () => {
|
|
91
|
+
const explicitPath = join(root, 'explicit', 'credentials.json');
|
|
92
|
+
mkdirSync(join(root, 'explicit'), { recursive: true });
|
|
93
|
+
writeCredentials({
|
|
94
|
+
accessToken: 'fixture-default-newer',
|
|
95
|
+
refreshToken: 'fixture-default-refresh',
|
|
96
|
+
expiresAt: Date.now() + 60 * 60_000,
|
|
97
|
+
});
|
|
98
|
+
writeCredentials({
|
|
99
|
+
accessToken: 'fixture-explicit',
|
|
100
|
+
refreshToken: 'fixture-explicit-refresh',
|
|
101
|
+
expiresAt: Date.now() + 20 * 60_000,
|
|
102
|
+
}, explicitPath);
|
|
103
|
+
process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH = explicitPath;
|
|
104
|
+
const snapshotPath = join(root, 'explicit-snapshot.json');
|
|
105
|
+
try {
|
|
106
|
+
assert.equal(loadCredentials().path, explicitPath);
|
|
107
|
+
assert.equal(loadCredentials().accessToken, 'fixture-explicit');
|
|
108
|
+
await preflightAnthropicOAuthCredentials({
|
|
109
|
+
credentialsPath: explicitPath,
|
|
110
|
+
minimumValidityMs: 5 * 60_000,
|
|
111
|
+
snapshotPath,
|
|
112
|
+
});
|
|
113
|
+
const snapshot = loadCredentialsFromPath(snapshotPath);
|
|
114
|
+
assert.equal(snapshot.accessToken, 'fixture-explicit');
|
|
115
|
+
assert.equal(snapshot.refreshToken, null);
|
|
116
|
+
assert.equal(
|
|
117
|
+
loadCredentialsFromPath(credentialsPath).accessToken,
|
|
118
|
+
'fixture-default-newer',
|
|
119
|
+
);
|
|
120
|
+
} finally {
|
|
121
|
+
process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH = credentialsPath;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('10 contending host processes share one token exchange lock', async () => {
|
|
126
|
+
const processCredentials = join(root, 'process-credentials.json');
|
|
127
|
+
const exchangeDir = join(root, 'exchange-owners');
|
|
128
|
+
const startPath = join(root, 'children-start');
|
|
129
|
+
const readyDir = join(root, 'children-ready');
|
|
130
|
+
const snapshotDir = join(root, 'child-snapshots');
|
|
131
|
+
mkdirSync(readyDir);
|
|
132
|
+
mkdirSync(snapshotDir);
|
|
133
|
+
mkdirSync(exchangeDir);
|
|
134
|
+
writeCredentials({
|
|
135
|
+
accessToken: 'fixture-process-old',
|
|
136
|
+
refreshToken: 'fixture-process-refresh-old',
|
|
137
|
+
expiresAt: Date.now() + 1_000,
|
|
138
|
+
}, processCredentials);
|
|
139
|
+
|
|
140
|
+
const moduleUrl = new URL(
|
|
141
|
+
'../src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs',
|
|
142
|
+
import.meta.url,
|
|
143
|
+
).href;
|
|
144
|
+
const childSource = `
|
|
145
|
+
import { existsSync, writeFileSync } from 'node:fs';
|
|
146
|
+
const mod = await import(${JSON.stringify(moduleUrl)});
|
|
147
|
+
const stale = mod.loadCredentials();
|
|
148
|
+
writeFileSync(process.env.READY_PATH, 'ready');
|
|
149
|
+
while (!existsSync(process.env.START_PATH)) {
|
|
150
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
151
|
+
}
|
|
152
|
+
globalThis.fetch = async () => {
|
|
153
|
+
const owner = String(process.pid);
|
|
154
|
+
writeFileSync(process.env.EXCHANGE_DIR + '/' + owner + '.exchange', owner, { flag: 'wx' });
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
status: 200,
|
|
159
|
+
async text() {
|
|
160
|
+
return JSON.stringify({
|
|
161
|
+
access_token: 'fixture-process-new-' + owner,
|
|
162
|
+
refresh_token: 'fixture-process-refresh-new-' + owner,
|
|
163
|
+
expires_in: 600
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
if (process.env.CHILD_MODE === 'preflight') {
|
|
169
|
+
await mod.preflightAnthropicOAuthCredentials({
|
|
170
|
+
credentialsPath: process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH,
|
|
171
|
+
minimumValidityMs: 300000,
|
|
172
|
+
snapshotPath: process.env.SNAPSHOT_PATH
|
|
173
|
+
});
|
|
174
|
+
} else {
|
|
175
|
+
await mod.refreshOAuthCredentials(stale);
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const exits = [];
|
|
180
|
+
for (let index = 0; index < 10; index += 1) {
|
|
181
|
+
const child = spawn(
|
|
182
|
+
process.execPath,
|
|
183
|
+
['--input-type=module', '--eval', childSource],
|
|
184
|
+
{
|
|
185
|
+
env: {
|
|
186
|
+
...process.env,
|
|
187
|
+
MIXDOG_DATA_DIR: root,
|
|
188
|
+
ANTHROPIC_OAUTH_CREDENTIALS_PATH: processCredentials,
|
|
189
|
+
READY_PATH: join(readyDir, String(index)),
|
|
190
|
+
START_PATH: startPath,
|
|
191
|
+
EXCHANGE_DIR: exchangeDir,
|
|
192
|
+
CHILD_MODE: index % 2 === 0 ? 'preflight' : 'refresh',
|
|
193
|
+
SNAPSHOT_PATH: join(snapshotDir, `${index}.json`),
|
|
194
|
+
},
|
|
195
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
exits.push(new Promise((resolveExit, rejectExit) => {
|
|
199
|
+
let stderr = '';
|
|
200
|
+
child.stderr.setEncoding('utf8');
|
|
201
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
202
|
+
child.on('error', rejectExit);
|
|
203
|
+
child.on('exit', (code) => {
|
|
204
|
+
if (code === 0) resolveExit();
|
|
205
|
+
else rejectExit(new Error(`contention child ${index} exited ${code}: ${stderr}`));
|
|
206
|
+
});
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const readyDeadline = Date.now() + 30_000;
|
|
211
|
+
while (
|
|
212
|
+
Array.from({ length: 10 }, (_, index) => existsSync(join(readyDir, String(index))))
|
|
213
|
+
.some((ready) => !ready)
|
|
214
|
+
) {
|
|
215
|
+
if (Date.now() >= readyDeadline) throw new Error('contention children did not become ready');
|
|
216
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 20));
|
|
217
|
+
}
|
|
218
|
+
writeFileSync(startPath, 'start');
|
|
219
|
+
await Promise.all(exits);
|
|
220
|
+
|
|
221
|
+
const exchangeMarkers = readdirSync(exchangeDir)
|
|
222
|
+
.filter((name) => name.endsWith('.exchange'));
|
|
223
|
+
assert.equal(exchangeMarkers.length, 1);
|
|
224
|
+
const exchangeOwner = exchangeMarkers[0].slice(0, -'.exchange'.length);
|
|
225
|
+
assert.equal(
|
|
226
|
+
readFileSync(join(exchangeDir, exchangeMarkers[0]), 'utf8'),
|
|
227
|
+
exchangeOwner,
|
|
228
|
+
);
|
|
229
|
+
assert.equal(
|
|
230
|
+
loadCredentialsFromPath(processCredentials).accessToken,
|
|
231
|
+
`fixture-process-new-${exchangeOwner}`,
|
|
232
|
+
);
|
|
233
|
+
for (let index = 0; index < 10; index += 2) {
|
|
234
|
+
const oauth = JSON.parse(
|
|
235
|
+
readFileSync(join(snapshotDir, `${index}.json`), 'utf8'),
|
|
236
|
+
).claudeAiOauth;
|
|
237
|
+
assert.equal(oauth.accessToken, `fixture-process-new-${exchangeOwner}`);
|
|
238
|
+
assert.equal(Object.hasOwn(oauth, 'refreshToken'), false);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('host preflight fails clearly when refresh cannot satisfy the lease', async () => {
|
|
243
|
+
writeCredentials({
|
|
244
|
+
accessToken: 'fixture-access-short-old',
|
|
245
|
+
refreshToken: 'fixture-refresh-short-old',
|
|
246
|
+
expiresAt: Date.now() + 1_000,
|
|
247
|
+
});
|
|
248
|
+
const snapshotPath = join(root, 'short-lease-container.json');
|
|
249
|
+
const refreshFn = async (current) => {
|
|
250
|
+
const raw = JSON.parse(readFileSync(current.path, 'utf8'));
|
|
251
|
+
raw.claudeAiOauth.accessToken = 'fixture-access-short-new';
|
|
252
|
+
raw.claudeAiOauth.refreshToken = 'fixture-refresh-short-new';
|
|
253
|
+
raw.claudeAiOauth.expiresAt = Date.now() + 30_000;
|
|
254
|
+
_saveCredentialsFile(current.path, raw);
|
|
255
|
+
return loadCredentials();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
await assert.rejects(
|
|
259
|
+
preflightAnthropicOAuthCredentials({
|
|
260
|
+
minimumValidityMs: 60_000,
|
|
261
|
+
snapshotPath,
|
|
262
|
+
refreshFn,
|
|
263
|
+
}),
|
|
264
|
+
/cannot satisfy the 60s credential lease/,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('expiring, forced, and 401 containers cause zero token exchanges', async () => {
|
|
269
|
+
writeCredentials({
|
|
270
|
+
accessToken: 'fixture-access-expiring',
|
|
271
|
+
refreshToken: 'fixture-refresh-must-not-be-used',
|
|
272
|
+
expiresAt: Date.now() + 1_000,
|
|
273
|
+
});
|
|
274
|
+
process.env.MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED = '1';
|
|
275
|
+
let fetchCalls = 0;
|
|
276
|
+
const originalFetch = globalThis.fetch;
|
|
277
|
+
globalThis.fetch = async () => {
|
|
278
|
+
fetchCalls += 1;
|
|
279
|
+
throw new Error('refresh transport must not run');
|
|
280
|
+
};
|
|
281
|
+
try {
|
|
282
|
+
const providers = Array.from({ length: 12 }, () => {
|
|
283
|
+
const provider = Object.create(AnthropicOAuthProvider.prototype);
|
|
284
|
+
provider.credentials = loadCredentials();
|
|
285
|
+
provider.config = {};
|
|
286
|
+
return provider;
|
|
287
|
+
});
|
|
288
|
+
const results = await Promise.allSettled(
|
|
289
|
+
providers.map((provider) => provider.ensureAuth()),
|
|
290
|
+
);
|
|
291
|
+
assert.equal(fetchCalls, 0);
|
|
292
|
+
assert.ok(results.every((result) => (
|
|
293
|
+
result.status === 'rejected'
|
|
294
|
+
&& /refresh is disabled.*Host credential preflight/s.test(result.reason.message)
|
|
295
|
+
)));
|
|
296
|
+
await assert.rejects(
|
|
297
|
+
refreshOAuthCredentials(loadCredentials()),
|
|
298
|
+
/host credential preflight must provide a fresh snapshot/,
|
|
299
|
+
);
|
|
300
|
+
writeCredentials({
|
|
301
|
+
accessToken: 'fixture-access-valid',
|
|
302
|
+
expiresAt: Date.now() + 10 * 60_000,
|
|
303
|
+
});
|
|
304
|
+
const forcedProvider = Object.create(AnthropicOAuthProvider.prototype);
|
|
305
|
+
forcedProvider.credentials = loadCredentials();
|
|
306
|
+
forcedProvider.config = {};
|
|
307
|
+
forcedProvider.fastModeBetaHeaderLatched = false;
|
|
308
|
+
await assert.rejects(
|
|
309
|
+
forcedProvider.ensureAuth({ forceRefresh: true, reason: 'test' }),
|
|
310
|
+
/refresh is disabled.*Host credential preflight/s,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
let apiRequests = 0;
|
|
314
|
+
await assert.rejects(
|
|
315
|
+
forcedProvider.send([], 'claude-sonnet-4-5', [], {
|
|
316
|
+
_doRequestFn: async () => {
|
|
317
|
+
apiRequests += 1;
|
|
318
|
+
return {
|
|
319
|
+
response: {
|
|
320
|
+
status: 401,
|
|
321
|
+
ok: false,
|
|
322
|
+
headers: new Map(),
|
|
323
|
+
async text() { return ''; },
|
|
324
|
+
},
|
|
325
|
+
controller: null,
|
|
326
|
+
cancelHandler: null,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
}),
|
|
330
|
+
/refresh is disabled.*Host credential preflight/s,
|
|
331
|
+
);
|
|
332
|
+
assert.equal(apiRequests, 1);
|
|
333
|
+
assert.equal(fetchCalls, 0);
|
|
334
|
+
} finally {
|
|
335
|
+
globalThis.fetch = originalFetch;
|
|
336
|
+
delete process.env.MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { estimateMessagesTokens } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
4
|
+
import {
|
|
5
|
+
compactionTelemetryPressureTokens,
|
|
6
|
+
recordProviderContextBaseline,
|
|
7
|
+
rememberCompactTelemetry,
|
|
8
|
+
resolveWorkerCompactPolicy,
|
|
9
|
+
shouldCompactForSession,
|
|
10
|
+
} from '../src/runtime/agent/orchestrator/session/loop/compact-policy.mjs';
|
|
11
|
+
|
|
12
|
+
function policyFor(session) {
|
|
13
|
+
return resolveWorkerCompactPolicy(session, []);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('Lead/main auto-compaction triggers at 95% of the effective boundary', () => {
|
|
17
|
+
const leadPolicy = policyFor({ contextWindow: 100_000, compaction: {} });
|
|
18
|
+
assert.equal(leadPolicy.triggerTokens, 95_000);
|
|
19
|
+
assert.equal(leadPolicy.bufferTokens, 5_000);
|
|
20
|
+
|
|
21
|
+
const noReserve = { ...leadPolicy, reserveTokens: 0 };
|
|
22
|
+
assert.equal(shouldCompactForSession(94_999, noReserve), false);
|
|
23
|
+
assert.equal(shouldCompactForSession(95_000, noReserve), true);
|
|
24
|
+
|
|
25
|
+
const agentPolicy = policyFor({ owner: 'agent', contextWindow: 100_000, compaction: {} });
|
|
26
|
+
assert.equal(agentPolicy.triggerTokens, 90_000, 'agent default 10% headroom must remain unchanged');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('provider callback usage counts assistant output once and estimates only later tool results', () => {
|
|
30
|
+
const session = { provider: 'anthropic', contextWindow: 100_000, compaction: {} };
|
|
31
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
32
|
+
const messages = [{ role: 'user', content: 'production-shaped tool request' }];
|
|
33
|
+
const onProviderUsage = d => recordProviderContextBaseline(session, messages, {
|
|
34
|
+
inputTokens: d.deltaInput,
|
|
35
|
+
outputTokens: d.deltaOutput,
|
|
36
|
+
promptTokens: d.deltaPrompt,
|
|
37
|
+
cachedTokens: d.deltaCachedRead,
|
|
38
|
+
cacheWriteTokens: d.deltaCacheWrite,
|
|
39
|
+
}, { boundary: 'request' });
|
|
40
|
+
assert.equal(onProviderUsage({
|
|
41
|
+
source: 'provider_send',
|
|
42
|
+
deltaInput: 5_000,
|
|
43
|
+
deltaOutput: 800,
|
|
44
|
+
deltaPrompt: 0,
|
|
45
|
+
deltaCachedRead: 88_000,
|
|
46
|
+
deltaCacheWrite: 1_000,
|
|
47
|
+
}), true);
|
|
48
|
+
messages.push({
|
|
49
|
+
role: 'assistant',
|
|
50
|
+
content: 'Calling the requested tool.',
|
|
51
|
+
reasoningItems: [{ type: 'reasoning', encrypted_content: 'opaque-provider-reasoning' }],
|
|
52
|
+
toolCalls: [{ id: 'call_1', name: 'read', arguments: '{"path":"large.txt"}' }],
|
|
53
|
+
});
|
|
54
|
+
const laterToolResult = { role: 'tool', toolCallId: 'call_1', content: 'x'.repeat(4_000) };
|
|
55
|
+
messages.push(laterToolResult);
|
|
56
|
+
|
|
57
|
+
const wholeEstimate = estimateMessagesTokens(messages);
|
|
58
|
+
assert.ok(wholeEstimate < policy.triggerTokens, 'fixture must reproduce local estimator undercount');
|
|
59
|
+
const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
|
|
60
|
+
const expectedPressure = 94_800 + estimateMessagesTokens([laterToolResult]);
|
|
61
|
+
assert.equal(pressure, expectedPressure, 'assistant output/reasoning must stay in actual usage, not be estimated again');
|
|
62
|
+
assert.ok(pressure >= 95_000, `actual usage plus later tool growth should cross trigger, got ${pressure}`);
|
|
63
|
+
assert.equal(shouldCompactForSession(wholeEstimate, policy, {
|
|
64
|
+
messages,
|
|
65
|
+
sessionRef: session,
|
|
66
|
+
pressureTokens: pressure,
|
|
67
|
+
}), true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('thinking-only continuation without assistant replay excludes provider output and estimates the nudge', () => {
|
|
71
|
+
const session = { provider: 'anthropic', contextWindow: 100_000, compaction: {} };
|
|
72
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
73
|
+
const messages = [{ role: 'user', content: 'request that returns thinking but no replayable assistant message' }];
|
|
74
|
+
recordProviderContextBaseline(session, messages, {
|
|
75
|
+
inputTokens: 5_000,
|
|
76
|
+
outputTokens: 2_000,
|
|
77
|
+
cachedTokens: 88_000,
|
|
78
|
+
cacheWriteTokens: 1_000,
|
|
79
|
+
}, { boundary: 'request' });
|
|
80
|
+
const nudge = {
|
|
81
|
+
role: 'user',
|
|
82
|
+
content: '[mixdog-runtime] Previous response was empty. Continue with a final answer or tool call.',
|
|
83
|
+
};
|
|
84
|
+
messages.push(nudge);
|
|
85
|
+
|
|
86
|
+
const wholeEstimate = estimateMessagesTokens(messages);
|
|
87
|
+
const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
|
|
88
|
+
const expectedPressure = 94_000 + estimateMessagesTokens([nudge]);
|
|
89
|
+
assert.equal(pressure, expectedPressure, 'unreplayed output must be removed while the later nudge is estimated');
|
|
90
|
+
assert.equal(shouldCompactForSession(wholeEstimate, policy, {
|
|
91
|
+
messages,
|
|
92
|
+
sessionRef: session,
|
|
93
|
+
pressureTokens: pressure,
|
|
94
|
+
}), false, 'unreplayed thinking output must not cause an early compact');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('successful compact invalidates stale usage and cannot immediately compact again', () => {
|
|
98
|
+
const session = { provider: 'openai', contextWindow: 100_000, compaction: {} };
|
|
99
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
100
|
+
const before = [{ role: 'user', content: 'old context' }];
|
|
101
|
+
recordProviderContextBaseline(session, before, { inputTokens: 99_000, outputTokens: 500 });
|
|
102
|
+
assert.equal(shouldCompactForSession(estimateMessagesTokens(before), policy, {
|
|
103
|
+
messages: before,
|
|
104
|
+
sessionRef: session,
|
|
105
|
+
}), true);
|
|
106
|
+
|
|
107
|
+
rememberCompactTelemetry(session, policy, {
|
|
108
|
+
compactChanged: true,
|
|
109
|
+
beforeTokens: 99_500,
|
|
110
|
+
afterTokens: 20,
|
|
111
|
+
pressureTokens: 99_500,
|
|
112
|
+
});
|
|
113
|
+
const compacted = [{ role: 'user', content: 'short summary' }];
|
|
114
|
+
const compactedEstimate = estimateMessagesTokens(compacted);
|
|
115
|
+
assert.equal(session.contextPressureBaselineTokens, null);
|
|
116
|
+
assert.equal(session.lastContextTokensStaleAfterCompact, true);
|
|
117
|
+
assert.equal(shouldCompactForSession(compactedEstimate, policy, {
|
|
118
|
+
messages: compacted,
|
|
119
|
+
sessionRef: session,
|
|
120
|
+
}), false, 'stale pre-compact usage must not trigger a consecutive compact');
|
|
121
|
+
|
|
122
|
+
recordProviderContextBaseline(session, compacted, { inputTokens: 10_000, outputTokens: 100 });
|
|
123
|
+
assert.equal(session.lastContextTokensStaleAfterCompact, false);
|
|
124
|
+
assert.equal(shouldCompactForSession(compactedEstimate, policy, {
|
|
125
|
+
messages: compacted,
|
|
126
|
+
sessionRef: session,
|
|
127
|
+
}), false, 'fresh post-compact usage may be reused safely');
|
|
128
|
+
});
|
|
@@ -55,14 +55,14 @@ function assert(condition, message) {
|
|
|
55
55
|
`sub-boundary explicit limit should be preserved, got ${meta.autoCompactTokenLimit}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack
|
|
59
|
-
//
|
|
58
|
+
// 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack keeps 5%
|
|
59
|
+
// headroom (95% trigger); a truly-explicit
|
|
60
60
|
// sub-boundary autoCompactTokenLimit still wins. Agent-owned semantic
|
|
61
61
|
// sessions keep the default 10% buffer (90%).
|
|
62
62
|
{
|
|
63
63
|
const boundary = 200000;
|
|
64
64
|
const legacy = compactTriggerForSession({ autoCompactTokenLimit: boundary, compaction: {} }, boundary);
|
|
65
|
-
assert(legacy ===
|
|
65
|
+
assert(legacy === 190000, `main/user boundary-equal limit should yield 95% trigger 190000, got ${legacy}`);
|
|
66
66
|
const explicit = compactTriggerForSession({ autoCompactTokenLimit: 150000, compaction: {} }, boundary);
|
|
67
67
|
assert(explicit === 150000, `sub-boundary explicit limit should be the trigger, got ${explicit}`);
|
|
68
68
|
const agent = compactTriggerForSession({ owner: 'agent', autoCompactTokenLimit: boundary, compaction: {} }, boundary);
|
|
@@ -72,19 +72,19 @@ function assert(condition, message) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// 4) A configured bufferPercent flows into the trigger for agent-owned sessions;
|
|
75
|
-
// main/user sessions
|
|
75
|
+
// main/user sessions use their fixed 5% headroom.
|
|
76
76
|
{
|
|
77
77
|
const boundary = 200000;
|
|
78
78
|
const agentTrigger = compactTriggerForSession({ owner: 'agent', compaction: { bufferPercent: 5 } }, boundary);
|
|
79
79
|
assert(agentTrigger === 190000, `agent bufferPercent 5 should yield trigger 190000, got ${agentTrigger}`);
|
|
80
80
|
const userTrigger = compactTriggerForSession({ compaction: { bufferPercent: 5 } }, boundary);
|
|
81
|
-
assert(userTrigger ===
|
|
81
|
+
assert(userTrigger === 190000, `main/user should keep 5% headroom and trigger at 190000, got ${userTrigger}`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// 5) Legacy/zero buffer telemetry migration is an agent-path concern (the
|
|
85
85
|
// default-buffer sanitizer). Agent-owned sessions reapply the current 10%
|
|
86
86
|
// default (trigger 180000); explicit bufferTokens still lowers the agent
|
|
87
|
-
// trigger. Main/user sessions ignore all of it and compact at
|
|
87
|
+
// trigger. Main/user sessions ignore all of it and compact at 95%.
|
|
88
88
|
{
|
|
89
89
|
const boundary = 200000;
|
|
90
90
|
const agentLegacy = compactTriggerForSession({
|
|
@@ -105,8 +105,8 @@ function assert(condition, message) {
|
|
|
105
105
|
const userTelemetry = compactTriggerForSession({
|
|
106
106
|
compaction: { boundaryTokens: boundary, triggerTokens: 180000, bufferTokens: 20000, bufferRatio: 0.1 },
|
|
107
107
|
}, boundary);
|
|
108
|
-
assert(userTelemetry ===
|
|
109
|
-
`main/user should ignore buffer telemetry and
|
|
108
|
+
assert(userTelemetry === 190000,
|
|
109
|
+
`main/user should ignore buffer telemetry and keep 5% headroom at 190000, got ${userTelemetry}`);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// 6) preserveBufferConfigFields copies only finite-positive percent/ratio fields.
|