mixdog 0.9.50 → 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 (134) 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/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -11,13 +11,14 @@ import test from 'node:test';
11
11
  import assert from 'node:assert/strict';
12
12
  import {
13
13
  applyDeferredToolSurface,
14
+ rebuildDeferredToolSurfaceForProvider,
14
15
  refreshInitialDeferredMcpSurface,
15
16
  reconcileDeferredMcpToolCatalog,
16
17
  } from '../src/session-runtime/tool-catalog.mjs';
17
18
 
18
- function baseSession() {
19
+ function baseSession(provider = 'anthropic-oauth') {
19
20
  return {
20
- provider: 'legacy',
21
+ provider,
21
22
  tools: [{ name: 'read', description: 'read a file' }, { name: 'grep', description: 'search' }],
22
23
  messages: [{ role: 'system', content: 'BASE PROMPT' }],
23
24
  };
@@ -26,9 +27,9 @@ function baseSession() {
26
27
  // A freshly-created session: the create-time surface is baked WITHOUT any MCP
27
28
  // (server still mid-handshake at create). `shell` is a deferred (non-active)
28
29
  // standalone tool so BP1 carries a manifest block even before MCP arrives.
29
- function createdSession() {
30
- const session = baseSession();
31
- applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: 'legacy' });
30
+ function createdSession(provider = 'anthropic-oauth') {
31
+ const session = baseSession(provider);
32
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: session.provider });
32
33
  return session;
33
34
  }
34
35
 
@@ -93,7 +94,7 @@ test('recreated session (MCP already connected at create) seeds its manifest, no
93
94
  // surface at create time, so a cwd-change recreate seeds its BP1 directly and
94
95
  // never needs the first-turn refresh.
95
96
  const session = baseSession();
96
- applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
97
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: session.provider });
97
98
  assert.ok(systemContent(session).includes('mcp__unity__get_scene'), 'MCP in recreated BP1');
98
99
  assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'recreated MCP pre-marked announced');
99
100
 
@@ -128,7 +129,7 @@ test('a second newly-connected MCP tool re-renders BP1 in place (both listed, st
128
129
  test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its next turn', () => {
129
130
  // A prior run baked BP1 with the MCP tool; resume reloads that transcript.
130
131
  const session = baseSession();
131
- applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
132
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: session.provider });
132
133
  const before = systemContent(session);
133
134
  const announcedBefore = [...session.deferredAnnouncedTools];
134
135
  // Resume path never sets the per-session fresh flag.
@@ -139,11 +140,86 @@ test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its
139
140
  assert.deepEqual([...session.deferredAnnouncedTools], announcedBefore, 'announced set unchanged on resume');
140
141
  });
141
142
 
