mixdog 0.9.41 → 0.9.43
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 +1 -1
- package/scripts/agent-tag-reuse-smoke.mjs +124 -10
- package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
- package/scripts/arg-guard-test.mjs +16 -0
- package/scripts/compact-pressure-test.mjs +256 -1
- package/scripts/internal-comms-bench.mjs +0 -1
- package/scripts/internal-comms-smoke.mjs +14 -32
- package/scripts/internal-tools-normalization-test.mjs +52 -0
- package/scripts/max-output-recovery-test.mjs +49 -0
- package/scripts/mcp-client-normalization-test.mjs +45 -0
- package/scripts/provider-toolcall-test.mjs +23 -0
- package/scripts/result-classification-test.mjs +75 -0
- package/scripts/smoke.mjs +1 -1
- package/scripts/submit-commandbusy-race-test.mjs +99 -7
- package/scripts/tui-transcript-perf-test.mjs +56 -1
- package/src/agents/reviewer/AGENT.md +4 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
- package/src/runtime/memory/tool-defs.mjs +4 -3
- package/src/runtime/shared/tool-surface.mjs +1 -2
- package/src/session-runtime/context-status.mjs +8 -2
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +202 -22
- package/src/tui/components/StatusLine.jsx +4 -26
- package/src/tui/dist/index.mjs +46 -31
- package/src/tui/engine/session-api.mjs +3 -0
- package/src/tui/engine/session-flow.mjs +19 -8
- package/src/tui/engine/turn.mjs +24 -2
- package/src/tui/engine.mjs +0 -1
- package/src/ui/statusline-agents.mjs +0 -8
- package/src/ui/statusline.mjs +2 -4
- package/src/workflows/default/WORKFLOW.md +29 -20
- package/src/workflows/solo-review/WORKFLOW.md +47 -0
- package/src/workflows/bench/WORKFLOW.md +0 -60
package/package.json
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// Validates the unified 4-tier spawn dispatch when an explicit tag is pinned:
|
|
5
5
|
// 1) live + idle -> spawn reuses the existing session (reused:true, send path)
|
|
6
6
|
// 2) live + busy -> spawn queues the prompt instead of throwing
|
|
7
|
-
// 3) lingering terminal trace (no live session) -> spawn/send error, no respawn
|
|
8
7
|
// 3) lingering terminal trace -> spawn reaps tag; dead-tag send auto-respawns
|
|
9
8
|
// 4) genuinely new tag -> a fresh spawn (no reused/respawned flag)
|
|
10
9
|
//
|
|
@@ -32,7 +31,7 @@ process.env.MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS = '50';
|
|
|
32
31
|
// is the authority; shorten only the test clock, not the configured duration.
|
|
33
32
|
process.env.MIXDOG_AGENT_TERMINAL_REAP_MS = '1000';
|
|
34
33
|
const nativeSetTimeout = globalThis.setTimeout;
|
|
35
|
-
globalThis.setTimeout = (callback, ms, ...args) => nativeSetTimeout(callback, ms === 60_000 ?
|
|
34
|
+
globalThis.setTimeout = (callback, ms, ...args) => nativeSetTimeout(callback, ms === 60_000 ? 1000 : ms, ...args);
|
|
36
35
|
|
|
37
36
|
const { createStandaloneAgent } = await import('../src/standalone/agent-tool.mjs');
|
|
38
37
|
|
|
@@ -119,7 +118,11 @@ async function waitJob(out, pattern, label, tries = 60, context = ctx) {
|
|
|
119
118
|
throw new Error(`${label} did not match ${pattern}: ${last}`);
|
|
120
119
|
}
|
|
121
120
|
function sessionForTag(tag) {
|
|
122
|
-
|
|
121
|
+
for (let i = askLog.length - 1; i >= 0; i -= 1) {
|
|
122
|
+
const session = getSession(askLog[i].sessionId);
|
|
123
|
+
if (session?.agentTag === tag && !session.closed) return session;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
123
126
|
}
|
|
124
127
|
function addPeerTerminalRow(tag, sessionId) {
|
|
125
128
|
const file = join(dataDir, 'agent-workers.json');
|
|
@@ -137,6 +140,40 @@ function addPeerTerminalRow(tag, sessionId) {
|
|
|
137
140
|
};
|
|
138
141
|
writeFileSync(file, JSON.stringify(index));
|
|
139
142
|
}
|
|
143
|
+
function addWorkerRow({
|
|
144
|
+
tag,
|
|
145
|
+
sessionId,
|
|
146
|
+
clientHostPid = ctx.clientHostPid,
|
|
147
|
+
status = 'idle',
|
|
148
|
+
updatedAt = new Date().toISOString(),
|
|
149
|
+
agentName = 'worker',
|
|
150
|
+
}) {
|
|
151
|
+
const file = join(dataDir, 'agent-workers.json');
|
|
152
|
+
const index = JSON.parse(readFileSync(file, 'utf8'));
|
|
153
|
+
index.workers[sessionId] = {
|
|
154
|
+
tag,
|
|
155
|
+
sessionId,
|
|
156
|
+
agent: agentName,
|
|
157
|
+
provider: 'openai-oauth',
|
|
158
|
+
status,
|
|
159
|
+
stage: status,
|
|
160
|
+
updatedAt,
|
|
161
|
+
clientHostPid,
|
|
162
|
+
cwd: root,
|
|
163
|
+
};
|
|
164
|
+
writeFileSync(file, JSON.stringify(index));
|
|
165
|
+
}
|
|
166
|
+
function addTombstones(rows) {
|
|
167
|
+
const file = join(dataDir, 'agent-workers.json');
|
|
168
|
+
const index = JSON.parse(readFileSync(file, 'utf8'));
|
|
169
|
+
index.tombstones ||= {};
|
|
170
|
+
for (const row of rows) index.tombstones[`${row.clientHostPid || 0}:${row.tag}`] = row;
|
|
171
|
+
writeFileSync(file, JSON.stringify(index));
|
|
172
|
+
}
|
|
173
|
+
function tombstonesForTag(tag) {
|
|
174
|
+
const index = JSON.parse(readFileSync(join(dataDir, 'agent-workers.json'), 'utf8'));
|
|
175
|
+
return Object.values(index.tombstones || {}).filter((row) => row.tag === tag);
|
|
176
|
+
}
|
|
140
177
|
function hasWorkerRow(sessionId) {
|
|
141
178
|
const index = JSON.parse(readFileSync(join(dataDir, 'agent-workers.json'), 'utf8'));
|
|
142
179
|
return Boolean(index.workers?.[sessionId]);
|
|
@@ -165,8 +202,9 @@ async function main() {
|
|
|
165
202
|
}, ctx);
|
|
166
203
|
assert(/agent task:/.test(firstOut), `first spawn must return a task: ${firstOut}`);
|
|
167
204
|
assert(!/reused: true/.test(firstOut) && !/respawned/.test(firstOut), `fresh tag must not be flagged reused/respawned: ${firstOut}`);
|
|
205
|
+
const firstSession = await waitSessionForTag('reviewerA', 'first spawn');
|
|
168
206
|
await waitJob(firstOut, /ack: first review pass/, 'first spawn');
|
|
169
|
-
const sid =
|
|
207
|
+
const sid = firstSession.id;
|
|
170
208
|
assert(sid, 'reviewerA session should remain live (idle) within the reap window');
|
|
171
209
|
|
|
172
210
|
// --- tier 1 (the headline bug): re-spawn the SAME tag while idle+live ---
|
|
@@ -204,6 +242,7 @@ async function main() {
|
|
|
204
242
|
type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'trace reap respawn', cwd: root,
|
|
205
243
|
}, ctx);
|
|
206
244
|
assert(!/^Error:/.test(traceSpawnOut), `terminal-trace spawn must reap and reuse tag: ${traceSpawnOut}`);
|
|
245
|
+
assert(/respawned: true/.test(traceSpawnOut), `terminal-trace spawn must flag lost context: ${traceSpawnOut}`);
|
|
207
246
|
await waitJob(traceSpawnOut, /ack: trace reap respawn/, 'trace reap spawn');
|
|
208
247
|
assert(hasWorkerRow('sess_peer_trace'), 'terminal-trace reap must preserve a peer terminal row with the same tag');
|
|
209
248
|
removeWorkerRow('sess_peer_trace');
|
|
@@ -225,26 +264,57 @@ async function main() {
|
|
|
225
264
|
await waitJob(timerPeerStart, /ack: timer peer seed/, 'timer peer seed');
|
|
226
265
|
const timerPeerSession = (await waitSessionForTag('timer-peer', 'timer peer seed')).id;
|
|
227
266
|
addPeerTerminalRow('timer-peer', 'sess_peer_timer');
|
|
228
|
-
await sleep(
|
|
267
|
+
await sleep(1200);
|
|
229
268
|
assert(getSession(timerPeerSession)?.closed, 'terminal timer must reap its local session');
|
|
230
269
|
assert(hasWorkerRow('sess_peer_timer'), 'terminal timer must preserve a peer row with the same tag');
|
|
231
270
|
|
|
232
|
-
// Fully reaped tags
|
|
233
|
-
//
|
|
271
|
+
// Fully reaped tags retain a terminal-scoped tombstone, so send inherits the
|
|
272
|
+
// prior agent/cwd without the caller having to reconstruct cold identity.
|
|
234
273
|
const coldStart = await agent.execute({
|
|
235
274
|
type: 'spawn', agent: 'worker', tag: 'cold-reaped', prompt: 'cold reap seed', cwd: root,
|
|
236
275
|
}, ctx);
|
|
237
276
|
await waitJob(coldStart, /ack: cold reap seed/, 'cold reap seed');
|
|
238
277
|
const coldSessionId = (await waitSessionForTag('cold-reaped', 'cold reap seed')).id;
|
|
239
|
-
await sleep(
|
|
278
|
+
await sleep(1700);
|
|
240
279
|
assert(!sessionForTag('cold-reaped') && getSession(coldSessionId)?.closed, 'terminal reaper must remove the cold-reaped session');
|
|
241
280
|
const coldSendOut = await agent.execute({
|
|
242
|
-
type: 'send',
|
|
281
|
+
type: 'send', tag: 'cold-reaped', message: 'cold reap replacement',
|
|
243
282
|
}, ctx);
|
|
244
283
|
assert(!/^Error:/.test(coldSendOut), `fully reaped tag must cold-respawn: ${coldSendOut}`);
|
|
245
284
|
assert(/respawned: true/.test(coldSendOut), `fully reaped send must flag respawned: ${coldSendOut}`);
|
|
246
285
|
await waitJob(coldSendOut, /ack: cold reap replacement/, 'cold reap replacement');
|
|
247
286
|
|
|
287
|
+
// Explicit spawn consumes the same tombstone form and also reports that the
|
|
288
|
+
// previous conversational context was lost.
|
|
289
|
+
const coldSpawnSeed = await agent.execute({
|
|
290
|
+
type: 'spawn', agent: 'reviewer', tag: 'cold-spawn-reaped', prompt: 'cold spawn seed', cwd: root,
|
|
291
|
+
}, ctx);
|
|
292
|
+
await waitJob(coldSpawnSeed, /ack: cold spawn seed/, 'cold spawn seed');
|
|
293
|
+
const coldSpawnSessionId = (await waitSessionForTag('cold-spawn-reaped', 'cold spawn seed')).id;
|
|
294
|
+
await sleep(1700);
|
|
295
|
+
assert(getSession(coldSpawnSessionId)?.closed, 'terminal reaper must close the cold-spawn seed');
|
|
296
|
+
const coldSpawnOut = await agent.execute({
|
|
297
|
+
type: 'spawn', tag: 'cold-spawn-reaped', prompt: 'cold spawn replacement',
|
|
298
|
+
}, ctx);
|
|
299
|
+
assert(!/^Error:/.test(coldSpawnOut), `tombstoned tag spawn must respawn: ${coldSpawnOut}`);
|
|
300
|
+
assert(/respawned: true/.test(coldSpawnOut), `tombstoned tag spawn must flag respawned: ${coldSpawnOut}`);
|
|
301
|
+
await waitJob(coldSpawnOut, /ack: cold spawn replacement/, 'cold spawn replacement');
|
|
302
|
+
|
|
303
|
+
// A stale running row with neither a live session nor a recent heartbeat is
|
|
304
|
+
// transitioned to a tombstone instead of blocking this tag forever.
|
|
305
|
+
addWorkerRow({
|
|
306
|
+
tag: 'stale-running',
|
|
307
|
+
sessionId: 'sess_stale_running',
|
|
308
|
+
status: 'running',
|
|
309
|
+
updatedAt: new Date(Date.now() - 120_000).toISOString(),
|
|
310
|
+
});
|
|
311
|
+
const staleRunningOut = await agent.execute({
|
|
312
|
+
type: 'spawn', tag: 'stale-running', prompt: 'stale running replacement',
|
|
313
|
+
}, ctx);
|
|
314
|
+
assert(!/^Error:/.test(staleRunningOut), `stale nonterminal row must transition: ${staleRunningOut}`);
|
|
315
|
+
assert(/respawned: true/.test(staleRunningOut), `stale nonterminal transition must flag respawned: ${staleRunningOut}`);
|
|
316
|
+
await waitJob(staleRunningOut, /ack: stale running replacement/, 'stale running replacement');
|
|
317
|
+
|
|
248
318
|
// A raw session id, typo without retained evidence/identity, and a peer tag
|
|
249
319
|
// must never turn into a local cold spawn.
|
|
250
320
|
const rawSessionOut = await agent.execute({
|
|
@@ -264,6 +334,47 @@ async function main() {
|
|
|
264
334
|
}, ctx);
|
|
265
335
|
assert(/^Error:/.test(peerOut), `peer-terminal tag must remain an error: ${peerOut}`);
|
|
266
336
|
|
|
337
|
+
// Let the peer session become a peer-owned tombstone. Neither normal nor
|
|
338
|
+
// allTerminals dispatch may inherit that terminal identity.
|
|
339
|
+
await sleep(1700);
|
|
340
|
+
assert(tombstonesForTag('peer-owned').some((row) => row.clientHostPid === peerCtx.clientHostPid), 'peer-owned tombstone must exist');
|
|
341
|
+
const peerTombstoneSend = await agent.execute({
|
|
342
|
+
type: 'send', tag: 'peer-owned', message: 'must not absorb peer tombstone', allTerminals: true,
|
|
343
|
+
}, ctx);
|
|
344
|
+
assert(/^Error:/.test(peerTombstoneSend), `allTerminals send must reject peer tombstone: ${peerTombstoneSend}`);
|
|
345
|
+
const peerTombstoneSpawn = await agent.execute({
|
|
346
|
+
type: 'spawn', tag: 'peer-owned', prompt: 'must not inherit peer spawn', allTerminals: true,
|
|
347
|
+
}, ctx);
|
|
348
|
+
assert(/^Error:/.test(peerTombstoneSpawn), `allTerminals spawn must reject peer tombstone inheritance: ${peerTombstoneSpawn}`);
|
|
349
|
+
|
|
350
|
+
// Local evidence wins when a peer tombstone with the same tag coexists.
|
|
351
|
+
addTombstones([
|
|
352
|
+
{ tag: 'coowned', agent: 'reviewer', cwd: root, clientHostPid: ctx.clientHostPid, reapedAt: new Date().toISOString() },
|
|
353
|
+
{ tag: 'coowned', agent: 'worker', cwd: root, clientHostPid: peerCtx.clientHostPid, reapedAt: new Date().toISOString() },
|
|
354
|
+
]);
|
|
355
|
+
const coownedOut = await agent.execute({
|
|
356
|
+
type: 'send', tag: 'coowned', message: 'prefer local tombstone', allTerminals: true,
|
|
357
|
+
}, ctx);
|
|
358
|
+
assert(!/^Error:/.test(coownedOut), `local tombstone must beat coexisting peer: ${coownedOut}`);
|
|
359
|
+
assert(/respawned: true/.test(coownedOut), `coexisting local tombstone must respawn: ${coownedOut}`);
|
|
360
|
+
await waitJob(coownedOut, /ack: prefer local tombstone/, 'coowned local tombstone');
|
|
361
|
+
|
|
362
|
+
// Fill the cap with future-dated entries, then let a real reap write one more.
|
|
363
|
+
// The current write must survive clamping/pruning.
|
|
364
|
+
const pruneSeed = await agent.execute({
|
|
365
|
+
type: 'spawn', agent: 'worker', tag: 'prune-current', prompt: 'prune seed', cwd: root,
|
|
366
|
+
}, ctx);
|
|
367
|
+
await waitJob(pruneSeed, /ack: prune seed/, 'prune seed');
|
|
368
|
+
addTombstones(Array.from({ length: 500 }, (_, i) => ({
|
|
369
|
+
tag: `future-${i}`,
|
|
370
|
+
agent: 'worker',
|
|
371
|
+
cwd: root,
|
|
372
|
+
clientHostPid: ctx.clientHostPid,
|
|
373
|
+
reapedAt: new Date(Date.now() + 86_400_000 + i).toISOString(),
|
|
374
|
+
})));
|
|
375
|
+
await sleep(1700);
|
|
376
|
+
assert(tombstonesForTag('prune-current').length === 1, 'prune cap must retain the tombstone written this call');
|
|
377
|
+
|
|
267
378
|
// agent close clears the trace; same tag can spawn fresh again
|
|
268
379
|
addPeerTerminalRow('reviewerA', 'sess_peer_close');
|
|
269
380
|
await agent.execute({ type: 'close', tag: 'reviewerA' }, ctx);
|
|
@@ -306,7 +417,10 @@ async function main() {
|
|
|
306
417
|
removeWorkerRow(staleSessionId);
|
|
307
418
|
addPeerTerminalRow('fallback-peer', 'sess_peer_fallback');
|
|
308
419
|
const fallbackClose = await agent.execute({ type: 'close', tag: 'fallback-peer' }, ctx);
|
|
309
|
-
assert(
|
|
420
|
+
assert(
|
|
421
|
+
/forgotten: true|target "fallback-peer" not found/.test(fallbackClose),
|
|
422
|
+
`stale close fallback must forget locally or report no local target: ${fallbackClose}`,
|
|
423
|
+
);
|
|
310
424
|
assert(hasWorkerRow('sess_peer_fallback'), 'stale close fallback must preserve a peer terminal row with the same tag');
|
|
311
425
|
removeWorkerRow('sess_peer_fallback');
|
|
312
426
|
const fallbackFresh = await agent.execute({
|
|
@@ -25,6 +25,7 @@ const { AnthropicOAuthProvider } = await import(
|
|
|
25
25
|
'../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs'
|
|
26
26
|
);
|
|
27
27
|
const {
|
|
28
|
+
DEFAULT_CLI_VERSION,
|
|
28
29
|
_saveCredentialsFile,
|
|
29
30
|
loadCredentials,
|
|
30
31
|
loadCredentialsFromPath,
|
|
@@ -48,6 +49,64 @@ function writeCredentials({ accessToken, refreshToken, expiresAt }, path = crede
|
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
test('refresh uses the current CLI identity and returns a neutral sanitized 403', async () => {
|
|
53
|
+
const accessToken = 'fixture-access-must-not-leak';
|
|
54
|
+
const refreshToken = 'fixture-refresh-must-not-leak';
|
|
55
|
+
writeCredentials({
|
|
56
|
+
accessToken,
|
|
57
|
+
refreshToken,
|
|
58
|
+
expiresAt: Date.now() + 1_000,
|
|
59
|
+
});
|
|
60
|
+
const before = readFileSync(credentialsPath, 'utf8');
|
|
61
|
+
const originalFetch = globalThis.fetch;
|
|
62
|
+
let request = null;
|
|
63
|
+
globalThis.fetch = async (url, options) => {
|
|
64
|
+
request = { url, options };
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
status: 403,
|
|
68
|
+
async text() {
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
error: 'forbidden',
|
|
71
|
+
accessToken,
|
|
72
|
+
refresh_token: refreshToken,
|
|
73
|
+
echoed: `Bearer ${accessToken}`,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
try {
|
|
79
|
+
assert.equal(DEFAULT_CLI_VERSION, '2.1.207');
|
|
80
|
+
await assert.rejects(
|
|
81
|
+
refreshOAuthCredentials(loadCredentials()),
|
|
82
|
+
(err) => {
|
|
83
|
+
assert.match(err.message, /token refresh 403/);
|
|
84
|
+
assert.match(err.message, /claude-cli\/2\.1\.207/);
|
|
85
|
+
assert.match(err.message, /Possible causes include OAuth client compatibility/);
|
|
86
|
+
assert.match(err.message, /account or scope policy/);
|
|
87
|
+
assert.match(err.message, /proxy\/VPN\/WAF/);
|
|
88
|
+
assert.match(err.message, /regional endpoint restrictions/);
|
|
89
|
+
assert.match(err.message, /Verify the account has Claude Code access and the required scopes/);
|
|
90
|
+
assert.match(err.message, /MIXDOG_CLI_VERSION/);
|
|
91
|
+
assert.match(err.message, /retry sign-in via \/providers/);
|
|
92
|
+
assert.doesNotMatch(err.message, /rejected the OAuth client identity/);
|
|
93
|
+
assert.doesNotMatch(err.message, /fixture-access-must-not-leak/);
|
|
94
|
+
assert.doesNotMatch(err.message, /fixture-refresh-must-not-leak/);
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
assert.equal(
|
|
99
|
+
request.options.headers['user-agent'],
|
|
100
|
+
'claude-cli/2.1.207 (external, sdk-cli)',
|
|
101
|
+
);
|
|
102
|
+
assert.equal(request.options.redirect, 'error');
|
|
103
|
+
assert.equal(JSON.parse(request.options.body).refresh_token, refreshToken);
|
|
104
|
+
assert.equal(readFileSync(credentialsPath, 'utf8'), before);
|
|
105
|
+
} finally {
|
|
106
|
+
globalThis.fetch = originalFetch;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
51
110
|
test('parallel host preflights refresh once and snapshot the leased generation', async () => {
|
|
52
111
|
writeCredentials({
|
|
53
112
|
accessToken: 'fixture-access-old',
|
|
@@ -75,3 +75,19 @@ test('below-min (negative) still errors', () => {
|
|
|
75
75
|
test('fractional numeric string is not coerced (still errors)', () => {
|
|
76
76
|
assert.match(validateBuiltinArgs('read', { path: 'x.mjs', limit: '3.5' }), /must be an integer/);
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
test('grep pattern and glob-filter numeric values coerce to strings', () => {
|
|
80
|
+
const pattern = { pattern: 123 };
|
|
81
|
+
assert.equal(validateBuiltinArgs('grep', pattern), null);
|
|
82
|
+
assert.equal(pattern.pattern, '123');
|
|
83
|
+
|
|
84
|
+
const patternArray = { pattern: [123, 'a'] };
|
|
85
|
+
assert.equal(validateBuiltinArgs('grep', patternArray), null);
|
|
86
|
+
assert.deepEqual(patternArray.pattern, ['123', 'a']);
|
|
87
|
+
|
|
88
|
+
const glob = { pattern: 'x', glob: 123 };
|
|
89
|
+
assert.equal(validateBuiltinArgs('grep', glob), null);
|
|
90
|
+
assert.equal(glob.glob, '123');
|
|
91
|
+
|
|
92
|
+
assert.match(validateBuiltinArgs('grep', { pattern: {} }), /must be string or string\[\]/);
|
|
93
|
+
});
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
estimateMessagesTokens,
|
|
5
|
+
estimateRequestReserveTokens,
|
|
6
|
+
estimateToolSchemaTokens,
|
|
7
|
+
estimateTokens,
|
|
8
|
+
IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
9
|
+
summarizeContextMessages,
|
|
10
|
+
} from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
11
|
+
import { createContextStatus } from '../src/session-runtime/context-status.mjs';
|
|
4
12
|
import {
|
|
5
13
|
compactionTelemetryPressureTokens,
|
|
6
14
|
recordProviderContextBaseline,
|
|
15
|
+
resolveCompactionPressureTokens,
|
|
7
16
|
rememberCompactTelemetry,
|
|
8
17
|
resolveWorkerCompactPolicy,
|
|
9
18
|
shouldCompactForSession,
|
|
@@ -26,6 +35,252 @@ test('Lead/main auto-compaction triggers at 95% of the effective boundary', () =
|
|
|
26
35
|
assert.equal(agentPolicy.triggerTokens, 90_000, 'agent default 10% headroom must remain unchanged');
|
|
27
36
|
});
|
|
28
37
|
|
|
38
|
+
test('image payload bytes do not inflate live gauge or fallback compaction estimates', () => {
|
|
39
|
+
const text = 'Please describe the attached image.';
|
|
40
|
+
const imageData = 'A'.repeat(400_000);
|
|
41
|
+
const imagePart = { type: 'image', data: imageData, mimeType: 'image/png' };
|
|
42
|
+
const messages = [{
|
|
43
|
+
role: 'user',
|
|
44
|
+
content: [{ type: 'text', text }, imagePart],
|
|
45
|
+
}];
|
|
46
|
+
const plainTextEstimate = estimateTokens(text) + 4;
|
|
47
|
+
const fallbackEstimate = estimateMessagesTokens(messages);
|
|
48
|
+
const summaryEstimate = summarizeContextMessages(messages).estimatedTokens;
|
|
49
|
+
|
|
50
|
+
assert.equal(estimateMessagesTokens([{ role: 'user', content: text }]), plainTextEstimate,
|
|
51
|
+
'ordinary string estimates must remain unchanged');
|
|
52
|
+
assert.equal(fallbackEstimate, plainTextEstimate + IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
53
|
+
'fallback compaction estimate must count text plus a visual image allowance');
|
|
54
|
+
const largerPayloadEstimate = estimateMessagesTokens([{
|
|
55
|
+
role: 'user',
|
|
56
|
+
content: [{ type: 'text', text }, { ...imagePart, data: imageData.repeat(2) }],
|
|
57
|
+
}]);
|
|
58
|
+
assert.equal(largerPayloadEstimate, fallbackEstimate,
|
|
59
|
+
'raw image byte length must not affect the visual allowance');
|
|
60
|
+
assert.equal(summaryEstimate, fallbackEstimate,
|
|
61
|
+
'display and fallback compaction must share the image-aware message estimate');
|
|
62
|
+
|
|
63
|
+
const session = {
|
|
64
|
+
id: 'image-context-test',
|
|
65
|
+
provider: 'openai',
|
|
66
|
+
contextWindow: 100_000,
|
|
67
|
+
rawContextWindow: 100_000,
|
|
68
|
+
compactBoundaryTokens: 100_000,
|
|
69
|
+
messages: [],
|
|
70
|
+
liveTurnMessages: messages,
|
|
71
|
+
tools: [],
|
|
72
|
+
compaction: {},
|
|
73
|
+
};
|
|
74
|
+
const { contextStatus } = createContextStatus({
|
|
75
|
+
getSession: () => session,
|
|
76
|
+
getRoute: () => ({ provider: 'openai', model: 'test-model' }),
|
|
77
|
+
getCurrentCwd: () => '',
|
|
78
|
+
getMode: () => 'default',
|
|
79
|
+
});
|
|
80
|
+
const status = contextStatus();
|
|
81
|
+
assert.ok(status.usedTokens < status.compaction.triggerTokens,
|
|
82
|
+
`image-aware live gauge must remain below compaction threshold, got ${status.usedTokens}`);
|
|
83
|
+
assert.ok(status.usedTokens < status.contextWindow,
|
|
84
|
+
`raw base64 must not pin the live gauge at 100%, got ${status.usedTokens}/${status.contextWindow}`);
|
|
85
|
+
|
|
86
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000, outputTokens: 0 });
|
|
87
|
+
const policy = { ...policyFor(session), reserveTokens: 0 };
|
|
88
|
+
assert.equal(
|
|
89
|
+
shouldCompactForSession(fallbackEstimate, policy, { messages, sessionRef: session }),
|
|
90
|
+
false,
|
|
91
|
+
'provider-backed pressure below threshold must remain below threshold',
|
|
92
|
+
);
|
|
93
|
+
assert.equal(imagePart.data, imageData,
|
|
94
|
+
'estimating live context must not sanitize or remove image content');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('dense ASCII, encoded, and minified payloads do not retain the prose chars/4 estimate', () => {
|
|
98
|
+
for (const text of [
|
|
99
|
+
'A1b2C3d4E5f6G7h8'.repeat(100),
|
|
100
|
+
'%7B%22path%22%3A%22very%2Flong%2Fencoded%2Fvalue%22%7D'.repeat(40),
|
|
101
|
+
JSON.stringify({ rows: Array.from({ length: 100 }, (_, i) => ({ id: i, value: `item_${i}_abcdef` })) }),
|
|
102
|
+
]) {
|
|
103
|
+
assert.ok(estimateTokens(text) >= Math.ceil(text.length * 0.7),
|
|
104
|
+
`dense ASCII estimate must be conservative (${estimateTokens(text)}/${text.length})`);
|
|
105
|
+
}
|
|
106
|
+
const shortJsonl = Array.from({ length: 200 }, (_, i) => `{"i":${i}}`).join('\n');
|
|
107
|
+
assert.ok(estimateTokens(shortJsonl) >= shortJsonl.replace(/\s/g, '').length * 0.7,
|
|
108
|
+
'short JSONL records must receive the structured multiline floor');
|
|
109
|
+
const spacedShortIdentifiers = 'A1b2C3d4E5 F6G7H8I9J0 '.repeat(100);
|
|
110
|
+
assert.ok(estimateTokens(spacedShortIdentifiers) >= spacedShortIdentifiers.length * 0.7,
|
|
111
|
+
'space-separated encoded identifiers below the long-run threshold must receive the dense floor');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('assistantBlocks and reasoningItems count provider-visible data but not image bytes', () => {
|
|
115
|
+
const base = { role: 'assistant', content: '' };
|
|
116
|
+
const assistantBlocks = [
|
|
117
|
+
{ type: 'text', text: 'native assistant replay' },
|
|
118
|
+
{ type: 'tool_use', id: 'call_native', name: 'read', input: '{"path":"x"}' },
|
|
119
|
+
{ type: 'image', data: 'Z'.repeat(300_000), mimeType: 'image/png' },
|
|
120
|
+
];
|
|
121
|
+
const reasoningItems = [{ type: 'reasoning', encrypted_content: 'enc_'.repeat(2_000) }];
|
|
122
|
+
const estimated = estimateMessagesTokens([{ ...base, assistantBlocks, reasoningItems }]);
|
|
123
|
+
const noNativeReplay = estimateMessagesTokens([base]);
|
|
124
|
+
assert.ok(estimated > noNativeReplay + IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
125
|
+
'native assistant blocks and opaque reasoning must contribute tokens');
|
|
126
|
+
assert.equal(
|
|
127
|
+
estimateMessagesTokens([{ ...base, assistantBlocks: assistantBlocks.map(block => (
|
|
128
|
+
block.type === 'image' ? { ...block, data: 'Z'.repeat(600_000) } : block
|
|
129
|
+
)), reasoningItems }]),
|
|
130
|
+
estimated,
|
|
131
|
+
'native replay image bytes must not be tokenized',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('image allowance handles identity, detail, dimensions, and multiple accepted forms', () => {
|
|
136
|
+
const low = { type: 'image_url', image_url: { url: 'https://example.test/a.png', detail: 'low' } };
|
|
137
|
+
const tiled = {
|
|
138
|
+
type: 'input_image',
|
|
139
|
+
image_url: { url: 'https://example.test/b.png', detail: 'high' },
|
|
140
|
+
dimensions: { width: 2_048, height: 1_024 },
|
|
141
|
+
};
|
|
142
|
+
const inline = { inlineData: { data: 'A'.repeat(1_000), mimeType: 'image/png' }, width: 512, height: 512 };
|
|
143
|
+
const lowEstimate = estimateMessagesTokens([{ role: 'user', content: [low] }]);
|
|
144
|
+
const tiledEstimate = estimateMessagesTokens([{ role: 'user', content: [tiled] }]);
|
|
145
|
+
const multiEstimate = estimateMessagesTokens([{ role: 'user', content: [low, tiled, inline] }]);
|
|
146
|
+
assert.ok(tiledEstimate > lowEstimate, 'high-detail multi-tile image must reserve more than low detail');
|
|
147
|
+
assert.ok(lowEstimate >= IMAGE_VISUAL_TOKEN_ALLOWANCE + 4,
|
|
148
|
+
'unverified low-detail metadata must not reduce the conservative image fallback');
|
|
149
|
+
const unverifiedSmallDimensions = estimateMessagesTokens([{
|
|
150
|
+
role: 'user',
|
|
151
|
+
content: [{ type: 'image', data: 'A'.repeat(1_000), width: 1, height: 1, detail: 'low' }],
|
|
152
|
+
}]);
|
|
153
|
+
assert.ok(unverifiedSmallDimensions >= IMAGE_VISUAL_TOKEN_ALLOWANCE + 4,
|
|
154
|
+
'unverified tiny dimensions must not reduce the conservative image fallback');
|
|
155
|
+
assert.ok(multiEstimate > tiledEstimate + lowEstimate, 'every accepted image form must add visual allowance');
|
|
156
|
+
|
|
157
|
+
const session = { provider: 'openai', model: 'm', tools: [], contextWindow: 100_000, compaction: {} };
|
|
158
|
+
const messages = [{ role: 'user', content: [{ type: 'image', data: 'A'.repeat(1_000), mimeType: 'image/png' }] }];
|
|
159
|
+
const policy = policyFor(session);
|
|
160
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000 }, { sendTools: [] });
|
|
161
|
+
messages[0].content[0].data = 'B'.repeat(1_000);
|
|
162
|
+
assert.equal(
|
|
163
|
+
resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: session }),
|
|
164
|
+
estimateMessagesTokens(messages) + policy.reserveTokens,
|
|
165
|
+
'same-size image replacement must invalidate the measured prefix',
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('tool reserve and status cache detect nested schema mutation', () => {
|
|
170
|
+
const tools = [{ name: 'read', parameters: { type: 'object', properties: { path: { type: 'string' } } } }];
|
|
171
|
+
const beforeSchema = estimateToolSchemaTokens(tools);
|
|
172
|
+
const beforeReserve = estimateRequestReserveTokens(tools);
|
|
173
|
+
tools[0].parameters.properties.path.description = 'dense_nested_schema_description_'.repeat(200);
|
|
174
|
+
assert.ok(estimateToolSchemaTokens(tools) > beforeSchema);
|
|
175
|
+
assert.ok(estimateRequestReserveTokens(tools) > beforeReserve);
|
|
176
|
+
|
|
177
|
+
const session = { id: 'tool-cache', contextWindow: 100_000, messages: [{ role: 'user', content: 'x' }], tools, compaction: {} };
|
|
178
|
+
const { contextStatus } = createContextStatus({
|
|
179
|
+
getSession: () => session,
|
|
180
|
+
getRoute: () => ({ provider: 'openai', model: 'm' }),
|
|
181
|
+
getCurrentCwd: () => '',
|
|
182
|
+
getMode: () => 'default',
|
|
183
|
+
});
|
|
184
|
+
const statusBefore = contextStatus();
|
|
185
|
+
tools[0].parameters.properties.path.description += 'more_'.repeat(500);
|
|
186
|
+
const statusAfter = contextStatus();
|
|
187
|
+
assert.notEqual(statusAfter, statusBefore);
|
|
188
|
+
assert.ok(statusAfter.request.toolSchemaTokens > statusBefore.request.toolSchemaTokens);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('native replay fingerprint detects encrypted reasoning and assistant metadata mutation', () => {
|
|
192
|
+
const message = {
|
|
193
|
+
role: 'assistant',
|
|
194
|
+
content: '',
|
|
195
|
+
reasoningItems: [{ type: 'reasoning', encrypted_content: 'short' }],
|
|
196
|
+
assistantBlocks: [{ type: 'tool_use', id: 'c1', name: 'read', input: { path: 'a' }, cache_control: { type: 'ephemeral' } }],
|
|
197
|
+
};
|
|
198
|
+
const messages = [message];
|
|
199
|
+
const first = summarizeContextMessages(messages).estimatedTokens;
|
|
200
|
+
message.reasoningItems[0].encrypted_content = 'encrypted_'.repeat(1_000);
|
|
201
|
+
const second = summarizeContextMessages(messages).estimatedTokens;
|
|
202
|
+
assert.ok(second > first, 'in-place encrypted_content mutation must invalidate the message memo');
|
|
203
|
+
message.assistantBlocks[0].cache_control.mode = 'metadata_'.repeat(1_000);
|
|
204
|
+
const third = summarizeContextMessages(messages).estimatedTokens;
|
|
205
|
+
assert.ok(third > second, 'in-place assistant-block metadata mutation must invalidate the message memo');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('provider baseline pressure includes only the configured extra reserve', () => {
|
|
209
|
+
const session = { provider: 'openai', model: 'm', tools: [], contextWindow: 100_000, compaction: {} };
|
|
210
|
+
const messages = [{ role: 'user', content: 'baseline' }];
|
|
211
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000, outputTokens: 0 });
|
|
212
|
+
const policy = { ...policyFor(session), reserveTokens: 7_512, requestReserveTokens: 512, configuredReserveTokens: 7_000 };
|
|
213
|
+
assert.equal(resolveCompactionPressureTokens(1, policy, { messages, sessionRef: session }), 87_000);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('provider baselines fingerprint actual sendTools and reject provider, model, tool-schema, and prefix changes', () => {
|
|
217
|
+
const make = () => ({
|
|
218
|
+
provider: 'openai',
|
|
219
|
+
model: 'm1',
|
|
220
|
+
tools: [{ name: 'read', parameters: { type: 'object' } }],
|
|
221
|
+
contextWindow: 100_000,
|
|
222
|
+
compaction: {},
|
|
223
|
+
});
|
|
224
|
+
const fallbackForMutation = (mutate) => {
|
|
225
|
+
const session = make();
|
|
226
|
+
const messages = [{ role: 'user', content: 'original prefix' }];
|
|
227
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000, outputTokens: 0 }, { sendTools: session.tools });
|
|
228
|
+
mutate(session, messages);
|
|
229
|
+
const policy = { ...resolveWorkerCompactPolicy(session, session.tools), reserveTokens: 0 };
|
|
230
|
+
return {
|
|
231
|
+
pressure: resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: session }),
|
|
232
|
+
fallback: estimateMessagesTokens(messages),
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
for (const mutate of [
|
|
236
|
+
session => { session.provider = 'anthropic'; },
|
|
237
|
+
session => { session.model = 'm2'; },
|
|
238
|
+
session => { session.tools[0].parameters.properties = { path: { type: 'string' } }; },
|
|
239
|
+
(_session, messages) => { messages[0].content = 'mutated earlier prefix'; },
|
|
240
|
+
]) {
|
|
241
|
+
const result = fallbackForMutation(mutate);
|
|
242
|
+
assert.equal(result.pressure, result.fallback);
|
|
243
|
+
}
|
|
244
|
+
const session = make();
|
|
245
|
+
const messages = [{ role: 'user', content: 'forced tool request' }];
|
|
246
|
+
const actualSendTools = [session.tools[0]];
|
|
247
|
+
const matchingPolicy = resolveWorkerCompactPolicy(session, actualSendTools);
|
|
248
|
+
recordProviderContextBaseline(session, messages, { inputTokens: 80_000 }, { sendTools: actualSendTools });
|
|
249
|
+
session.tools = [{ name: 'different-session-tool', parameters: { type: 'object' } }];
|
|
250
|
+
assert.equal(
|
|
251
|
+
resolveCompactionPressureTokens(1, matchingPolicy, { messages, sessionRef: session }),
|
|
252
|
+
80_000,
|
|
253
|
+
'baseline must remain aligned to actual sendTools rather than mutable sessionRef.tools',
|
|
254
|
+
);
|
|
255
|
+
const changedSendPolicy = resolveWorkerCompactPolicy(session, session.tools);
|
|
256
|
+
assert.notEqual(
|
|
257
|
+
resolveCompactionPressureTokens(1, changedSendPolicy, { messages, sessionRef: session }),
|
|
258
|
+
80_000,
|
|
259
|
+
'a different next-send schema must invalidate the baseline',
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('context status cache notices mutation of an earlier message', () => {
|
|
264
|
+
const session = {
|
|
265
|
+
id: 'status-mutation',
|
|
266
|
+
contextWindow: 100_000,
|
|
267
|
+
messages: [{ role: 'user', content: 'short' }, { role: 'assistant', content: 'tail' }],
|
|
268
|
+
tools: [],
|
|
269
|
+
compaction: {},
|
|
270
|
+
};
|
|
271
|
+
const { contextStatus } = createContextStatus({
|
|
272
|
+
getSession: () => session,
|
|
273
|
+
getRoute: () => ({ provider: 'openai', model: 'm' }),
|
|
274
|
+
getCurrentCwd: () => '',
|
|
275
|
+
getMode: () => 'default',
|
|
276
|
+
});
|
|
277
|
+
const before = contextStatus();
|
|
278
|
+
session.messages[0].content = 'dense_earlier_message_'.repeat(1_000);
|
|
279
|
+
const after = contextStatus();
|
|
280
|
+
assert.notEqual(after, before);
|
|
281
|
+
assert.ok(after.usedTokens > before.usedTokens);
|
|
282
|
+
});
|
|
283
|
+
|
|
29
284
|
test('provider callback usage counts assistant output once and estimates only later tool results', () => {
|
|
30
285
|
const session = { provider: 'anthropic', contextWindow: 100_000, compaction: {} };
|
|
31
286
|
const policy = { ...policyFor(session), reserveTokens: 0 };
|