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.
- package/package.json +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- 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
|
|
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:
|
|
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:
|
|
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:
|
|
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('
|
|
143
|
-
const
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
assert.
|
|
148
|
-
assert.
|
|
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 {
|
|
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:
|
|
242
|
+
max: 5,
|
|
173
243
|
classifier: 'ws_1006',
|
|
174
|
-
message: 'Reconnecting... 1/
|
|
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,
|
|
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
|
+
});
|