142
- test('a fresh session (flagged) is refreshed exactly once, then falls to the late path', () => {
143
- const session = createdSession();
143
+ test('fresh-session refresh state is isolated, one-shot, and survives provider switches without leaking', () => {
144
+ const refreshedSession = createdSession();
145
+ const noOpSession = createdSession();
146
+ refreshedSession.deferredInitialRefreshPending = true;
147
+ noOpSession.deferredInitialRefreshPending = true;
148
+
149
+ assert.equal(firstTurnGate(refreshedSession, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
150
+ assert.equal(refreshedSession.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
151
+ assert.equal(noOpSession.deferredInitialRefreshPending, true, 'consuming one session does not consume another');
152
+ assert.ok(refreshedSession.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
153
+ assert.ok(!noOpSession.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'announced state does not leak between sessions');
154
+ assert.equal(firstTurnGate(refreshedSession, [mcpTool2]), 'late', 'second turn no longer refreshes');
155
+
156
+ rebuildDeferredToolSurfaceForProvider(noOpSession, 'xai');
157
+ noOpSession.provider = 'xai';
158
+ const beforeNoOp = systemContent(noOpSession);
159
+ assert.equal(beforeNoOp.includes('<available-deferred-tools>'), false, 'canonical switch removes native manifest state');
160
+ assert.equal(firstTurnGate(noOpSession, []), 'refresh-noop', 'canonical provider consumes an empty one-shot without a manifest refresh');
161
+ assert.equal(noOpSession.deferredInitialRefreshPending, false, 'no-op also consumes the fresh flag');
162
+ assert.equal(systemContent(noOpSession), beforeNoOp, 'canonical no-op leaves the initial manifest byte-identical');
163
+
164
+ rebuildDeferredToolSurfaceForProvider(noOpSession, 'anthropic-oauth');
165
+ noOpSession.provider = 'anthropic-oauth';
166
+ assert.equal(firstTurnGate(noOpSession, [mcpTool2]), 'late', 'switching back cannot resurrect first-turn refresh state');
167
+ assert.ok(!noOpSession.deferredAnnouncedTools.includes('mcp__unity__run_tests'), 'post-switch tool remains eligible for the late path');
168
+ });
169
+
170
+ test('legacy canonical snapshot gains a grace-connected MCP tool for the first turn and session lifetime', () => {
171
+ const session = createdSession('legacy');
144
172
  session.deferredInitialRefreshPending = true;
145
- assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
146
- assert.equal(session.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
147
- assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
148
- assert.equal(firstTurnGate(session, [mcpTool2]), 'late', 'second turn no longer refreshes');
173
+ const before = systemContent(session);
174
+
175
+ assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'grace fold updates the canonical snapshot');
176
+ assert.ok(session.tools.some((tool) => tool.name === mcpTool.name), 'tool is provider-visible on the first turn');
177
+ assert.ok(session.deferredCallableTools.includes(mcpTool.name), 'tool is callable for the session');
178
+ assert.ok(session.deferredToolCatalog.some((tool) => tool.name === mcpTool.name), 'tool persists in the session catalog');
179
+ assert.equal(systemContent(session), before, 'canonical refresh does not introduce a native manifest');
180
+ assert.equal(systemContent(session).includes('<available-deferred-tools>'), false);
181
+
182
+ const toolsOnce = JSON.stringify(session.tools);
183
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), false, 'repeated fold is a no-op');
184
+ assert.equal(JSON.stringify(session.tools), toolsOnce, 'repeated fold leaves the fixed surface byte-identical');
185
+ assert.equal(firstTurnGate(session, [mcpTool]), 'late', 'one-shot gate remains consumed');
186
+ });
187
+
188
+ test('legacy to Anthropic and back preserves grace MCP availability without manifest leakage or duplication', () => {
189
+ const session = createdSession('legacy');
190
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), true);
191
+
192
+ rebuildDeferredToolSurfaceForProvider(session, 'anthropic-oauth');
193
+ session.provider = 'anthropic-oauth';
194
+ assert.ok(session.deferredToolCatalog.some((tool) => tool.name === mcpTool.name), 'native switch retains the MCP catalog entry');
195
+ assert.ok(systemContent(session).includes(mcpTool.name), 'native switch advertises the retained deferred tool');
196
+ assert.equal((systemContent(session).match(/<available-deferred-tools>/g) || []).length, 1, 'native switch creates one manifest');
197
+
198
+ rebuildDeferredToolSurfaceForProvider(session, 'legacy');
199
+ session.provider = 'legacy';
200
+ assert.ok(session.tools.some((tool) => tool.name === mcpTool.name), 'canonical switch restores the MCP tool to the active surface');
201
+ assert.ok(session.deferredCallableTools.includes(mcpTool.name), 'canonical switch keeps the MCP tool callable');
202
+ assert.equal(systemContent(session).includes('<available-deferred-tools>'), false, 'native manifest does not leak back to canonical');
203
+ assert.deepEqual(session.deferredAnnouncedTools, [], 'native announcement state does not leak back to canonical');
204
+
205
+ rebuildDeferredToolSurfaceForProvider(session, 'anthropic-oauth');
206
+ session.provider = 'anthropic-oauth';
207
+ assert.equal((systemContent(session).match(/<available-deferred-tools>/g) || []).length, 1, 'repeat switch still creates only one manifest');
208
+ });
209
+
210
+ test('canonical MCP arriving after the first-turn grace keeps the fixed snapshot and emits no late reminder', () => {
211
+ const session = createdSession('legacy');
212
+ session.deferredInitialRefreshPending = true;
213
+ assert.equal(firstTurnGate(session, []), 'refresh-noop');
214
+ const before = JSON.stringify(session.tools);
215
+ let enqueued = null;
216
+
217
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool2], {
218
+ enqueue: (text) => { enqueued = text; return true; },
219
+ });
220
+ assert.equal(result, null, 'canonical reconciliation keeps the create/grace snapshot fixed');
221
+ assert.equal(JSON.stringify(session.tools), before, 'post-grace MCP does not churn the canonical surface');
222
+ assert.equal(session.deferredToolCatalog.some((tool) => tool.name === mcpTool2.name), false);
223
+ assert.equal(enqueued, null, 'canonical provider emits no native late reminder');
224
+ assert.equal(systemContent(session).includes('<available-deferred-tools>'), false);
149
225
  });
