mixdog 0.9.52 → 0.9.54

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 (124) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/bench-run.mjs +2 -2
  5. package/scripts/compact-pressure-test.mjs +104 -0
  6. package/scripts/desktop-session-bridge-test.mjs +704 -0
  7. package/scripts/freevar-smoke.mjs +7 -4
  8. package/scripts/gemini-provider-test.mjs +396 -1
  9. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  10. package/scripts/lifecycle-api-test.mjs +65 -4
  11. package/scripts/max-output-recovery-test.mjs +31 -0
  12. package/scripts/memory-core-input-test.mjs +10 -0
  13. package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
  14. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  15. package/scripts/parent-abort-link-test.mjs +24 -0
  16. package/scripts/process-lifecycle-test.mjs +91 -22
  17. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  18. package/scripts/provider-contract-test.mjs +326 -11
  19. package/scripts/provider-toolcall-test.mjs +269 -27
  20. package/scripts/session-orphan-sweep-test.mjs +27 -1
  21. package/src/lib/keychain-cjs.cjs +36 -23
  22. package/src/repl.mjs +11 -0
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  25. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  26. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
  30. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
  31. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
  34. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
  35. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  36. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  37. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  38. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +96 -12
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  46. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  49. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
  50. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  51. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  52. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  53. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  54. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
  55. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  56. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  57. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  59. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  60. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  62. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  63. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  65. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  66. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  68. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  70. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  71. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  73. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  74. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  75. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  76. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/backends/discord.mjs +6 -6
  78. package/src/runtime/channels/lib/config.mjs +15 -2
  79. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  80. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  81. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  82. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  83. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  84. package/src/runtime/memory/index.mjs +24 -198
  85. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  86. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  87. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  89. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  90. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  91. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  92. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  93. package/src/runtime/shared/config.mjs +58 -13
  94. package/src/runtime/shared/llm/cost.mjs +14 -4
  95. package/src/runtime/shared/memory-snapshot.mjs +245 -0
  96. package/src/runtime/shared/process-lifecycle.mjs +184 -34
  97. package/src/runtime/shared/process-shutdown.mjs +6 -0
  98. package/src/runtime/shared/resource-admission.mjs +7 -2
  99. package/src/session-runtime/channel-config-api.mjs +7 -7
  100. package/src/session-runtime/config-lifecycle.mjs +20 -17
  101. package/src/session-runtime/cwd-plugins.mjs +9 -7
  102. package/src/session-runtime/env.mjs +1 -2
  103. package/src/session-runtime/hitch-profile.mjs +45 -0
  104. package/src/session-runtime/lifecycle-api.mjs +36 -9
  105. package/src/session-runtime/mcp-glue.mjs +6 -11
  106. package/src/session-runtime/provider-init-key.mjs +17 -0
  107. package/src/session-runtime/runtime-core.mjs +44 -103
  108. package/src/session-runtime/runtime-paths.mjs +20 -0
  109. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  110. package/src/session-runtime/session-turn-api.mjs +1 -0
  111. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  112. package/src/session-runtime/tool-catalog.mjs +15 -89
  113. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  114. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  115. package/src/standalone/agent-tool.mjs +12 -162
  116. package/src/standalone/channel-admin.mjs +29 -0
  117. package/src/tui/App.jsx +11 -5
  118. package/src/tui/dist/index.mjs +233 -65
  119. package/src/tui/engine/session-api-ext.mjs +37 -4
  120. package/src/tui/engine/turn.mjs +31 -0
  121. package/src/tui/index.jsx +1 -0
  122. package/src/tui/lib/voice-setup.mjs +5 -5
  123. package/scripts/_devtools-stub.mjs +0 -1
  124. package/src/runtime/lib/keychain-cjs.cjs +0 -288
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.52",
3
+ "version": "0.9.54",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -27,6 +27,11 @@ import { parseSSEStream } from '../src/runtime/agent/orchestrator/providers/anth
27
27
  import { _streamResponse } from '../src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs';
28
28
  import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
29
29
  import { shouldFallbackTransport } from '../src/runtime/agent/orchestrator/providers/retry-classifier.mjs';
