mixdog 0.9.52 → 0.9.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/bench-run.mjs +2 -2
  5. package/scripts/compact-pressure-test.mjs +104 -0
  6. package/scripts/desktop-session-bridge-test.mjs +704 -0
  7. package/scripts/freevar-smoke.mjs +7 -4
  8. package/scripts/gemini-provider-test.mjs +396 -1
  9. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  10. package/scripts/lifecycle-api-test.mjs +65 -4
  11. package/scripts/max-output-recovery-test.mjs +31 -0
  12. package/scripts/memory-core-input-test.mjs +10 -0
  13. package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
  14. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  15. package/scripts/parent-abort-link-test.mjs +24 -0
  16. package/scripts/process-lifecycle-test.mjs +91 -22
  17. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  18. package/scripts/provider-contract-test.mjs +326 -11
  19. package/scripts/provider-toolcall-test.mjs +269 -27
  20. package/scripts/session-orphan-sweep-test.mjs +27 -1
  21. package/src/lib/keychain-cjs.cjs +36 -23
  22. package/src/repl.mjs +11 -0
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  25. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  26. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
  29. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
  30. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
  31. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  32. package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
  33. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
  34. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
  35. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  36. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  37. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  38. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +96 -12
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  46. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  49. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
  50. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  51. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  52. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  53. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  54. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
  55. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  56. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  57. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  59. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  60. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  62. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  63. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  65. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  66. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  68. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  69. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  70. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  71. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  73. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  74. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  75. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  76. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/backends/discord.mjs +6 -6
  78. package/src/runtime/channels/lib/config.mjs +15 -2
  79. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  80. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  81. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  82. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  83. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  84. package/src/runtime/memory/index.mjs +24 -198
  85. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  86. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  87. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  89. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  90. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  91. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  92. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  93. package/src/runtime/shared/config.mjs +58 -13
  94. package/src/runtime/shared/llm/cost.mjs +14 -4
  95. package/src/runtime/shared/memory-snapshot.mjs +245 -0
  96. package/src/runtime/shared/process-lifecycle.mjs +184 -34
  97. package/src/runtime/shared/process-shutdown.mjs +6 -0
  98. package/src/runtime/shared/resource-admission.mjs +7 -2
  99. package/src/session-runtime/channel-config-api.mjs +7 -7
  100. package/src/session-runtime/config-lifecycle.mjs +20 -17
  101. package/src/session-runtime/cwd-plugins.mjs +9 -7
  102. package/src/session-runtime/env.mjs +1 -2
  103. package/src/session-runtime/hitch-profile.mjs +45 -0
  104. package/src/session-runtime/lifecycle-api.mjs +36 -9
  105. package/src/session-runtime/mcp-glue.mjs +6 -11
  106. package/src/session-runtime/provider-init-key.mjs +17 -0
  107. package/src/session-runtime/runtime-core.mjs +44 -103
  108. package/src/session-runtime/runtime-paths.mjs +20 -0
  109. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  110. package/src/session-runtime/session-turn-api.mjs +1 -0
  111. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  112. package/src/session-runtime/tool-catalog.mjs +15 -89
  113. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  114. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  115. package/src/standalone/agent-tool.mjs +12 -162
  116. package/src/standalone/channel-admin.mjs +29 -0
  117. package/src/tui/App.jsx +11 -5
  118. package/src/tui/dist/index.mjs +233 -65
  119. package/src/tui/engine/session-api-ext.mjs +37 -4
  120. package/src/tui/engine/turn.mjs +31 -0
  121. package/src/tui/index.jsx +1 -0
  122. package/src/tui/lib/voice-setup.mjs +5 -5
  123. package/scripts/_devtools-stub.mjs +0 -1
  124. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -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 { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
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: '0',
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 { content: 'http-recovered', toolCalls: [], usage: {} };
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 by process identity while uncertain live identity is preserved', () => {
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
- const seed = beginProcessLifecycle({ directory: root, configureReports: false });
112
- const currentIdentity = JSON.parse(readFileSync(seed.markerPath, 'utf8')).processIdentity;
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
- finishProcessLifecycle('clean-shutdown', 0);
115
- const reused = join(paths.markerDir, `${process.pid}-reused.json`);
116
- const matching = join(paths.markerDir, `${process.pid}-matching.json`);
117
- const uncertain = join(paths.markerDir, `${process.pid}-uncertain.json`);
118
- const malformed = join(paths.markerDir, `${process.pid}-malformed.json`);
119
- const clockAmbiguous = join(paths.markerDir, `${process.pid}-clock.json`);
120
- const differentKind = join(paths.markerDir, `${process.pid}-different-kind.json`);
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
- : { kind: currentIdentity.kind, value: currentIdentity.value + 1 };
152
+ : { ...currentIdentity, value: currentIdentity.value + 1 };
124
153
  writeFileSync(reused, JSON.stringify({
125
- pid: process.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: process.pid,
159
+ pid: child.pid,
132
160
  token: 'matching',
133
161
  processIdentity: currentIdentity,
134
162
  }));
135
163
  writeFileSync(uncertain, JSON.stringify({
136
- pid: process.pid,
137
- ppid: process.ppid,
164
+ pid: child.pid,
138
165
  token: 'uncertain',
139
166
  }));
140
167
  writeFileSync(malformed, JSON.stringify({
141
- pid: process.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: process.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: process.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, 1);
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 providers retry request-local pre-output 429 responses', async () => {
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
- const oauthResult = await oauth.send([], 'claude-sonnet-4-5', [], {
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(oauthResult.content, 'oauth-ok');
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';