@@ -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
+ })
@@ -1,9 +1,20 @@
1
1
  // Regression: OpenAI OAuth WS abnormal-close recovery before visible output.
2
2
  import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
+ import { EventEmitter } from 'node:events';
4
5
  import { OpenAIOAuthProvider } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
5
6
  import { _acquireWithRetry, sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
6
- import { _sendFrame } from '../src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs';
7
+ import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
8
+ import {
9
+ _clearWebSocketPoolForTest,
10
+ _closeAllPooledSockets,
11
+ _resetOpenSocketDrainForTest,
12
+ _seedWebSocketEntryForTest,
13
+ _setOpenSocketForTest,
14
+ acquireWebSocket,
15
+ releaseWebSocket,
16
+ _sendFrame,
17
+ } from '../src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs';
7
18
 
8
19
  function close1006() {
9
20
  const err = new Error('WebSocket closed abnormally');
@@ -28,6 +39,65 @@ function wsArgs(overrides = {}) {
28
39
  };
29
40
  }
30
41
 
42
+ class PoolSocket extends EventEmitter {
43
+ constructor() {
44
+ super();
45
+ this.readyState = 1;
46
+ this._socket = { ref() {}, unref() {} };
47
+ }
48
+ close() { this.readyState = 3; }
49
+ ping() { queueMicrotask(() => this.emit('pong')); }
50
+ }
51
+
52
+ function poolEntry(responseId) {
53
+ return {
54
+ socket: new PoolSocket(),
55
+ busy: true,
56
+ closing: false,
57
+ ephemeral: false,
58
+ lastResponseId: responseId,
59
+ };
60
+ }
61
+
62
+ test('pool release/acquire preserves latest compatible chain and auth/cache boundaries', async (t) => {
63
+ t.after(() => _clearWebSocketPoolForTest());
64
+ const poolKey = 'reused-session-id';
65
+ const authA = { account_id: 'account-a', access_token: 'token-a' };
66
+ const authB = { account_id: 'account-b', access_token: 'token-b' };
67
+ const oldest = _seedWebSocketEntryForTest({
68
+ poolKey, auth: authA, cacheKey: 'cache-a', entry: poolEntry('resp-old'),
69
+ });
70
+ const latest = _seedWebSocketEntryForTest({
71
+ poolKey, auth: authA, cacheKey: 'cache-a', entry: poolEntry('resp-latest'),
72
+ });
73
+ const otherAccount = _seedWebSocketEntryForTest({
74
+ poolKey, auth: authB, cacheKey: 'cache-a', entry: poolEntry('resp-account-b'),
75
+ });
76
+ const otherCache = _seedWebSocketEntryForTest({
77
+ poolKey, auth: authA, cacheKey: 'cache-b', entry: poolEntry('resp-cache-b'),
78
+ });
79
+
80
+ releaseWebSocket({ entry: oldest, poolKey, keep: true });
81
+ releaseWebSocket({ entry: otherAccount, poolKey, keep: true });
82
+ releaseWebSocket({ entry: otherCache, poolKey, keep: true });
83
+ releaseWebSocket({ entry: latest, poolKey, keep: true });
84
+
85
+ const first = await acquireWebSocket({
86
+ auth: authA, poolKey, cacheKey: 'cache-a', forceFresh: false,
87
+ });
88
+ assert.equal(first.entry, latest, 'latest completed compatible chain wins');
89
+ assert.equal(first.reused, true);
90
+ assert.equal(latest.busy, true, 'acquire reserves the selected entry');
91
+
92
+ const second = await acquireWebSocket({
93
+ auth: authA, poolKey, cacheKey: 'cache-a', forceFresh: false,
94
+ });
95
+ assert.equal(second.entry, oldest, 'reserved latest entry cannot be acquired concurrently');
96
+ assert.equal(second.entry.lastResponseId, 'resp-old');
97
+ assert.notEqual(second.entry, otherAccount);
98
+ assert.notEqual(second.entry, otherCache);
99
+ });
100
+
31
101
  test('acquire timeout reconnects successfully with progress, not a terminal WS error', async () => {
32
102
  const oldWrite = process.stderr.write;
33
103
  const oldQuiet = process.env.MIXDOG_QUIET_PROVIDER_LOG;
@@ -169,9 +239,9 @@ test('pre-response 1006 opens a fresh WS and replays the same request', async ()
169
239
  stages.filter(({ stage }) => stage === 'reconnecting'),
170
240
  [{ stage: 'reconnecting', detail: {
171
241
  attempt: 1,
172
- max: 4,
242
+ max: 5,
173
243
  classifier: 'ws_1006',
174
- message: 'Reconnecting... 1/4',
244
+ message: 'Reconnecting... 1/5',
175
245
  } }],
176
246
  );
177
247
  assert.doesNotMatch(stderr, /mid-stream recovered|Reconnecting/);
@@ -203,6 +273,33 @@ test('successful iteration emits one compact send-spans row', async () => {
203
273
  assert.equal('body' in rows[0].payload, false);
204
274
  });
205
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
+
206
303
  test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async () => {
207
304
  const savedEnv = Object.fromEntries([
208
305
  'MIXDOG_OAI_TRANSPORT',
@@ -244,10 +341,11 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
244
341
  const result = await provider.send([], 'gpt-5.5', [], sendOpts);
245
342
  const stickyResult = await provider.send([], 'gpt-5.5', [], sendOpts);
246
343
 
247
- 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');
248
345
  assert.equal(httpCalls, 2);
249
346
  assert.equal(result.content, 'http-recovered');
250
347
  assert.equal(stickyResult.content, 'http-recovered');
348
+ assert.equal(provider._httpFallbackUntilByPoolKey.get(sendOpts.sessionId), Number.POSITIVE_INFINITY);
251
349
  } finally {
252
350
  for (const [name, value] of Object.entries(savedEnv)) {
253
351
  if (value == null) delete process.env[name];
@@ -256,6 +354,233 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
256
354
  }
257
355
  });
258
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);
402
+ } finally {
403
+ for (const [name, value] of Object.entries(savedEnv)) {
404
+ if (value == null) delete process.env[name];
405
+ else process.env[name] = value;
406
+ }
407
+ }
408
+ });
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
+
259
584
  test('sticky HTTP fallback is isolated by session and expired entries are cleaned', async () => {
260
585
  const savedEnv = Object.fromEntries([
261
586
  'MIXDOG_OAI_TRANSPORT',
@@ -425,3 +750,65 @@ test('post-emission 1006 refuses replay after text or tool output', async () =>
425
750
  assert.equal(acquires, 1, `${emitted} must prevent a replay`);
426
751
  }
427
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
+ });