30
+ import {
31
+ PROVIDER_MAX_BEFORE_WARN_MS,
32
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
33
+ PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS,
34
+ } from '../src/runtime/agent/orchestrator/stall-policy.mjs';
30
35
  import {
31
36
  ProviderAdmissionScheduler,
32
37
  wrapProviderAdmission,
@@ -39,6 +44,12 @@ const policy = {
39
44
  toolRunningMs: 600_000,
40
45
  };
41
46
 
47
+ test('OpenAI HTTP and WS semantic idle defaults share the pre-watchdog ceiling', () => {
48
+ assert.equal(PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS, PROVIDER_MAX_BEFORE_WARN_MS);
49
+ assert.equal(PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS, PROVIDER_MAX_BEFORE_WARN_MS);
50
+ assert.ok(PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS < 300_000);
51
+ });
52
+
42
53
  function seededSnapshot(id, elapsedMs) {
43
54
  markSessionAskStart(id);
44
55
  const entry = _getRuntimeEntry(id);
@@ -436,6 +447,63 @@ function openAiHttpResponse(events) {
436
447
  };
437
448
  }
438
449
 
450
+ test('OAuth HTTP retries refresh requesting-stage liveness for every attempt', async (t) => {
451
+ const id = `liveness-oauth-http-retries-${Date.now()}`;
452
+ t.after(() => _clearSessionRuntime(id));
453
+ markSessionAskStart(id);
454
+ const entry = _getRuntimeEntry(id);
455
+ const stages = [];
456
+ const scheduler = new ProviderAdmissionScheduler({ concurrency: 1 });
457
+ let fetchCalls = 0;
458
+
459
+ const provider = wrapProviderAdmission({
460
+ async send(_messages, _model, _tools, opts) {
461
+ return sendViaHttpSse({
462
+ auth: { type: 'openai-direct', apiKey: 'test' },
463
+ body: {},
464
+ opts: {},
465
+ useModel: 'gpt',
466
+ onStageChange: opts?.onStageChange,
467
+ fetchFn: async () => {
468
+ fetchCalls += 1;
469
+ if (fetchCalls <= 4) throw new Error('transient request failure');
470
+ return openAiHttpResponse([
471
+ { type: 'response.completed', response: { id: 'r1', model: 'gpt', status: 'completed', output: [] } },
472
+ ]);
473
+ },
474
+ _sleepFn: async () => {},
475
+ });
476
+ },
477
+ }, 'liveness-oauth-http-retries', scheduler);
478
+ await provider.send([], 'gpt', [], {
479
+ onStageChange: (stage, detail) => {
480
+ // Set a known stale value before the real ask-session liveness
481
+ // update. The post-send assertion therefore fails if this callback
482
+ // no longer refreshes lastProgressAt.
483
+ entry.lastProgressAt = 1;
484
+ updateSessionStage(id, stage);
485
+ stages.push({ stage, detail, lastProgressAt: entry.lastProgressAt });
486
+ },
487
+ });
488
+
489
+ assert.equal(fetchCalls, 5);
490
+ // Admission emits the first requesting signal, followed by one from each
491
+ // HTTP attempt. Keep the total explicit so stage-only consumers cannot
492
+ // silently dedupe the retry heartbeats.
493
+ const requestingStages = stages.filter(({ stage }) => stage === 'requesting');
494
+ assert.equal(stages.length, 7);
495
+ assert.deepEqual(requestingStages.map(({ stage }) => stage), Array(6).fill('requesting'));
496
+ assert.ok(requestingStages.every(({ lastProgressAt }) => lastProgressAt > 1));
497
+ assert.equal(requestingStages[0].detail, undefined);
498
+ assert.deepEqual(requestingStages.slice(1).map(({ detail }) => detail), [
499
+ { attempt: 1, maxAttempts: 5, retry: false },
500
+ { attempt: 2, maxAttempts: 5, retry: true },
501
+ { attempt: 3, maxAttempts: 5, retry: true },
502
+ { attempt: 4, maxAttempts: 5, retry: true },
503
+ { attempt: 5, maxAttempts: 5, retry: true },
504
+ ]);
505
+ });
506
+
439
507
  function wedgedOpenAiHttpResponse() {
440
508
  return {
441
509
  ok: true,
@@ -16,6 +16,26 @@ import {
16
16
  import { _midstreamSleepWithAbort, parseSSEStream } from '../src/runtime/agent/orchestrator/providers/anthropic-sse.mjs';
17
17
  import { AnthropicProvider } from '../src/runtime/agent/orchestrator/providers/anthropic.mjs';
18
18
  import { AnthropicOAuthProvider } from '../src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs';
19
+ import {
20
+ ANTHROPIC_CACHE_TTL_STABLE,
21
+ ANTHROPIC_CACHE_TTL_VOLATILE,
22
+ resolveAnthropicCacheTtls,
23
+ } from '../src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs';
24
+
25
+ test('Anthropic cache defaults keep system/tier3 stable and messages volatile', () => {
26
+ assert.deepEqual(resolveAnthropicCacheTtls(), {
27
+ tools: null,
28
+ system: ANTHROPIC_CACHE_TTL_STABLE,
29
+ tier3: ANTHROPIC_CACHE_TTL_STABLE,
30
+ messages: ANTHROPIC_CACHE_TTL_VOLATILE,
31
+ });
32
+ const descending = resolveAnthropicCacheTtls({
33
+ cacheStrategy: { system: '5m', tier3: '1h', messages: '1h' },
34
+ });
35
+ assert.equal(descending.system, ANTHROPIC_CACHE_TTL_VOLATILE);
36
+ assert.equal(descending.tier3, ANTHROPIC_CACHE_TTL_VOLATILE);
37
+ assert.equal(descending.messages, ANTHROPIC_CACHE_TTL_VOLATILE);
38
+ });
19
39
 
20
40
  test('Anthropic transport classifier covers every 5xx, 529, and nested connection errno', () => {
21
41
  for (const status of [500, 501, 505, 529, 599]) {
@@ -110,6 +130,33 @@ test('permanent quota 429 is not retried by the generic rate-limit path', async
110
130
  assert.equal(classifyError(quota), 'permanent');
111
131
  });
112
132
 
133
+ test('OAuth subscription 429 fails immediately without honoring Retry-After', async () => {
134
+ const oauth = Object.create(AnthropicOAuthProvider.prototype);
135
+ oauth.credentials = { accessToken: 'fixture', expiresAt: Date.now() + 60_000 };
136
+ oauth.config = {};
137
+ oauth.fastModeBetaHeaderLatched = false;
138
+ oauth.ensureAuth = async () => oauth.credentials;
139
+ oauth.scrubTokens = (text) => text;
140
+ let attempts = 0;
141
+ const limited = {
142
+ status: 429,
143
+ ok: false,
144
+ headers: new Map([['retry-after', '14400']]),
145
+ async text() { return 'subscription rate limit'; },
146
+ };
147
+ await assert.rejects(oauth.send([], 'claude-sonnet-4-6', [], {
148
+ _doRequestFn: async () => {
149
+ attempts += 1;
150
+ return {
151
+ response: limited,
152
+ controller: { abort() {} },
153
+ cancelHandler: null,
154
+ };
155
+ },
156
+ }), (err) => err?.status === 429 && err?.retryAfterMs === 14_400_000);
157
+ assert.equal(attempts, 1);
158
+ });
159
+
113
160
  test('structured and nested permanent quota codes are terminal; rate-limit codes retry', async () => {
114
161
  const terminalCases = [
115
162
  Object.assign(new Error('generic 429'), { status: 429, code: 'insufficient_quota' }),
@@ -220,6 +267,191 @@ function successfulAnthropicResponse(content = 'fallback-ok') {
220
267
  };
221
268
  }
222
269
 
270
+ function nonStreamingAnthropicMessage(content = 'replacement') {
271
+ return {
272
+ id: 'msg_fixture',
273
+ model: 'claude-sonnet-4-6',
274
+ content: [{ type: 'text', text: content }],
275
+ stop_reason: 'end_turn',
276
+ usage: { input_tokens: 2, output_tokens: 1 },
277
+ };
278
+ }
279
+
280
+ test('session plumbing fail-closes stream replacement and preserves unique output', { concurrency: false }, async (t) => {
281
+ const priorRetries = process.env.CLAUDE_CODE_MAX_RETRIES;
282
+ process.env.CLAUDE_CODE_MAX_RETRIES = '0';
283
+ try {
284
+ process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
285
+ const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
286
+ await initProviders({ anthropic: { enabled: true, apiKey: 'fixture-key' } });
287
+ const provider = getProvider('anthropic');
288
+ const {
289
+ abortSessionTurn,
290
+ askSession,
291
+ createSession,
292
+ getSession,
293
+ } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
294
+
295
+ const makeDroppedResponse = () => {
296
+ const partial = new TextEncoder().encode([
297
+ { type: 'message_start', message: { model: 'claude-sonnet-4-6', usage: { input_tokens: 1 } } },
298
+ { type: 'content_block_start', index: 0, content_block: { type: 'text' } },
299
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'partial' } },
300
+ ].map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''));
301
+ let reads = 0;
302
+ return {
303
+ ok: true,
304
+ status: 200,
305
+ headers: new Map(),
306
+ body: { getReader: () => ({
307
+ async read() {
308
+ if (reads++ === 0) return { done: false, value: partial };
309
+ throw Object.assign(new Error('body stream terminated'), { code: 'ECONNRESET' });
310
+ },
311
+ async cancel() {},
312
+ releaseLock() {},
313
+ }) },
314
+ };
315
+ };
316
+ const createTestSession = () => createSession({
317
+ provider: 'anthropic',
318
+ model: 'claude-sonnet-4-6',
319
+ tools: [],
320
+ cwd: process.cwd(),
321
+ skipAgentRules: true,
322
+ skipSkills: true,
323
+ compaction: { auto: false },
324
+ });
325
+ const partialsInHistory = (session) => session.messages.filter((message) => (
326
+ message.role === 'assistant' && message.content === 'partial'
327
+ )).length;
328
+ const runCase = async ({ reset, restartFails = false, cancelRestart = false }) => {
329
+ const session = createTestSession();
330
+ let calls = 0;
331
+ let visible = '';
332
+ let markRestartEntered;
333
+ const restartEntered = new Promise((resolve) => { markRestartEntered = resolve; });
334
+ provider.client = { messages: { create(params, requestOptions = {}) {
335
+ calls += 1;
336
+ if (params.stream === false) {
337
+ if (restartFails) throw new Error('non-streaming restart failed');
338
+ if (cancelRestart) {
339
+ markRestartEntered();
340
+ return new Promise((_resolve, reject) => {
341
+ const signal = requestOptions.signal;
342
+ const rejectFromAbort = () => reject(
343
+ signal?.reason instanceof Error
344
+ ? signal.reason
345
+ : new Error('non-streaming restart canceled'),
346
+ );
347
+ if (signal?.aborted) rejectFromAbort();
348
+ else signal?.addEventListener('abort', rejectFromAbort, { once: true });
349
+ });
350
+ }
351
+ return Promise.resolve(nonStreamingAnthropicMessage('replacement'));
352
+ }
353
+ return { asResponse: async () => makeDroppedResponse() };
354
+ } } };
355
+ const options = {
356
+ onTextDelta: (chunk) => { visible += chunk; },
357
+ ...(reset ? {
358
+ onTextReset: async ({ chars }) => reset({
359
+ chars,
360
+ getVisible: () => visible,
361
+ setVisible: (value) => { visible = value; },
362
+ }),
363
+ } : {}),
364
+ };
365
+ const asking = askSession(
366
+ session.id,
367
+ 'fixture prompt',
368
+ null,
369
+ null,
370
+ process.cwd(),
371
+ null,
372
+ options,
373
+ );
374
+ if (cancelRestart) {
375
+ await restartEntered;
376
+ assert.equal(abortSessionTurn(session.id, 'user-cancel'), true);
377
+ }
378
+ const outcome = await asking.then(
379
+ (result) => ({ result }),
380
+ (error) => ({ error }),
381
+ );
382
+ return {
383
+ ...outcome,
384
+ calls,
385
+ visible,
386
+ session: getSession(session.id),
387
+ };
388
+ };
389
+
390
+ for (const [name, reset] of [
391
+ ['absent acknowledgment', null],
392
+ ['false acknowledgment', async () => false],
393
+ ['throwing acknowledgment', async () => { throw new Error('reset failed'); }],
394
+ ]) {
395
+ await t.test(name, async () => {
396
+ const outcome = await runCase({ reset });
397
+ assert.match(outcome.error?.message || '', /body stream terminated/);
398
+ assert.equal(outcome.calls, 1, 'must not start non-streaming request');
399
+ assert.equal(outcome.visible, 'partial');
400
+ assert.equal(partialsInHistory(outcome.session), 1);
401
+ });
402
+ }
403
+
404
+ await t.test('late positive acknowledgment is awaited before one replacement', async () => {
405
+ const outcome = await runCase({
406
+ reset: async ({ chars, getVisible, setVisible }) => {
407
+ await new Promise((resolve) => setTimeout(resolve, 15));
408
+ setVisible(getVisible().slice(0, -chars));
409
+ return true;
410
+ },
411
+ });
412
+ assert.equal(outcome.error, undefined);
413
+ assert.equal(outcome.calls, 2);
414
+ assert.equal(outcome.visible, '');
415
+ assert.equal(outcome.result.content, 'replacement');
416
+ assert.equal(outcome.session.messages.filter((message) => (
417
+ message.role === 'assistant' && message.content === 'replacement'
418
+ )).length, 1);
419
+ assert.equal(partialsInHistory(outcome.session), 0);
420
+ });
421
+
422
+ await t.test('failed restart restores one partial to interruption history', async () => {
423
+ const outcome = await runCase({
424
+ restartFails: true,
425
+ reset: async ({ chars, getVisible, setVisible }) => {
426
+ setVisible(getVisible().slice(0, -chars));
427
+ return true;
428
+ },
429
+ });
430
+ assert.match(outcome.error?.message || '', /non-streaming restart failed/);
431
+ assert.equal(outcome.calls, 2);
432
+ assert.equal(outcome.visible, '', 'acknowledged consumer keeps the tombstone');
433
+ assert.equal(partialsInHistory(outcome.session), 1, 'history restores exactly one exposed partial');
434
+ });
435
+
436
+ await t.test('cancellation during restart restores the partial before finalization', async () => {
437
+ const outcome = await runCase({
438
+ cancelRestart: true,
439
+ reset: async ({ chars, getVisible, setVisible }) => {
440
+ setVisible(getVisible().slice(0, -chars));
441
+ return true;
442
+ },
443
+ });
444
+ assert.equal(outcome.error?.name, 'SessionClosedError');
445
+ assert.equal(outcome.calls, 2);
446
+ assert.equal(outcome.visible, '', 'acknowledged consumer keeps the tombstone');
447
+ assert.equal(partialsInHistory(outcome.session), 1, 'cancel history preserves exactly one partial');
448
+ });
449
+ } finally {
450
+ if (priorRetries == null) delete process.env.CLAUDE_CODE_MAX_RETRIES;
451
+ else process.env.CLAUDE_CODE_MAX_RETRIES = priorRetries;
452
+ }
453
+ });
454
+
223
455
  test('OAuth and API-key providers switch to the opt-in fallback after three 529s', async () => {
224
456
  const priorRetries = process.env.CLAUDE_CODE_MAX_RETRIES;
225
457
  process.env.CLAUDE_CODE_MAX_RETRIES = '2';
@@ -359,6 +591,34 @@ test('x-should-retry false vetoes retry before status defaults', async () => {
359
591
  }
360
592
  });
361
593
 
594
+ test('Anthropic x-should-retry true overrides a normally terminal status only for Anthropic', async () => {
595
+ for (const provider of ['anthropic', 'gemini']) {
596
+ let attempts = 0;
597
+ const result = await withRetry(async () => {
598
+ attempts += 1;
599
+ if (attempts === 1) {
600
+ throw Object.assign(new Error('bad request with server override'), {
601
+ status: 400,
602
+ headers: new Map([['x-should-retry', 'true']]),
603
+ });
604
+ }
605
+ return 'recovered';
606
+ }, {
607
+ provider,
608
+ maxAttempts: 2,
609
+ backoffMs: [0],
610
+ retryJitterRatio: 0,
611
+ }).then((value) => value, (error) => error);
612
+ if (provider === 'anthropic') {
613
+ assert.equal(result, 'recovered');
614
+ assert.equal(attempts, 2);
615
+ } else {
616
+ assert.equal(result?.status, 400);
617
+ assert.equal(attempts, 1);
618
+ }
619
+ }
620
+ });
621
+
362
622
  test('Retry-After is not capped or jittered and remains abortable', async () => {
363
623
  const ac = new AbortController();
364
624
  let observed;
@@ -336,7 +336,7 @@ function averageCards(cards) {
336
336
  // ---- main ----
337
337
  const tasksPath = argValue('--tasks', null);
338
338
  if (!tasksPath) {
339
- console.error('usage: --tasks <tasks.json> [--round N] [--runner mixdog] [--provider P] [--model M] [--effort E] [--fast] [--save round.json] [--json]');
339
+ console.error('usage: --tasks <tasks.json> [--round N] [--runner mixdog|codex|lead] [--provider P] [--model M] [--effort E] [--fast] [--save round.json] [--json]');
340
340
  process.exit(1);
341
341
  }
342
342
  if (!existsSync(resolve(tasksPath))) { console.error(`tasks file not found: ${tasksPath}`); process.exit(1); }
@@ -345,7 +345,7 @@ if (!Array.isArray(tasks) || !tasks.length) { console.error('tasks.json must be
345
345
 
346
346
  const runnerName = argValue('--runner', 'mixdog');
347
347
  const runner = RUNNERS[runnerName];
348
- if (!runner) { console.error(`unknown runner "${runnerName}" (mixdog|codex|claude)`); process.exit(1); }
348
+ if (!runner) { console.error(`unknown runner "${runnerName}" (mixdog|codex|lead)`); process.exit(1); }
349
349
  const round = argValue('--round', '1');
350
350
  const savePath = argValue('--save', null);
351
351
  const jsonMode = hasFlag('--json');
@@ -22,6 +22,8 @@ import { initialCompactionConfig } from '../src/runtime/agent/orchestrator/sessi
22
22
  import { resolveSessionCompactionPolicy } from '../src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs';
23
23
  import { sendWithRecovery } from '../src/runtime/agent/orchestrator/session/send-with-recovery.mjs';
24
24
  import { recallFastTrackCompactMessages } from '../src/runtime/agent/orchestrator/session/compact/engine.mjs';
25
+ import { _combineUsageWithWarmup } from '../src/runtime/agent/orchestrator/providers/openai-ws-events.mjs';
26
+ import { applyAskTerminalUsageTotals } from '../src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs';
25
27
 
26
28
  function policyFor(session) {
27
29
  return resolveWorkerCompactPolicy(session, []);
@@ -626,6 +628,108 @@ test('thinking-only continuation without assistant replay excludes provider outp
626
628
  }), false, 'unreplayed thinking output must not cause an early compact');
627
629
  });
628
630
 
631
+ test('OpenAI OAuth WS warmup remains billed but does not double the main context footprint', async () => {
632
+ const main = {
633
+ inputTokens: 167_635,
634
+ outputTokens: 611,
635
+ cachedTokens: 160_000,
636
+ promptTokens: 167_635,
637
+ raw: {},
638
+ };
639
+ const usage = _combineUsageWithWarmup(main, main, { separateMainContext: true });
640
+ assert.equal(usage.inputTokens, 335_270, 'billing usage must retain warmup plus main input');
641
+ const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
642
+ const { createSession, askSession, getSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
643
+ await initProviders({ 'openai-oauth': { enabled: true } });
644
+ const provider = getProvider('openai-oauth');
645
+ const originalSend = provider.send;
646
+ const compactEvents = [];
647
+ provider.send = async () => ({ content: 'done', usage });
648
+ try {
649
+ const session = createSession({
650
+ provider: 'openai-oauth',
651
+ model: 'warmup-context-regression',
652
+ tools: [],
653
+ cwd: process.cwd(),
654
+ skipAgentRules: true,
655
+ skipSkills: true,
656
+ compaction: { auto: true },
657
+ });
658
+ session.contextWindow = 272_000;
659
+ session.rawContextWindow = 272_000;
660
+ await askSession(session.id, 'large OAuth WebSocket request', null, null, process.cwd(), null, {
661
+ onCompactEvent: event => compactEvents.push(event),
662
+ });
663
+
664
+ const persisted = getSession(session.id);
665
+ const policy = { ...policyFor(persisted), reserveTokens: 0 };
666
+ const messageTokens = estimateMessagesTokens(persisted.messages);
667
+ const pressure = resolveCompactionPressureTokens(messageTokens, policy, {
668
+ messages: persisted.messages,
669
+ sessionRef: persisted,
670
+ });
671
+ assert.equal(persisted.totalInputTokens, 335_270, 'incremental lifetime totals must retain warmup billing');
672
+ assert.equal(persisted.lastContextTokens, 167_635, 'incremental context snapshot must exclude warmup');
673
+ assert.equal(persisted.contextPressureBaselineTokens, 168_246, 'stored baseline must use main request usage');
674
+ assert.equal(pressure, 168_246, 'resolved pressure must use the stored baseline without transcript growth');
675
+ assert.equal(policy.triggerTokens, 258_400, 'fixture trigger must match the reported premature-compaction threshold');
676
+ assert.equal(shouldCompactForSession(messageTokens, policy, {
677
+ messages: persisted.messages,
678
+ sessionRef: persisted,
679
+ pressureTokens: pressure,
680
+ }), false, 'main-request pressure must not trigger compaction');
681
+ assert.equal(compactEvents.length, 0, 'warmup must not cause an auto-compaction');
682
+ } finally {
683
+ provider.send = originalSend;
684
+ }
685
+ });
686
+
687
+ test('warmup-only OpenAI OAuth WS usage stays billable but invalidates context usage', () => {
688
+ const warmup = { inputTokens: 167_635, outputTokens: 611, cachedTokens: 160_000, promptTokens: 167_635 };
689
+ const usage = _combineUsageWithWarmup(null, warmup, { separateMainContext: true });
690
+ const session = { provider: 'openai-oauth', contextPressureBaselineTokens: 99_000 };
691
+ assert.equal(usage.mainUsageAvailable, false);
692
+ assert.equal(recordProviderContextBaseline(session, [], usage), false);
693
+ assert.equal(session.contextPressureBaselineTokens, null, 'warmup-only usage must not remain a provider baseline');
694
+ applyAskTerminalUsageTotals(session, { usage, lastTurnUsage: usage });
695
+ assert.equal(session.totalInputTokens, 167_635, 'warmup-only usage remains billable');
696
+ assert.equal(session.lastContextTokens, null, 'warmup-only usage has no main-request context snapshot');
697
+ });
698
+
699
+ test('xAI warmup remains billable while main usage alone drives context pressure', () => {
700
+ const actual = { inputTokens: 20, outputTokens: 2, cachedTokens: 5, promptTokens: 20, raw: { provider: 'xai', cost_in_usd_ticks: 200 } };
701
+ const warmup = { inputTokens: 10, outputTokens: 1, cachedTokens: 3, promptTokens: 10, raw: { phase: 'warmup', cost_in_usd_ticks: 100 } };
702
+ const usage = _combineUsageWithWarmup(actual, warmup, { separateMainContext: true });
703
+ assert.deepEqual(usage, {
704
+ ...actual,
705
+ inputTokens: 30,
706
+ outputTokens: 3,
707
+ cachedTokens: 8,
708
+ promptTokens: 30,
709
+ warmupInputTokens: 10,
710
+ warmupCachedTokens: 3,
711
+ warmupOutputTokens: 1,
712
+ warmupPromptTokens: 10,
713
+ warmupCacheWriteTokens: 0,
714
+ raw: {
715
+ provider: 'xai',
716
+ cost_in_usd_ticks: 300,
717
+ warmup_usage: { phase: 'warmup', cost_in_usd_ticks: 100 },
718
+ },
719
+ mainInputTokens: 20,
720
+ mainOutputTokens: 2,
721
+ mainCachedTokens: 5,
722
+ mainPromptTokens: 20,
723
+ mainCacheWriteTokens: 0,
724
+ mainUsageAvailable: true,
725
+ });
726
+ const session = { provider: 'xai', contextPressureBaselineTokens: null };
727
+ assert.equal(recordProviderContextBaseline(session, [], usage), true);
728
+ applyAskTerminalUsageTotals(session, { usage, lastTurnUsage: usage });
729
+ assert.equal(session.totalInputTokens, 30, 'lifetime total includes xAI warmup');
730
+ assert.equal(session.lastContextTokens, 20, 'context snapshot excludes xAI warmup');
731
+ });
732
+
629
733
  test('successful compact invalidates stale usage and cannot immediately compact again', () => {
630
734
  const session = { provider: 'openai', contextWindow: 100_000, compaction: {} };
631
735
  const policy = { ...policyFor(session), reserveTokens: 0 };