mixdog 0.9.51 → 0.9.52

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 (110) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/interrupted-turn-history-test.mjs +371 -0
  18. package/scripts/lifecycle-api-test.mjs +76 -0
  19. package/scripts/max-output-recovery-test.mjs +55 -0
  20. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  21. package/scripts/memory-pg-recovery-test.mjs +59 -0
  22. package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
  23. package/scripts/process-lifecycle-test.mjs +389 -0
  24. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  25. package/scripts/provider-contract-test.mjs +268 -0
  26. package/scripts/provider-toolcall-test.mjs +320 -1
  27. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  28. package/scripts/resource-admission-test.mjs +789 -0
  29. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  30. package/scripts/steering-drain-buckets-test.mjs +18 -0
  31. package/scripts/toolcall-args-test.mjs +14 -6
  32. package/scripts/tui-transcript-perf-test.mjs +5 -7
  33. package/src/cli.mjs +15 -2
  34. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  35. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  36. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  38. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  39. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  40. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  41. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  42. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  43. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  44. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  45. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  46. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  49. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  55. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  56. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  59. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
  60. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  62. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  63. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  64. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  65. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  66. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  67. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  69. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  70. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  71. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  72. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  73. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  74. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  75. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  76. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  79. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  80. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  82. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  83. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  84. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  85. package/src/runtime/memory/index.mjs +22 -4
  86. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  87. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  89. package/src/runtime/shared/atomic-file.mjs +28 -150
  90. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  91. package/src/runtime/shared/process-shutdown.mjs +27 -4
  92. package/src/runtime/shared/resource-admission.mjs +359 -0
  93. package/src/runtime/shared/staged-child-result.mjs +19 -0
  94. package/src/session-runtime/context-status.mjs +38 -27
  95. package/src/session-runtime/lifecycle-api.mjs +26 -6
  96. package/src/session-runtime/provider-request-tools.mjs +72 -0
  97. package/src/session-runtime/runtime-core.mjs +6 -3
  98. package/src/session-runtime/session-turn-api.mjs +5 -4
  99. package/src/session-runtime/tool-catalog.mjs +375 -15
  100. package/src/standalone/agent-tool.mjs +17 -38
  101. package/src/standalone/channel-daemon-client.mjs +200 -38
  102. package/src/standalone/channel-daemon-transport.mjs +136 -9
  103. package/src/standalone/channel-worker.mjs +79 -12
  104. package/src/tui/dist/index.mjs +73 -278
  105. package/src/tui/engine/session-api-ext.mjs +13 -2
  106. package/src/tui/engine/session-api.mjs +1 -1
  107. package/src/tui/engine/turn.mjs +10 -6
  108. package/src/tui/engine.mjs +13 -4
  109. package/src/tui/index.jsx +4 -1
  110. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict'
