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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +124 -10
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +59 -0
  4. package/scripts/arg-guard-test.mjs +16 -0
  5. package/scripts/compact-pressure-test.mjs +256 -1
  6. package/scripts/internal-comms-bench.mjs +0 -1
  7. package/scripts/internal-comms-smoke.mjs +14 -32
  8. package/scripts/internal-tools-normalization-test.mjs +52 -0
  9. package/scripts/max-output-recovery-test.mjs +49 -0
  10. package/scripts/mcp-client-normalization-test.mjs +45 -0
  11. package/scripts/provider-toolcall-test.mjs +23 -0
  12. package/scripts/result-classification-test.mjs +75 -0
  13. package/scripts/smoke.mjs +1 -1
  14. package/scripts/submit-commandbusy-race-test.mjs +99 -7
  15. package/scripts/tui-transcript-perf-test.mjs +56 -1
  16. package/src/agents/reviewer/AGENT.md +4 -0
  17. package/src/runtime/agent/orchestrator/internal-tools.mjs +16 -3
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +17 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +49 -6
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +14 -0
  21. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +59 -2
  22. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  23. package/src/runtime/agent/orchestrator/session/context-utils.mjs +140 -81
  24. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +23 -5
  25. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +3 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -3
  27. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/session/result-classification.mjs +4 -1
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +7 -2
  30. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +8 -6
  31. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  33. package/src/runtime/memory/tool-defs.mjs +4 -3
  34. package/src/runtime/shared/tool-surface.mjs +1 -2
  35. package/src/session-runtime/context-status.mjs +8 -2
  36. package/src/standalone/agent-tool/render.mjs +2 -0
  37. package/src/standalone/agent-tool.mjs +202 -22
  38. package/src/tui/components/StatusLine.jsx +4 -26
  39. package/src/tui/dist/index.mjs +46 -31
  40. package/src/tui/engine/session-api.mjs +3 -0
  41. package/src/tui/engine/session-flow.mjs +19 -8
  42. package/src/tui/engine/turn.mjs +24 -2
  43. package/src/tui/engine.mjs +0 -1
  44. package/src/ui/statusline-agents.mjs +0 -8
  45. package/src/ui/statusline.mjs +2 -4
  46. package/src/workflows/default/WORKFLOW.md +29 -20
  47. package/src/workflows/solo-review/WORKFLOW.md +47 -0
  48. package/src/workflows/bench/WORKFLOW.md +0 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.41",
3
+ "version": "0.9.43",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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 ? 500 : ms, ...args);
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
- return listSessions().find((s) => s?.agentTag === tag && !s.closed) || null;
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 = (await waitSessionForTag('reviewerA', 'first spawn')).id;
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(700);
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 have no in-memory map or persisted row. Explicit
233
- // cold-spawn identity is enough to create a replacement.
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(1200);
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', agent: 'worker', tag: 'cold-reaped', message: 'cold reap replacement', cwd: root,
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(/forgotten: true/.test(fallbackClose), `stale close fallback must stay forgotten:true: ${fallbackClose}`);
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 { estimateMessagesTokens } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
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 };
@@ -38,7 +38,6 @@ const RULE_FILES = [
38
38
  'agents/debugger/AGENT.md',
39
39
  'workflows/default/WORKFLOW.md',
40
40
  'workflows/solo/WORKFLOW.md',
41
- 'workflows/bench/WORKFLOW.md',
42
41
  'rules/lead/01-general.md',
43
42
  'rules/lead/02-channels.md',
44
43
  'rules/lead/lead-tool.md',