mixdog 0.9.52 → 0.9.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/bench-run.mjs +2 -2
- package/scripts/compact-pressure-test.mjs +104 -0
- package/scripts/desktop-session-bridge-test.mjs +704 -0
- package/scripts/freevar-smoke.mjs +7 -4
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/lifecycle-api-test.mjs +65 -4
- package/scripts/max-output-recovery-test.mjs +31 -0
- package/scripts/memory-core-input-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
- package/scripts/openai-ws-early-settle-test.mjs +40 -0
- package/scripts/parent-abort-link-test.mjs +24 -0
- package/scripts/process-lifecycle-test.mjs +91 -22
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +326 -11
- package/scripts/provider-toolcall-test.mjs +269 -27
- package/scripts/session-orphan-sweep-test.mjs +27 -1
- package/src/lib/keychain-cjs.cjs +36 -23
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
- package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
- package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
- package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +96 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
- package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
- package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +6 -6
- package/src/runtime/channels/lib/config.mjs +15 -2
- package/src/runtime/channels/lib/memory-client.mjs +20 -3
- package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
- package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
- package/src/runtime/channels/lib/worker-main.mjs +16 -5
- package/src/runtime/memory/index.mjs +24 -198
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
- package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -122
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
- package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
- package/src/runtime/search/lib/http-fetch.mjs +1 -1
- package/src/runtime/shared/config.mjs +58 -13
- package/src/runtime/shared/llm/cost.mjs +14 -4
- package/src/runtime/shared/memory-snapshot.mjs +245 -0
- package/src/runtime/shared/process-lifecycle.mjs +184 -34
- package/src/runtime/shared/process-shutdown.mjs +6 -0
- package/src/runtime/shared/resource-admission.mjs +7 -2
- package/src/session-runtime/channel-config-api.mjs +7 -7
- package/src/session-runtime/config-lifecycle.mjs +20 -17
- package/src/session-runtime/cwd-plugins.mjs +9 -7
- package/src/session-runtime/env.mjs +1 -2
- package/src/session-runtime/hitch-profile.mjs +45 -0
- package/src/session-runtime/lifecycle-api.mjs +36 -9
- package/src/session-runtime/mcp-glue.mjs +6 -11
- package/src/session-runtime/provider-init-key.mjs +17 -0
- package/src/session-runtime/runtime-core.mjs +44 -103
- package/src/session-runtime/runtime-paths.mjs +20 -0
- package/src/session-runtime/runtime-tool-routing.mjs +55 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/tool-catalog-data.mjs +51 -0
- package/src/session-runtime/tool-catalog.mjs +15 -89
- package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
- package/src/standalone/agent-tool/worker-rows.mjs +93 -0
- package/src/standalone/agent-tool.mjs +12 -162
- package/src/standalone/channel-admin.mjs +29 -0
- package/src/tui/App.jsx +11 -5
- package/src/tui/dist/index.mjs +233 -65
- package/src/tui/engine/session-api-ext.mjs +37 -4
- package/src/tui/engine/turn.mjs +31 -0
- package/src/tui/index.jsx +1 -0
- package/src/tui/lib/voice-setup.mjs +5 -5
- package/scripts/_devtools-stub.mjs +0 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -288
|
@@ -7,6 +7,7 @@ import { normalizeCoreInput } from '../src/runtime/memory/lib/core-memory-store.
|
|
|
7
7
|
import { createMemoryActionHandlers } from '../src/runtime/memory/lib/memory-action-handlers.mjs'
|
|
8
8
|
import { TOOL_DEFS as MEMORY_TOOL_DEFS } from '../src/runtime/memory/tool-defs.mjs'
|
|
9
9
|
import { parseMemoryCandidateRows, parseMemoryCoreRows } from '../src/tui/app/input-parsers.mjs'
|
|
10
|
+
import { memoryToolArgsForCaller } from '../src/session-runtime/runtime-core.mjs'
|
|
10
11
|
|
|
11
12
|
test('memory mutation schema omits category while recall keeps its internal filter', () => {
|
|
12
13
|
const memoryTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'memory')
|
|
@@ -16,6 +17,15 @@ test('memory mutation schema omits category while recall keeps its internal filt
|
|
|
16
17
|
assert.equal(Object.hasOwn(recallTool.inputSchema.properties, 'category'), true)
|
|
17
18
|
})
|
|
18
19
|
|
|
20
|
+
test('memory tool calls inherit the active caller cwd only when cwd is omitted', () => {
|
|
21
|
+
assert.deepEqual(memoryToolArgsForCaller({ action: 'status' }, '/active/project'), {
|
|
22
|
+
action: 'status',
|
|
23
|
+
cwd: '/active/project',
|
|
24
|
+
})
|
|
25
|
+
const explicit = { action: 'status', cwd: '/explicit/project' }
|
|
26
|
+
assert.equal(memoryToolArgsForCaller(explicit, '/active/project'), explicit)
|
|
27
|
+
})
|
|
28
|
+
|
|
19
29
|
test('core content aliases summary, derives an element, and accepts no category', () => {
|
|
20
30
|
const content = 'A durable preference that callers should receive concise answers.'
|
|
21
31
|
const input = normalizeCoreInput({ content }, {
|
|
@@ -4,7 +4,11 @@ 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 {
|
|
7
|
+
import {
|
|
8
|
+
sendViaHttpSse,
|
|
9
|
+
_shouldUseOpenAIHttpFallback,
|
|
10
|
+
} from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
|
|
11
|
+
import { applyAskTerminalUsageTotals } from '../src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs';
|
|
8
12
|
import {
|
|
9
13
|
_clearWebSocketPoolForTest,
|
|
10
14
|
_closeAllPooledSockets,
|
|
@@ -154,6 +158,83 @@ test('acquire timeout reconnects successfully with progress, not a terminal WS e
|
|
|
154
158
|
}
|
|
155
159
|
});
|
|
156
160
|
|
|
161
|
+
test('OAuth WS-handshake HTTP 429 is terminal and cannot enter the HTTP fallback gate', async () => {
|
|
162
|
+
const savedEnv = Object.fromEntries([
|
|
163
|
+
'MIXDOG_OAI_TRANSPORT',
|
|
164
|
+
'MIXDOG_OPENAI_HTTP_FALLBACK',
|
|
165
|
+
'MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK',
|
|
166
|
+
'MIXDOG_OPENAI_OAUTH_WS_WARMUP',
|
|
167
|
+
'MIXDOG_AGENT_TRACE_DISABLE',
|
|
168
|
+
].map((name) => [name, process.env[name]]));
|
|
169
|
+
Object.assign(process.env, {
|
|
170
|
+
MIXDOG_OAI_TRANSPORT: 'auto',
|
|
171
|
+
MIXDOG_OPENAI_HTTP_FALLBACK: '1',
|
|
172
|
+
MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK: '1',
|
|
173
|
+
MIXDOG_OPENAI_OAUTH_WS_WARMUP: '0',
|
|
174
|
+
MIXDOG_AGENT_TRACE_DISABLE: '1',
|
|
175
|
+
});
|
|
176
|
+
let acquires = 0;
|
|
177
|
+
let sleeps = 0;
|
|
178
|
+
let httpCalls = 0;
|
|
179
|
+
const provider = new OpenAIOAuthProvider({});
|
|
180
|
+
provider.ensureAuth = async () => ({ access_token: 'test-token' });
|
|
181
|
+
try {
|
|
182
|
+
await assert.rejects(
|
|
183
|
+
provider.send([], 'gpt-5.5', [], {
|
|
184
|
+
sessionId: 'openai-oauth-ws-429-test',
|
|
185
|
+
_prebuiltBody: { model: 'gpt-5.5', input: [] },
|
|
186
|
+
_sendViaWebSocketFn: async (args) => {
|
|
187
|
+
try {
|
|
188
|
+
return await sendViaWebSocket({
|
|
189
|
+
...args,
|
|
190
|
+
_acquireWithRetryFn: async () => {
|
|
191
|
+
acquires += 1;
|
|
192
|
+
throw Object.assign(new Error('handshake rate limited'), { httpStatus: 429 });
|
|
193
|
+
},
|
|
194
|
+
_sleepFn: async () => { sleeps += 1; },
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// Force the provider's real exhausted-WS gate; the
|
|
198
|
+
// OAuth predicate itself must still reject this 429.
|
|
199
|
+
err.wsRetriesExhausted = true;
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
_sendViaHttpSseFn: async () => {
|
|
204
|
+
httpCalls += 1;
|
|
205
|
+
return { content: 'must not fallback', toolCalls: [], usage: {} };
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
(err) => err?.httpStatus === 429,
|
|
209
|
+
);
|
|
210
|
+
assert.equal(_shouldUseOpenAIHttpFallback({ httpStatus: 429, wsRetriesExhausted: true }), false);
|
|
211
|
+
assert.equal(acquires, 1, 'OAuth 429 must not consume the five-retry WS budget');
|
|
212
|
+
assert.equal(sleeps, 0, 'OAuth 429 must not schedule WS reconnect backoff');
|
|
213
|
+
assert.equal(httpCalls, 0, 'OAuth 429 must not enter HTTP/SSE fallback');
|
|
214
|
+
} finally {
|
|
215
|
+
for (const [name, value] of Object.entries(savedEnv)) {
|
|
216
|
+
if (value == null) delete process.env[name];
|
|
217
|
+
else process.env[name] = value;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('non-OAuth WS handshakes retain their existing 429 retry budget', async () => {
|
|
223
|
+
let acquires = 0;
|
|
224
|
+
await assert.rejects(
|
|
225
|
+
sendViaWebSocket(wsArgs({
|
|
226
|
+
traceProvider: 'xai',
|
|
227
|
+
_acquireWithRetryFn: async () => {
|
|
228
|
+
acquires += 1;
|
|
229
|
+
throw Object.assign(new Error('handshake rate limited'), { httpStatus: 429 });
|
|
230
|
+
},
|
|
231
|
+
_sleepFn: async () => {},
|
|
232
|
+
})),
|
|
233
|
+
(err) => err?.httpStatus === 429 && err?.wsRetriesExhausted === true,
|
|
234
|
+
);
|
|
235
|
+
assert.equal(acquires, 6, 'non-OAuth 429 preserves the five-retry WS budget');
|
|
236
|
+
});
|
|
237
|
+
|
|
157
238
|
for (const failure of ['callback error', 'callback timeout']) {
|
|
158
239
|
test(`send ${failure} drops the socket and retries on a fresh one`, async () => {
|
|
159
240
|
const acquires = [];
|
|
@@ -247,6 +328,42 @@ test('pre-response 1006 opens a fresh WS and replays the same request', async ()
|
|
|
247
328
|
assert.doesNotMatch(stderr, /mid-stream recovered|Reconnecting/);
|
|
248
329
|
});
|
|
249
330
|
|
|
331
|
+
test('completed warmup usage survives a same-send WS retry exactly once', async () => {
|
|
332
|
+
let streams = 0;
|
|
333
|
+
const result = await sendViaWebSocket(wsArgs({
|
|
334
|
+
warmupBody: { model: 'gpt-5.5', instructions: 'base', input: [], generate: false },
|
|
335
|
+
_acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
|
|
336
|
+
_streamFn: async ({ state }) => {
|
|
337
|
+
streams += 1;
|
|
338
|
+
if (state.warmup) {
|
|
339
|
+
return {
|
|
340
|
+
content: '',
|
|
341
|
+
model: 'gpt-5.5',
|
|
342
|
+
toolCalls: [],
|
|
343
|
+
usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 4, promptTokens: 10 },
|
|
344
|
+
responseId: 'warm-retry-1',
|
|
345
|
+
responseItems: [],
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (streams === 2) throw close1006();
|
|
349
|
+
return {
|
|
350
|
+
content: 'recovered',
|
|
351
|
+
model: 'gpt-5.5',
|
|
352
|
+
toolCalls: [],
|
|
353
|
+
usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 8, promptTokens: 20 },
|
|
354
|
+
responseId: 'main-retry-1',
|
|
355
|
+
responseItems: [],
|
|
356
|
+
closeSocket: true,
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
}));
|
|
360
|
+
assert.equal(streams, 3, 'one warmup plus two main attempts');
|
|
361
|
+
assert.equal(result.usage.inputTokens, 30);
|
|
362
|
+
assert.equal(result.usage.outputTokens, 2);
|
|
363
|
+
assert.equal(result.usage.mainInputTokens, 20);
|
|
364
|
+
assert.equal(result.usage.warmupInputTokens, 10);
|
|
365
|
+
});
|
|
366
|
+
|
|
250
367
|
test('successful iteration emits one compact send-spans row', async () => {
|
|
251
368
|
const rows = [];
|
|
252
369
|
const result = await sendViaWebSocket(wsArgs({
|
|
@@ -311,7 +428,7 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
311
428
|
Object.assign(process.env, {
|
|
312
429
|
MIXDOG_OAI_TRANSPORT: 'auto',
|
|
313
430
|
MIXDOG_OPENAI_HTTP_FALLBACK: '1',
|
|
314
|
-
MIXDOG_OPENAI_OAUTH_WS_WARMUP: '
|
|
431
|
+
MIXDOG_OPENAI_OAUTH_WS_WARMUP: '1',
|
|
315
432
|
MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK: '0',
|
|
316
433
|
MIXDOG_AGENT_TRACE_DISABLE: '1',
|
|
317
434
|
});
|
|
@@ -327,7 +444,17 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
327
444
|
...args,
|
|
328
445
|
_acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
|
|
329
446
|
_sendFrameFn: async () => {},
|
|
330
|
-
_streamFn: async () => {
|
|
447
|
+
_streamFn: async ({ state }) => {
|
|
448
|
+
if (state.warmup) {
|
|
449
|
+
return {
|
|
450
|
+
content: '',
|
|
451
|
+
model: 'gpt-5.5',
|
|
452
|
+
toolCalls: [],
|
|
453
|
+
usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 4, promptTokens: 10 },
|
|
454
|
+
responseId: 'warm-fallback-1',
|
|
455
|
+
responseItems: [],
|
|
456
|
+
};
|
|
457
|
+
}
|
|
331
458
|
streamAttempts += 1;
|
|
332
459
|
throw close1006();
|
|
333
460
|
},
|
|
@@ -335,7 +462,11 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
335
462
|
}),
|
|
336
463
|
_sendViaHttpSseFn: async () => {
|
|
337
464
|
httpCalls += 1;
|
|
338
|
-
return {
|
|
465
|
+
return {
|
|
466
|
+
content: 'http-recovered',
|
|
467
|
+
toolCalls: [],
|
|
468
|
+
usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 8, promptTokens: 20 },
|
|
469
|
+
};
|
|
339
470
|
},
|
|
340
471
|
};
|
|
341
472
|
const result = await provider.send([], 'gpt-5.5', [], sendOpts);
|
|
@@ -345,6 +476,15 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
345
476
|
assert.equal(httpCalls, 2);
|
|
346
477
|
assert.equal(result.content, 'http-recovered');
|
|
347
478
|
assert.equal(stickyResult.content, 'http-recovered');
|
|
479
|
+
assert.equal(result.usage.inputTokens, 30, 'fallback bills the completed WS warmup once');
|
|
480
|
+
assert.equal(result.usage.mainInputTokens, 20, 'fallback context remains HTTP-main-only');
|
|
481
|
+
assert.equal(result.usage.warmupInputTokens, 10);
|
|
482
|
+
assert.equal(stickyResult.usage.inputTokens, 20, 'sticky HTTP sends do not rebill the old warmup');
|
|
483
|
+
assert.equal(stickyResult.usage.warmupInputTokens, undefined);
|
|
484
|
+
const session = { provider: 'openai-oauth' };
|
|
485
|
+
applyAskTerminalUsageTotals(session, { usage: result.usage, lastTurnUsage: result.usage });
|
|
486
|
+
assert.equal(session.totalInputTokens, 30, 'lifetime billing includes WS warmup plus HTTP main once');
|
|
487
|
+
assert.equal(session.lastContextTokens, 20, 'last context remains HTTP-main-only');
|
|
348
488
|
assert.equal(provider._httpFallbackUntilByPoolKey.get(sendOpts.sessionId), Number.POSITIVE_INFINITY);
|
|
349
489
|
} finally {
|
|
350
490
|
for (const [name, value] of Object.entries(savedEnv)) {
|
|
@@ -12,6 +12,7 @@ import test from 'node:test';
|
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
13
|
import { EventEmitter } from 'node:events';
|
|
14
14
|
import { _streamResponse } from '../src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs';
|
|
15
|
+
import { classifyMidstreamError, MIDSTREAM_RETRY_POLICY } from '../src/runtime/agent/orchestrator/providers/retry-classifier.mjs';
|
|
15
16
|
|
|
16
17
|
// Minimal fake WS: matches the .on/.off/.close/.ping surface _streamResponse
|
|
17
18
|
// uses. close() is a no-op here -- the early-settle path sets done=true before
|
|
@@ -174,3 +175,42 @@ test('ws early settle: function args.done without item.done does NOT early-settl
|
|
|
174
175
|
await assert.rejects(p, /inter-chunk inactivity/);
|
|
175
176
|
assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'function item must stay active until output_item.done');
|
|
176
177
|
});
|
|
178
|
+
|
|
179
|
+
test('OpenAI WS rejects an oversized Buffer before UTF-8 conversion and marks it retryable', async () => {
|
|
180
|
+
const socket = new FakeSocket();
|
|
181
|
+
const state = { attemptIndex: 0 };
|
|
182
|
+
const payload = Buffer.alloc(33);
|
|
183
|
+
payload.toString = () => { throw new Error('oversized Buffer was decoded'); };
|
|
184
|
+
const p = _streamResponse({
|
|
185
|
+
entry: { socket },
|
|
186
|
+
state,
|
|
187
|
+
_timeouts: { ...FAST_TIMEOUTS, maxIncomingFrameBytes: 32 },
|
|
188
|
+
});
|
|
189
|
+
socket.emit('message', payload);
|
|
190
|
+
await assert.rejects(p, (error) => {
|
|
191
|
+
assert.equal(error.code, 'EOPENAIWSFRAMETOOLARGE');
|
|
192
|
+
assert.equal(error.retryable, true);
|
|
193
|
+
assert.match(error.message, /33 bytes; limit 32 bytes.*retryable/);
|
|
194
|
+
assert.equal(
|
|
195
|
+
classifyMidstreamError(error, state, MIDSTREAM_RETRY_POLICY.ws),
|
|
196
|
+
'ws_frame_too_large',
|
|
197
|
+
);
|
|
198
|
+
return true;
|
|
199
|
+
});
|
|
200
|
+
assert.deepEqual(socket.closed, { code: 1009, reason: 'frame_too_large' });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('OpenAI WS preserves normal-size Buffer handling', async () => {
|
|
204
|
+
const socket = new FakeSocket();
|
|
205
|
+
const p = _streamResponse({
|
|
206
|
+
entry: { socket },
|
|
207
|
+
state: {},
|
|
208
|
+
_timeouts: { ...FAST_TIMEOUTS, maxIncomingFrameBytes: 1024 },
|
|
209
|
+
});
|
|
210
|
+
socket.emit('message', Buffer.from(JSON.stringify({
|
|
211
|
+
type: 'response.completed',
|
|
212
|
+
response: { id: 'resp_normal', model: 'gpt-5.5', output: [] },
|
|
213
|
+
})));
|
|
214
|
+
const result = await p;
|
|
215
|
+
assert.equal(result.responseId, 'resp_normal');
|
|
216
|
+
});
|
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
markSessionCancelled,
|
|
7
7
|
markSessionError,
|
|
8
8
|
} from '../src/runtime/agent/orchestrator/session/manager.mjs';
|
|
9
|
+
import {
|
|
10
|
+
_clearSessionRuntime,
|
|
11
|
+
_sweepTerminalSessionRuntimes,
|
|
12
|
+
} from '../src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs';
|
|
9
13
|
|
|
10
14
|
test('markSessionError drops parent AbortSignal listener but keeps runtime entry', () => {
|
|
11
15
|
const sessionId = `parent-abort-error-${Date.now()}`;
|
|
@@ -64,3 +68,23 @@ test('markSessionCancelled drops parent AbortSignal listener but keeps runtime e
|
|
|
64
68
|
assert.equal(childAborted, false);
|
|
65
69
|
});
|
|
66
70
|
|
|
71
|
+
test('terminal runtime sweep drains the full backlog but preserves in-flight controllers', () => {
|
|
72
|
+
const prefix = `terminal-retention-${Date.now()}`;
|
|
73
|
+
const terminalIds = Array.from({ length: 40 }, (_, i) => `${prefix}-${i}`);
|
|
74
|
+
for (const id of terminalIds) {
|
|
75
|
+
linkParentSignalToSession(id, new AbortController().signal);
|
|
76
|
+
markSessionError(id, 'finished');
|
|
77
|
+
getSessionRuntime(id).controller = null;
|
|
78
|
+
}
|
|
79
|
+
const activeId = `${prefix}-active`;
|
|
80
|
+
linkParentSignalToSession(activeId, new AbortController().signal);
|
|
81
|
+
markSessionError(activeId, 'provider still unwinding');
|
|
82
|
+
|
|
83
|
+
const cleaned = _sweepTerminalSessionRuntimes();
|
|
84
|
+
|
|
85
|
+
assert.ok(cleaned >= terminalIds.length, 'one pass drains every settled terminal entry');
|
|
86
|
+
for (const id of terminalIds) assert.equal(getSessionRuntime(id), null);
|
|
87
|
+
assert.ok(getSessionRuntime(activeId)?.controller, 'active in-flight controller is retained');
|
|
88
|
+
_clearSessionRuntime(activeId);
|
|
89
|
+
});
|
|
90
|
+
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
mkdirSync,
|
|
5
5
|
mkdtempSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
readdirSync,
|
|
7
8
|
rmSync,
|
|
8
9
|
unlinkSync,
|
|
9
10
|
utimesSync,
|
|
@@ -103,71 +104,118 @@ test('vanished evidence uses only old-process fields and both ledger files stay
|
|
|
103
104
|
}
|
|
104
105
|
});
|
|
105
106
|
|
|
106
|
-
test('PID reuse is detected
|
|
107
|
+
test('PID reuse is detected once across a finish/rebegin while uncertain live identity is preserved', async () => {
|
|
107
108
|
const root = tempRoot();
|
|
109
|
+
let child;
|
|
108
110
|
try {
|
|
109
111
|
const paths = lifecyclePathsForTest(root);
|
|
110
112
|
mkdirSync(paths.markerDir, { recursive: true });
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
child = spawn(process.execPath, ['--input-type=module', '--eval', `
|
|
114
|
+
import { beginProcessLifecycle } from './src/runtime/shared/process-lifecycle.mjs';
|
|
115
|
+
beginProcessLifecycle({ directory: process.env.LEDGER_DIR, configureReports: false });
|
|
116
|
+
setTimeout(() => {}, 10000);
|
|
117
|
+
`], {
|
|
118
|
+
cwd: REPO_ROOT,
|
|
119
|
+
env: { ...process.env, LEDGER_DIR: root },
|
|
120
|
+
stdio: 'ignore',
|
|
121
|
+
});
|
|
122
|
+
let childMarker;
|
|
123
|
+
let currentIdentity;
|
|
124
|
+
const deadline = Date.now() + 5000;
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
const name = readdirSync(paths.markerDir).find((entry) => entry.startsWith(`${child.pid}-`));
|
|
127
|
+
if (name) {
|
|
128
|
+
childMarker = join(paths.markerDir, name);
|
|
129
|
+
try {
|
|
130
|
+
currentIdentity = JSON.parse(readFileSync(childMarker, 'utf8')).processIdentity;
|
|
131
|
+
} catch {
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (currentIdentity?.kind === 'linux-start-ticks' || currentIdentity?.method === 'powershell') break;
|
|
136
|
+
}
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
138
|
+
}
|
|
113
139
|
assert.ok(currentIdentity);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const
|
|
140
|
+
if (process.platform === 'win32') assert.equal(currentIdentity.method, 'powershell');
|
|
141
|
+
|
|
142
|
+
const reused = join(paths.markerDir, `${child.pid}-reused.json`);
|
|
143
|
+
const matching = join(paths.markerDir, `${child.pid}-matching.json`);
|
|
144
|
+
const uncertain = join(paths.markerDir, `${child.pid}-uncertain.json`);
|
|
145
|
+
const malformed = join(paths.markerDir, `${child.pid}-malformed.json`);
|
|
146
|
+
const clockAmbiguous = join(paths.markerDir, `${child.pid}-clock.json`);
|
|
147
|
+
const differentKind = join(paths.markerDir, `${child.pid}-different-kind.json`);
|
|
148
|
+
const mixedTransient = join(paths.markerDir, `${child.pid}-mixed-transient.json`);
|
|
149
|
+
const samePidForeign = join(paths.markerDir, `${process.pid}-same-pid-foreign.json`);
|
|
121
150
|
const reusedIdentity = currentIdentity.kind === 'linux-start-ticks'
|
|
122
151
|
? { kind: currentIdentity.kind, value: String(BigInt(currentIdentity.value) + 1n) }
|
|
123
|
-
: {
|
|
152
|
+
: { ...currentIdentity, value: currentIdentity.value + 1 };
|
|
124
153
|
writeFileSync(reused, JSON.stringify({
|
|
125
|
-
pid:
|
|
126
|
-
ppid: process.ppid,
|
|
154
|
+
pid: child.pid,
|
|
127
155
|
token: 'reused',
|
|
128
156
|
processIdentity: reusedIdentity,
|
|
129
157
|
}));
|
|
130
158
|
writeFileSync(matching, JSON.stringify({
|
|
131
|
-
pid:
|
|
159
|
+
pid: child.pid,
|
|
132
160
|
token: 'matching',
|
|
133
161
|
processIdentity: currentIdentity,
|
|
134
162
|
}));
|
|
135
163
|
writeFileSync(uncertain, JSON.stringify({
|
|
136
|
-
pid:
|
|
137
|
-
ppid: process.ppid,
|
|
164
|
+
pid: child.pid,
|
|
138
165
|
token: 'uncertain',
|
|
139
166
|
}));
|
|
140
167
|
writeFileSync(malformed, JSON.stringify({
|
|
141
|
-
pid:
|
|
142
|
-
ppid: process.ppid,
|
|
168
|
+
pid: child.pid,
|
|
143
169
|
token: 'malformed',
|
|
144
170
|
processIdentity: process.platform === 'linux'
|
|
145
171
|
? { kind: 'linux-start-ticks', value: 'not-a-number' }
|
|
146
172
|
: { kind: 'start-seconds', value: 'not-a-number' },
|
|
147
173
|
}));
|
|
148
174
|
writeFileSync(clockAmbiguous, JSON.stringify({
|
|
149
|
-
pid:
|
|
150
|
-
ppid: process.ppid,
|
|
175
|
+
pid: child.pid,
|
|
151
176
|
token: 'clock-adjusted',
|
|
152
177
|
processIdentity: { kind: 'legacy-wall-clock', value: Date.now() + 86400000 },
|
|
153
178
|
}));
|
|
154
179
|
writeFileSync(differentKind, JSON.stringify({
|
|
155
|
-
pid:
|
|
180
|
+
pid: child.pid,
|
|
156
181
|
token: 'different-kind',
|
|
157
182
|
processIdentity: currentIdentity.kind === 'linux-start-ticks'
|
|
158
183
|
? { kind: 'start-seconds', value: 1 }
|
|
159
184
|
: { kind: 'linux-start-ticks', value: '1' },
|
|
160
185
|
}));
|
|
186
|
+
if (currentIdentity.kind === 'start-seconds') {
|
|
187
|
+
writeFileSync(mixedTransient, JSON.stringify({
|
|
188
|
+
pid: child.pid,
|
|
189
|
+
token: 'mixed-transient',
|
|
190
|
+
processIdentity: { kind: currentIdentity.kind, value: currentIdentity.value + 1, method: 'uptime' },
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
writeFileSync(samePidForeign, JSON.stringify({
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
token: 'same-pid-foreign',
|
|
196
|
+
processIdentity: currentIdentity,
|
|
197
|
+
}));
|
|
198
|
+
beginProcessLifecycle({ directory: root, configureReports: false });
|
|
199
|
+
assert.equal(finishProcessLifecycle('clean-shutdown', 0), true);
|
|
161
200
|
beginProcessLifecycle({ directory: root, configureReports: false });
|
|
201
|
+
const reapDeadline = Date.now() + 5000;
|
|
202
|
+
while (existsSync(reused) && Date.now() < reapDeadline) {
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
204
|
+
}
|
|
162
205
|
finishProcessLifecycle('clean-shutdown', 0);
|
|
163
|
-
assert.equal(entries(paths.ledger).filter((row) => row.reason === 'prior-process-vanished').length,
|
|
206
|
+
assert.equal(entries(paths.ledger).filter((row) => row.reason === 'prior-process-vanished').length, 2);
|
|
164
207
|
assert.equal(existsSync(reused), false);
|
|
165
208
|
assert.equal(existsSync(matching), true);
|
|
166
209
|
assert.equal(existsSync(uncertain), true);
|
|
167
210
|
assert.equal(existsSync(malformed), true);
|
|
168
211
|
assert.equal(existsSync(clockAmbiguous), true);
|
|
169
212
|
assert.equal(existsSync(differentKind), true);
|
|
213
|
+
assert.equal(existsSync(samePidForeign), false);
|
|
214
|
+
assert.equal(existsSync(childMarker), true);
|
|
215
|
+
if (currentIdentity.kind === 'start-seconds') assert.equal(existsSync(mixedTransient), true);
|
|
170
216
|
} finally {
|
|
217
|
+
child?.kill();
|
|
218
|
+
if (child) await childExit(child);
|
|
171
219
|
rmSync(root, { recursive: true, force: true });
|
|
172
220
|
}
|
|
173
221
|
});
|
|
@@ -315,6 +363,27 @@ test('cleanup rejection and timeout are forced cleanup, not clean shutdown', asy
|
|
|
315
363
|
}
|
|
316
364
|
});
|
|
317
365
|
|
|
366
|
+
test('uncaughtException synchronously restores terminal modes before cleanup', () => {
|
|
367
|
+
const source = `
|
|
368
|
+
import { installProcessSignalCleanup } from './src/runtime/shared/process-shutdown.mjs';
|
|
369
|
+
const reset = '\\x1b[?1000l\\x1b[?1002l\\x1b[?1006l\\x1b[?1049l';
|
|
370
|
+
installProcessSignalCleanup({
|
|
371
|
+
signals: [],
|
|
372
|
+
exit: false,
|
|
373
|
+
restoreTerminal: () => process.stdout.write(reset),
|
|
374
|
+
cleanup: async () => {},
|
|
375
|
+
log: () => {},
|
|
376
|
+
});
|
|
377
|
+
throw new Error('terminal fixture crash');
|
|
378
|
+
`;
|
|
379
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
|
380
|
+
cwd: REPO_ROOT,
|
|
381
|
+
encoding: 'utf8',
|
|
382
|
+
});
|
|
383
|
+
assert.equal(result.status, 0, result.stderr);
|
|
384
|
+
assert.equal(result.stdout, '\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1049l');
|
|
385
|
+
});
|
|
386
|
+
|
|
318
387
|
test('Node report is compact, excludes environment, and rotates to one previous report', () => {
|
|
319
388
|
const root = tempRoot();
|
|
320
389
|
try {
|
|
@@ -340,14 +340,14 @@ test('xAI legacy cache-lane knobs cannot create a secondary queue or timeout', a
|
|
|
340
340
|
assert.deepEqual((await Promise.all([first, second])).map((item) => item.value), [0, 1]);
|
|
341
341
|
});
|
|
342
342
|
|
|
343
|
-
test('Anthropic
|
|
343
|
+
test('Anthropic OAuth subscription 429 fails fast while API-key 429 retries request-locally', async () => {
|
|
344
344
|
const oauth = Object.create(AnthropicOAuthProvider.prototype);
|
|
345
345
|
oauth.credentials = { accessToken: 'fixture', expiresAt: Date.now() + 60_000 };
|
|
346
346
|
oauth.config = {};
|
|
347
347
|
oauth.fastModeBetaHeaderLatched = false;
|
|
348
348
|
oauth.ensureAuth = async () => oauth.credentials;
|
|
349
349
|
let oauthAttempts = 0;
|
|
350
|
-
|
|
350
|
+
await assert.rejects(oauth.send([], 'claude-sonnet-4-5', [], {
|
|
351
351
|
_doRequestFn: async () => {
|
|
352
352
|
oauthAttempts += 1;
|
|
353
353
|
return {
|
|
@@ -364,9 +364,8 @@ test('Anthropic providers retry request-local pre-output 429 responses', async (
|
|
|
364
364
|
_parseSSEFn: async () => ({
|
|
365
365
|
content: 'oauth-ok', model: 'claude', toolCalls: [], usage: {},
|
|
366
366
|
}),
|
|
367
|
-
});
|
|
368
|
-
assert.equal(
|
|
369
|
-
assert.equal(oauthAttempts, 2);
|
|
367
|
+
}), (error) => error?.status === 429 && error?.retryAfterMs === 0);
|
|
368
|
+
assert.equal(oauthAttempts, 1);
|
|
370
369
|
|
|
371
370
|
const direct = Object.create(AnthropicProvider.prototype);
|
|
372
371
|
direct.name = 'anthropic';
|