2
+ import { EventEmitter } from 'node:events'
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import test from 'node:test'
7
+
8
+ import {
9
+ installPoolErrorHandler,
10
+ isPgConnectionLossError,
11
+ } from '../src/runtime/memory/lib/pg/adapter.mjs'
12
+ import { startPg } from '../src/runtime/memory/lib/pg/process.mjs'
13
+
14
+ test('checked-out pg clients always retain an error listener', () => {
15
+ const priorQuiet = process.env.MIXDOG_QUIET_MEMORY_LOG
16
+ process.env.MIXDOG_QUIET_MEMORY_LOG = '1'
17
+ try {
18
+ const pool = new EventEmitter()
19
+ const client = new EventEmitter()
20
+ installPoolErrorHandler(pool, 'test-pool')
21
+ pool.emit('connect', client)
22
+ pool.emit('connect', client)
23
+
24
+ assert.equal(client.listenerCount('error'), 1)
25
+ assert.doesNotThrow(() => {
26
+ client.emit('error', new Error('Connection terminated unexpectedly'))
27
+ })
28
+ } finally {
29
+ if (priorQuiet == null) delete process.env.MIXDOG_QUIET_MEMORY_LOG
30
+ else process.env.MIXDOG_QUIET_MEMORY_LOG = priorQuiet
31
+ }
32
+ })
33
+
34
+ test('postgres connection-loss classifier covers reset and server termination', () => {
35
+ assert.equal(isPgConnectionLossError(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' })), true)
36
+ assert.equal(isPgConnectionLossError(new Error('Connection terminated unexpectedly')), true)
37
+ assert.equal(isPgConnectionLossError(Object.assign(new Error('admin shutdown'), { code: '57P01' })), true)
38
+ assert.equal(isPgConnectionLossError(new Error('duplicate key value violates unique constraint')), false)
39
+ })
40
+
41
+ test('startPg refuses a second start while postmaster.pid owner is alive but not ready', async () => {
42
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-pg-recovery-'))
43
+ const runtimeDir = join(root, 'runtime')
44
+ const pgdataDir = join(root, 'pgdata')
45
+ mkdirSync(runtimeDir, { recursive: true })
46
+ mkdirSync(pgdataDir, { recursive: true })
47
+ writeFileSync(
48
+ join(pgdataDir, 'postmaster.pid'),
49
+ `${process.pid}\n${pgdataDir}\n${Math.floor(Date.now() / 1000)}\n55432\n\n127.0.0.1\n`,
50
+ )
51
+ try {
52
+ await assert.rejects(
53
+ startPg({ runtimeDir, pgdataDir, existingWaitMs: 20 }),
54
+ /is alive but not ready; refusing concurrent start/,
55
+ )
56
+ } finally {
57
+ rmSync(root, { recursive: true, force: true })
58
+ }
59
+ })
@@ -4,9 +4,13 @@ import assert from 'node:assert/strict';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import { OpenAIOAuthProvider } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
6
6
  import { _acquireWithRetry, sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
7
+ import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
7
8
  import {
8
9
  _clearWebSocketPoolForTest,
10
+ _closeAllPooledSockets,
11
+ _resetOpenSocketDrainForTest,
9
12
  _seedWebSocketEntryForTest,
13
+ _setOpenSocketForTest,
10
14
  acquireWebSocket,
11
15
  releaseWebSocket,
12
16
  _sendFrame,
@@ -235,9 +239,9 @@ test('pre-response 1006 opens a fresh WS and replays the same request', async ()
235
239
  stages.filter(({ stage }) => stage === 'reconnecting'),
236
240
  [{ stage: 'reconnecting', detail: {
237
241
  attempt: 1,
238
- max: 4,
242
+ max: 5,
239
243
  classifier: 'ws_1006',
240
- message: 'Reconnecting... 1/4',
244
+ message: 'Reconnecting... 1/5',
241
245
  } }],
242
246
  );
243
247
  assert.doesNotMatch(stderr, /mid-stream recovered|Reconnecting/);
@@ -269,6 +273,33 @@ test('successful iteration emits one compact send-spans row', async () => {
269
273
  assert.equal('body' in rows[0].payload, false);
270
274
  });
271
275
 
276
+ test('normal handshake cannot return or reinsert a socket after pool drain', async () => {
277
+ let finishOpen;
278
+ const opened = new Promise((resolve) => { finishOpen = resolve; });
279
+ const closes = [];
280
+ const socket = new EventEmitter();
281
+ socket.readyState = 1;
282
+ socket.close = (code, reason) => closes.push([code, reason]);
283
+ socket.on = socket.on.bind(socket);
284
+ try {
285
+ _setOpenSocketForTest(() => opened);
286
+ const acquire = acquireWebSocket({
287
+ auth: { type: 'openai-direct', apiKey: 'test-key' },
288
+ poolKey: 'drain-race',
289
+ cacheKey: 'drain-race',
290
+ forceFresh: false,
291
+ externalSignal: null,
292
+ });
293
+ _closeAllPooledSockets('test-drain');
294
+ finishOpen({ socket, turnState: null });
295
+ await assert.rejects(acquire, /WS pool drained/);
296
+ assert.deepEqual(closes, [[1000, 'drain-complete']]);
297
+ } finally {
298
+ _clearWebSocketPoolForTest();
299
+ _resetOpenSocketDrainForTest();
300
+ }
301
+ });
302
+
272
303
  test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async () => {
273
304
  const savedEnv = Object.fromEntries([
274
305
  'MIXDOG_OAI_TRANSPORT',
@@ -310,10 +341,64 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
310
341
  const result = await provider.send([], 'gpt-5.5', [], sendOpts);
311
342
  const stickyResult = await provider.send([], 'gpt-5.5', [], sendOpts);
312
343
 
313
- assert.equal(streamAttempts, 5, 'all bounded ws_1006 attempts must run before fallback');
344
+ assert.equal(streamAttempts, 6, 'five bounded retries must run before fallback');
314
345
  assert.equal(httpCalls, 2);
315
346
  assert.equal(result.content, 'http-recovered');
316
347
  assert.equal(stickyResult.content, 'http-recovered');
348
+ assert.equal(provider._httpFallbackUntilByPoolKey.get(sendOpts.sessionId), Number.POSITIVE_INFINITY);
349
+ } finally {
350
+ for (const [name, value] of Object.entries(savedEnv)) {
351
+ if (value == null) delete process.env[name];
352
+ else process.env[name] = value;
353
+ }
354
+ }
355
+ });
356
+
357
+ test('handshake 403 spends the shared retry budget without auth refresh, then disables WS for the session', async () => {
358
+ const savedEnv = Object.fromEntries([
359
+ 'MIXDOG_OAI_TRANSPORT',
360
+ 'MIXDOG_OPENAI_HTTP_FALLBACK',
361
+ 'MIXDOG_OPENAI_OAUTH_WS_WARMUP',
362
+ 'MIXDOG_AGENT_TRACE_DISABLE',
363
+ ].map((name) => [name, process.env[name]]));
364
+ Object.assign(process.env, {
365
+ MIXDOG_OAI_TRANSPORT: 'auto',
366
+ MIXDOG_OPENAI_HTTP_FALLBACK: '1',
367
+ MIXDOG_OPENAI_OAUTH_WS_WARMUP: '0',
368
+ MIXDOG_AGENT_TRACE_DISABLE: '1',
369
+ });
370
+ try {
371
+ const provider = new OpenAIOAuthProvider({});
372
+ let authRefreshes = 0;
373
+ provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
374
+ if (forceRefresh) authRefreshes += 1;
375
+ return { access_token: 'test-token' };
376
+ };
377
+ let acquires = 0;
378
+ let httpCalls = 0;
379
+ const sendOpts = {
380
+ sessionId: 'openai-oauth-ws-403-fallback-test',
381
+ _prebuiltBody: { model: 'gpt-5.5', input: [] },
382
+ _sendViaWebSocketFn: (args) => sendViaWebSocket({
383
+ ...args,
384
+ _acquireWithRetryFn: async ({ forceFresh }) => {
385
+ assert.equal(forceFresh, acquires > 0);
386
+ acquires += 1;
387
+ throw Object.assign(new Error('Unexpected server response: 403'), { httpStatus: 403 });
388
+ },
389
+ _sleepFn: async () => {},
390
+ }),
391
+ _sendViaHttpSseFn: async () => {
392
+ httpCalls += 1;
393
+ return { content: 'http-recovered', toolCalls: [], usage: {} };
394
+ },
395
+ };
396
+
397
+ assert.equal((await provider.send([], 'gpt-5.5', [], sendOpts)).content, 'http-recovered');
398
+ assert.equal((await provider.send([], 'gpt-5.5', [], sendOpts)).content, 'http-recovered');
399
+ assert.equal(acquires, 6);
400
+ assert.equal(authRefreshes, 0);
401
+ assert.equal(httpCalls, 2);
317
402
  } finally {
318
403
  for (const [name, value] of Object.entries(savedEnv)) {
319
404
  if (value == null) delete process.env[name];
@@ -322,6 +407,180 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
322
407
  }
323
408
  });
324
409
 
410
+ test('handshake 426 falls back immediately without retry or auth refresh', async () => {
411
+ const provider = new OpenAIOAuthProvider({});
412
+ let authRefreshes = 0;
413
+ provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
414
+ if (forceRefresh) authRefreshes += 1;
415
+ return { access_token: 'test-token' };
416
+ };
417
+ let wsCalls = 0;
418
+ let httpCalls = 0;
419
+ const result = await provider.send([], 'gpt-5.5', [], {
420
+ sessionId: 'openai-oauth-ws-426-fallback-test',
421
+ _prebuiltBody: { model: 'gpt-5.5', input: [] },
422
+ _sendViaWebSocketFn: async () => {
423
+ wsCalls += 1;
424
+ throw Object.assign(new Error('Upgrade Required'), { httpStatus: 426 });
425
+ },
426
+ _sendViaHttpSseFn: async () => {
427
+ httpCalls += 1;
428
+ return { content: 'http-426', toolCalls: [], usage: {} };
429
+ },
430
+ });
431
+ assert.equal(result.content, 'http-426');
432
+ assert.equal(wsCalls, 1);
433
+ assert.equal(httpCalls, 1);
434
+ assert.equal(authRefreshes, 0);
435
+ });
436
+
437
+ for (const path of ['handshake', 'pre-output-stream']) {
438
+ for (const terminalStatus of [401, 426]) {
439
+ test(`mixed ${path} failure surfaces later ${terminalStatus} to provider recovery`, async () => {
440
+ const provider = new OpenAIOAuthProvider({});
441
+ let authRefreshes = 0;
442
+ provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
443
+ if (forceRefresh) authRefreshes += 1;
444
+ return { access_token: forceRefresh ? 'refreshed-token' : 'test-token' };
445
+ };
446
+ let wsDispatches = 0;
447
+ let httpCalls = 0;
448
+ const result = await provider.send([], 'gpt-5.5', [], {
449
+ sessionId: `mixed-${path}-${terminalStatus}`,
450
+ _prebuiltBody: { model: 'gpt-5.5', input: [] },
451
+ _sendViaWebSocketFn: (args) => {
452
+ wsDispatches += 1;
453
+ if (wsDispatches > 1) {
454
+ return { content: 'ws-after-refresh', toolCalls: [], usage: {} };
455
+ }
456
+ let failures = 0;
457
+ return sendViaWebSocket({
458
+ ...args,
459
+ _acquireWithRetryFn: async () => {
460
+ if (path === 'handshake') {
461
+ failures += 1;
462
+ const status = failures === 1 ? 403 : terminalStatus;
463
+ throw Object.assign(new Error(`handshake ${status}`), { httpStatus: status });
464
+ }
465
+ return { entry: entry(), reused: false };
466
+ },
467
+ _sendFrameFn: async () => {},
468
+ _streamFn: async () => {
469
+ failures += 1;
470
+ if (failures === 1) throw close1006();
471
+ throw Object.assign(new Error(`stream ${terminalStatus}`), { httpStatus: terminalStatus });
472
+ },
473
+ _sleepFn: async () => {},
474
+ });
475
+ },
476
+ _sendViaHttpSseFn: async () => {
477
+ httpCalls += 1;
478
+ return { content: 'http-after-426', toolCalls: [], usage: {} };
479
+ },
480
+ });
481
+
482
+ assert.equal(result.content, terminalStatus === 401 ? 'ws-after-refresh' : 'http-after-426');
483
+ assert.equal(authRefreshes, terminalStatus === 401 ? 1 : 0);
484
+ assert.equal(wsDispatches, terminalStatus === 401 ? 2 : 1);
485
+ assert.equal(httpCalls, terminalStatus === 426 ? 1 : 0);
486
+ });
487
+ }
488
+ }
489
+
490
+ for (const fallbackOutcome of ['failure', 'abort']) {
491
+ test(`WS remains disabled when sticky HTTP fallback ends in ${fallbackOutcome}`, async () => {
492
+ const provider = new OpenAIOAuthProvider({});
493
+ provider.ensureAuth = async () => ({ access_token: 'test-token' });
494
+ const controller = new AbortController();
495
+ let wsCalls = 0;
496
+ let httpCalls = 0;
497
+ const opts = {
498
+ sessionId: `sticky-before-http-${fallbackOutcome}`,
499
+ signal: controller.signal,
500
+ _prebuiltBody: { model: 'gpt-5.5', input: [] },
501
+ _sendViaWebSocketFn: async () => {
502
+ wsCalls += 1;
503
+ throw Object.assign(new Error('WS retries exhausted'), {
504
+ retryClassifier: 'reset',
505
+ wsRetriesExhausted: true,
506
+ });
507
+ },
508
+ _sendViaHttpSseFn: async () => {
509
+ httpCalls += 1;
510
+ if (httpCalls === 1) {
511
+ const err = new Error(`fallback ${fallbackOutcome}`);
512
+ if (fallbackOutcome === 'abort') controller.abort(err);
513
+ throw err;
514
+ }
515
+ return { content: 'http-still-disabled', toolCalls: [], usage: {} };
516
+ },
517
+ };
518
+
519
+ await assert.rejects(
520
+ provider.send([], 'gpt-5.5', [], opts),
521
+ fallbackOutcome === 'abort'
522
+ ? (err) => err === controller.signal.reason && err.message === 'fallback abort'
523
+ : /WS retries exhausted/,
524
+ );
525
+ assert.equal(
526
+ provider._httpFallbackUntilByPoolKey.get(opts.sessionId),
527
+ Number.POSITIVE_INFINITY,
528
+ );
529
+ if (fallbackOutcome === 'abort') opts.signal = null;
530
+ assert.equal((await provider.send([], 'gpt-5.5', [], opts)).content, 'http-still-disabled');
531
+ assert.equal(wsCalls, 1);
532
+ assert.equal(httpCalls, 2);
533
+ });
534
+ }
535
+
536
+ for (const fallbackPath of ['primary', 'auth-retry']) {
537
+ for (const exposed of ['text', 'tool']) {
538
+ test(`HTTP ${fallbackPath} fallback failure after ${exposed} overrides stale WS error`, async () => {
539
+ const provider = new OpenAIOAuthProvider({});
540
+ let refreshes = 0;
541
+ provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
542
+ if (forceRefresh) refreshes += 1;
543
+ return { access_token: forceRefresh ? 'refreshed-token' : 'test-token' };
544
+ };
545
+ let wsCalls = 0;
546
+ const stale = Object.assign(new Error(`stale ${fallbackPath} WS failure`), {
547
+ retryClassifier: 'reset',
548
+ wsRetriesExhausted: true,
549
+ });
550
+ const exposedFailure = Object.assign(new Error(`HTTP fallback failed after ${exposed}`), {
551
+ unsafeToRetry: true,
552
+ ...(exposed === 'text'
553
+ ? { liveTextEmitted: true }
554
+ : { emittedToolCall: true }),
555
+ });
556
+
557
+ await assert.rejects(
558
+ provider.send([], 'gpt-5.5', [], {
559
+ sessionId: `http-${fallbackPath}-${exposed}-failure`,
560
+ _prebuiltBody: { model: 'gpt-5.5', input: [] },
561
+ _sendViaWebSocketFn: async () => {
562
+ wsCalls += 1;
563
+ if (fallbackPath === 'auth-retry' && wsCalls === 1) {
564
+ throw Object.assign(new Error('refresh auth'), { httpStatus: 401 });
565
+ }
566
+ throw stale;
567
+ },
568
+ _sendViaHttpSseFn: async () => {
569
+ throw exposedFailure;
570
+ },
571
+ }),
572
+ (err) => err === exposedFailure
573
+ && err.unsafeToRetry === true
574
+ && (exposed === 'text'
575
+ ? err.liveTextEmitted === true
576
+ : err.emittedToolCall === true),
577
+ );
578
+ assert.equal(wsCalls, fallbackPath === 'auth-retry' ? 2 : 1);
579
+ assert.equal(refreshes, fallbackPath === 'auth-retry' ? 1 : 0);
580
+ });
581
+ }
582
+ }
583
+
325
584
  test('sticky HTTP fallback is isolated by session and expired entries are cleaned', async () => {
326
585
  const savedEnv = Object.fromEntries([
327
586
  'MIXDOG_OAI_TRANSPORT',
@@ -491,3 +750,65 @@ test('post-emission 1006 refuses replay after text or tool output', async () =>
491
750
  assert.equal(acquires, 1, `${emitted} must prevent a replay`);
492
751
  }
493
752
  });
753
+
754
+ test('partial reasoning or tool generation refuses WS replay', async () => {
755
+ for (const partial of ['emittedReasoning', 'startedToolCall']) {
756
+ let acquires = 0;
757
+ await assert.rejects(
758
+ sendViaWebSocket(wsArgs({
759
+ poolKey: `openai-oauth-ws-1006-${partial}-test`,
760
+ _acquireWithRetryFn: async () => {
761
+ acquires += 1;
762
+ return { entry: entry(), reused: false };
763
+ },
764
+ _streamFn: async ({ state }) => {
765
+ state[partial] = true;
766
+ throw close1006();
767
+ },
768
+ })),
769
+ (err) => err.wsCloseCode === 1006
770
+ && err.unsafeToRetry === true
771
+ && (partial === 'emittedReasoning'
772
+ ? err.partialReasoningEmitted === true
773
+ : err.partialToolCallStarted === true),
774
+ );
775
+ assert.equal(acquires, 1, `${partial} must prevent a replay`);
776
+ }
777
+ });
778
+
779
+ test('HTTP request transport retries match Codex count/backoff and exclude 429', async () => {
780
+ const delays = [];
781
+ let calls = 0;
782
+ await assert.rejects(
783
+ sendViaHttpSse({
784
+ auth: { access_token: 'test-token' },
785
+ body: { model: 'gpt-5.5', input: [] },
786
+ fetchFn: async () => {
787
+ calls += 1;
788
+ return new Response('busy', { status: 503 });
789
+ },
790
+ _sleepFn: async (ms) => { delays.push(ms); },
791
+ }),
792
+ /503/,
793
+ );
794
+ assert.equal(calls, 5, 'four retries plus the initial request');
795
+ assert.equal(delays.length, 4);
796
+ for (const [index, base] of [200, 400, 800, 1600].entries()) {
797
+ assert.ok(delays[index] >= base * 0.9 && delays[index] <= base * 1.1);
798
+ }
799
+
800
+ calls = 0;
801
+ await assert.rejects(
802
+ sendViaHttpSse({
803
+ auth: { access_token: 'test-token' },
804
+ body: { model: 'gpt-5.5', input: [] },
805
+ fetchFn: async () => {
806
+ calls += 1;
807
+ return new Response('limited', { status: 429 });
808
+ },
809
+ _sleepFn: async () => assert.fail('429 must not retry'),
810
+ }),
811
+ /429/,
812
+ );
813
+ assert.equal(calls, 1);
814
+ });