mixdog 0.9.39 → 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.
Files changed (59) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-terminal-reap-test.mjs +6 -6
  3. package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
  4. package/scripts/compact-pressure-test.mjs +128 -0
  5. package/scripts/compact-trigger-migration-smoke.mjs +8 -8
  6. package/scripts/execution-resume-esc-integration-test.mjs +4 -2
  7. package/scripts/internal-comms-smoke.mjs +66 -25
  8. package/scripts/max-output-recovery-persist-test.mjs +81 -0
  9. package/scripts/max-output-recovery-test.mjs +222 -0
  10. package/scripts/provider-toolcall-test.mjs +32 -0
  11. package/scripts/steering-drain-buckets-test.mjs +140 -5
  12. package/scripts/tui-transcript-perf-test.mjs +279 -0
  13. package/src/agents/reviewer/AGENT.md +4 -0
  14. package/src/rules/lead/lead-brief.md +11 -3
  15. package/src/rules/lead/lead-tool.md +0 -2
  16. package/src/rules/shared/01-tool.md +4 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
  18. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
  20. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
  22. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
  25. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
  27. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
  28. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
  29. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +17 -1
  31. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +26 -0
  32. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
  33. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  34. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
  35. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
  36. package/src/runtime/agent/orchestrator/session/store.mjs +9 -1
  37. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  38. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +49 -34
  39. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
  41. package/src/runtime/shared/buffered-appender.mjs +13 -2
  42. package/src/session-runtime/config-helpers.mjs +6 -6
  43. package/src/session-runtime/lifecycle-api.mjs +4 -0
  44. package/src/session-runtime/runtime-core.mjs +1 -0
  45. package/src/session-runtime/session-turn-api.mjs +12 -0
  46. package/src/standalone/agent-tool.mjs +8 -1
  47. package/src/tui/App.jsx +2 -1
  48. package/src/tui/app/transcript-window.mjs +9 -10
  49. package/src/tui/app/use-transcript-window.mjs +38 -56
  50. package/src/tui/dist/index.mjs +354 -163
  51. package/src/tui/engine/agent-job-feed.mjs +76 -6
  52. package/src/tui/engine/session-api.mjs +7 -1
  53. package/src/tui/engine/session-flow.mjs +3 -1
  54. package/src/tui/engine/tool-card-results.mjs +10 -5
  55. package/src/tui/engine/turn.mjs +96 -36
  56. package/src/tui/engine.mjs +136 -37
  57. package/src/tui/index.jsx +2 -2
  58. package/src/workflows/bench/WORKFLOW.md +51 -27
  59. 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.39",
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",
@@ -33,16 +33,16 @@ try {
33
33
  AUTO_CLEAR_PROVIDER_IDLE_MS.anthropic,
34
34
  'global idleMs and default row are ignored for listed providers',
35
35
  );
36
- assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'default'), null, 'default row is ignored');
37
- assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unlisted'), null, 'unlisted override is ignored');
38
- assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unknown'), null, 'unknown provider is ignored');
36
+ assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'default'), 90_000, 'default row applies to default provider');
37
+ assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unlisted'), 90_000, 'default row applies to unlisted provider');
38
+ assert.equal(resolveAgentTerminalReapMs({ autoClear: {} }, 'unknown'), AUTO_CLEAR_PROVIDER_IDLE_MS.default, 'unknown provider uses built-in default');
39
39
 
40
40
  mkdirSync(join(root, 'sessions'), { recursive: true });
41
41
  writeFileSync(join(root, 'mixdog-config.json'), JSON.stringify({
42
42
  agent: { autoClear: overrideConfig.autoClear },
43
43
  }));
44
44
  const { sweepStaleSessions } = await import('../src/runtime/agent/orchestrator/session/store.mjs');
45
- const old = Date.now() - 61_000;
45
+ const old = Date.now() - 181_000;
46
46
  const known = {
47
47
  id: 'sess_known_reap',
48
48
  owner: 'agent',
@@ -81,7 +81,7 @@ try {
81
81
  'short provider override bypasses the default sweep freshness gate',
82
82
  );
83
83
  assert.ok(defaultSweep.details.some((detail) => detail.id === known.id), 'store reaps a listed provider at its Advanced duration');
84
- assert.ok(!defaultSweep.details.some((detail) => detail.id === unknown.id), 'store never sweeps an unlisted provider');
84
+ assert.ok(defaultSweep.details.some((detail) => detail.id === unknown.id), 'store reaps an unlisted provider at the default duration');
85
85
 
86
86
  const dataDir = join(root, 'worker-index');
87
87
  mkdirSync(dataDir, { recursive: true });
@@ -116,7 +116,7 @@ try {
116
116
  });
117
117
  const workers = agent.getStatus().workers;
118
118
  assert.ok(!workers.some((worker) => worker.tag === 'known'), 'worker row expires at the provider duration');
119
- assert.ok(workers.some((worker) => worker.tag === 'unknown'), 'unlisted worker row does not expire');
119
+ assert.ok(!workers.some((worker) => worker.tag === 'unknown'), 'unlisted worker row expires at the default duration');
120
120
  agent.closeAll('agent-terminal-reap-test');
121
121
 
122
122
  process.stdout.write(`agent terminal reap test passed (${builtIns.length} providers)\n`);
@@ -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 compacts
59
- // on the boundary itself (100%, no early-trigger buffer); a truly-explicit
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 === 200000, `main/user boundary-equal limit should yield 100% trigger 200000, got ${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 ignore the buffer and stay on the boundary (100%).
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 === 200000, `main/user should ignore bufferPercent and stay at 100% 200000, got ${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 100%.
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 === 200000,
109
- `main/user should ignore buffer telemetry and stay at 100% 200000, got ${userTelemetry}`);
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.
@@ -146,9 +146,10 @@ for (const phase of ['before first delta', 'after response progress']) {
146
146
  harness.activeAsk().options.onTextDelta('partial response\n');
147
147
  await wait(40);
148
148
  assert.equal(
149
- harness.state.items.some((item) => item.kind === 'assistant' && /partial response/.test(item.text)),
149
+ harness.state.streamingTail?.kind === 'assistant'
150
+ && /partial response/.test(harness.state.streamingTail.text),
150
151
  true,
151
- 'response progress reached the live turn',
152
+ 'response progress reached the dedicated live tail',
152
153
  );
153
154
  }
154
155
 
@@ -157,6 +158,7 @@ for (const phase of ['before first delta', 'after response progress']) {
157
158
  assert.equal(harness.state.busy, false, 'busy clears after the real abort unwind');
158
159
  assert.equal(harness.state.spinner, null, 'spinner clears after Esc');
159
160
  assert.equal(harness.state.thinking, null, 'thinking clears after Esc');
161
+ assert.equal(harness.state.streamingTail == null, true, 'Esc leaves no orphaned streaming tail');
160
162
 
161
163
  harness.deliver(completion('execution_A', 'duplicate retry'));
162
164
  await tick();