mixdog 0.9.52 → 0.9.53

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 (111) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench-run.mjs +2 -2
  3. package/scripts/compact-pressure-test.mjs +104 -0
  4. package/scripts/desktop-session-bridge-test.mjs +704 -0
  5. package/scripts/freevar-smoke.mjs +7 -4
  6. package/scripts/lifecycle-api-test.mjs +65 -4
  7. package/scripts/max-output-recovery-test.mjs +31 -0
  8. package/scripts/memory-core-input-test.mjs +10 -0
  9. package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
  10. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  11. package/scripts/parent-abort-link-test.mjs +24 -0
  12. package/scripts/process-lifecycle-test.mjs +80 -22
  13. package/scripts/provider-contract-test.mjs +257 -0
  14. package/scripts/provider-toolcall-test.mjs +172 -10
  15. package/scripts/session-orphan-sweep-test.mjs +27 -1
  16. package/src/lib/keychain-cjs.cjs +36 -23
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  18. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  19. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
  22. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
  24. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
  27. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  28. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  29. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  30. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
  41. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  42. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  43. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  44. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  45. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
  46. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  47. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  48. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  50. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  51. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  53. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  55. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  59. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  62. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  63. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  64. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  65. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  66. package/src/runtime/channels/backends/discord.mjs +6 -6
  67. package/src/runtime/channels/lib/config.mjs +15 -2
  68. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  69. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  70. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  72. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  73. package/src/runtime/memory/index.mjs +24 -198
  74. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  75. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  76. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  77. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  78. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  79. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  80. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  81. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  82. package/src/runtime/shared/config.mjs +58 -13
  83. package/src/runtime/shared/llm/cost.mjs +14 -4
  84. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  85. package/src/runtime/shared/process-lifecycle.mjs +92 -19
  86. package/src/runtime/shared/process-shutdown.mjs +6 -0
  87. package/src/runtime/shared/resource-admission.mjs +7 -2
  88. package/src/session-runtime/channel-config-api.mjs +7 -7
  89. package/src/session-runtime/config-lifecycle.mjs +20 -17
  90. package/src/session-runtime/cwd-plugins.mjs +9 -7
  91. package/src/session-runtime/env.mjs +1 -2
  92. package/src/session-runtime/hitch-profile.mjs +45 -0
  93. package/src/session-runtime/lifecycle-api.mjs +36 -9
  94. package/src/session-runtime/mcp-glue.mjs +6 -11
  95. package/src/session-runtime/provider-init-key.mjs +17 -0
  96. package/src/session-runtime/runtime-core.mjs +44 -103
  97. package/src/session-runtime/runtime-paths.mjs +20 -0
  98. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  99. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  100. package/src/session-runtime/tool-catalog.mjs +15 -89
  101. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  102. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  103. package/src/standalone/agent-tool.mjs +12 -162
  104. package/src/standalone/channel-admin.mjs +29 -0
  105. package/src/tui/App.jsx +11 -5
  106. package/src/tui/dist/index.mjs +202 -65
  107. package/src/tui/engine/session-api-ext.mjs +37 -4
  108. package/src/tui/index.jsx +1 -0
  109. package/src/tui/lib/voice-setup.mjs +5 -5
  110. package/scripts/_devtools-stub.mjs +0 -1
  111. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -20,8 +20,11 @@ import { analyze } from 'eslint-scope';
20
20
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
21
21
  // Plain .mjs source roots. src/tui is covered by smoke:tui on the BUILT
22
22
  // bundle (App.jsx is JSX — acorn can't parse it raw); vendor is third-party.
23
- const SCAN_ROOTS = ['src/runtime', 'src/standalone', 'src/shared', 'scripts'].map(p => join(ROOT, p));
24
- const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']);
23
+ const SCAN_ROOTS = ['src/runtime', 'src/session-runtime', 'src/standalone', 'src/shared', 'scripts'].map(p => join(ROOT, p));
24
+ const ROOT_RUNTIME_FACADES = readdirSync(join(ROOT, 'src'), { withFileTypes: true })
25
+ .filter(entry => entry.isFile() && entry.name.endsWith('.mjs'))
26
+ .map(entry => join(ROOT, 'src', entry.name));
27
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'vendor', '.git']);
25
28
 
26
29
  const KNOWN_GLOBALS = new Set([
27
30
  'Array', 'ArrayBuffer', 'Atomics', 'BigInt', 'BigInt64Array', 'BigUint64Array', 'Boolean',
@@ -61,9 +64,9 @@ function* walk(dir) {
61
64
 
62
65
  let failures = 0;
63
66
  let scanned = 0;
64
- for (const root of SCAN_ROOTS) {
67
+ for (const root of [...SCAN_ROOTS, ...ROOT_RUNTIME_FACADES]) {
65
68
  let files;
66
- try { files = [...walk(root)]; } catch { continue; }
69
+ try { files = statSync(root).isDirectory() ? [...walk(root)] : [root]; } catch { continue; }
67
70
  for (const file of files) {
68
71
  scanned++;
69
72
  const source = readFileSync(file, 'utf8');
@@ -1,6 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import test from 'node:test';
3
6
 
7
+ import { createConfigLifecycle } from '../src/session-runtime/config-lifecycle.mjs';
4
8
  import { createLifecycleApi } from '../src/session-runtime/lifecycle-api.mjs';
5
9
  import {
6
10
  _clearWebSocketPoolForTest,
@@ -17,7 +21,7 @@ function socket() {
17
21
  };
18
22
  }
19
23
 
20
- function lifecycleFor(session) {
24
+ function lifecycleFor(session, overrides = {}) {
21
25
  let current = session;
22
26
  return createLifecycleApi({
23
27
  getSession: () => current,
@@ -45,15 +49,14 @@ function lifecycleFor(session) {
45
49
  mcpClient: { disconnectAll: () => null },
46
50
  warmupTimers: {},
47
51
  prewarmTimers: {},
48
- flushConfigSave: () => {},
49
- flushBackendSave: () => {},
50
- flushOutputStyleSave: () => {},
52
+ flushAllConfigSavesAsync: async () => {},
51
53
  withTeardownDeadline: (promise) => promise,
52
54
  closePatchRuntimeIfLoaded: () => null,
53
55
  invalidateContextStatusCache: () => {},
54
56
  invalidatePreSessionToolSurface: () => {},
55
57
  notificationListeners: { clear: () => {} },
56
58
  remoteStateListeners: { clear: () => {} },
59
+ ...overrides,
57
60
  });
58
61
  }
59
62
 
@@ -74,3 +77,61 @@ test('lifecycle drains the OpenAI WS pool only for process exit', async () => {
74
77
 
75
78
  assert.deepEqual(retainedSocket.closed, ['cli-exit']);
76
79
  });
80
+
81
+ test('lifecycle barrier drains a direct updateSectionAsync with no queued lifecycle save', async () => {
82
+ const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-lifecycle-config-'));
83
+ const previousDataDir = process.env.MIXDOG_DATA_DIR;
84
+ const previousBackupRoot = process.env.MIXDOG_USER_DATA_BACKUP_ROOT;
85
+ process.env.MIXDOG_DATA_DIR = dataDir;
86
+ process.env.MIXDOG_USER_DATA_BACKUP_ROOT = join(dataDir, 'backups');
87
+ const sharedCfgMod = await import(`../src/runtime/shared/config.mjs?lifecycle-tail=${Date.now()}`);
88
+ const configLifecycle = createConfigLifecycle({
89
+ getConfig: () => ({}),
90
+ setConfig: () => {},
91
+ getSearchRoute: () => null,
92
+ setSearchRoute: () => {},
93
+ getConfigHasSecrets: () => false,
94
+ setConfigHasSecrets: () => {},
95
+ getRoute: () => ({}),
96
+ cfgMod: {
97
+ saveConfigAsync: async () => {},
98
+ patchSkillsDisabledAsync: async () => {},
99
+ getPluginData: () => dataDir,
100
+ },
101
+ sharedCfgMod,
102
+ setBackendAsync: async () => {},
103
+ setConfiguredShell: () => {},
104
+ normalizeSystemShellConfig: () => ({ command: '' }),
105
+ normalizeSearchRouteConfig: () => null,
106
+ outputStyleStatus: () => ({}),
107
+ LAZY_SECRET_PROVIDERS: new Set(),
108
+ clean: (value) => String(value || ''),
109
+ resolve: (value) => value,
110
+ STANDALONE_DATA_DIR: dataDir,
111
+ });
112
+
113
+ const events = [];
114
+ let directSettled = false;
115
+ try {
116
+ const directWrite = sharedCfgMod.updateSectionAsync('cycle3', () => ({ value: 'direct' }))
117
+ .finally(() => { directSettled = true; events.push('direct:settled'); });
118
+ assert.equal(directSettled, false);
119
+
120
+ const lifecycle = lifecycleFor(
121
+ { id: 'tail-drain', messages: [], liveTurnMessages: [] },
122
+ {
123
+ flushAllConfigSavesAsync: configLifecycle.flushAllConfigSavesAsync,
124
+ closePatchRuntimeIfLoaded: async () => { events.push('teardown:continued'); },
125
+ },
126
+ );
127
+ await lifecycle.close('engine-replace');
128
+ await directWrite;
129
+ assert.deepEqual(events, ['direct:settled', 'teardown:continued']);
130
+ } finally {
131
+ if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
132
+ else process.env.MIXDOG_DATA_DIR = previousDataDir;
133
+ if (previousBackupRoot == null) delete process.env.MIXDOG_USER_DATA_BACKUP_ROOT;
134
+ else process.env.MIXDOG_USER_DATA_BACKUP_ROOT = previousBackupRoot;
135
+ rmSync(dataDir, { recursive: true, force: true });
136
+ }
137
+ });
@@ -118,6 +118,37 @@ test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery
118
118
  assert.equal(provider.sent[1][1].content, 'gemini ');
119
119
  });
120
120
 
121
+ test('Gemini MAX_TOKENS recovery derives missing prompt usage from total minus output', async () => {
122
+ const incomplete = Object.assign(new Error('Gemini response incomplete: finishReason=MAX_TOKENS'), {
123
+ code: 'PROVIDER_INCOMPLETE',
124
+ providerIncomplete: true,
125
+ finishReason: 'MAX_TOKENS',
126
+ partialContent: 'gemini ',
127
+ rawUsage: {
128
+ total_token_count: 20,
129
+ candidates_token_count: 4,
130
+ thoughts_token_count: 1,
131
+ },
132
+ });
133
+ const result = await run(queuedProvider([
134
+ incomplete,
135
+ {
136
+ content: 'complete',
137
+ stopReason: 'end_turn',
138
+ usage: { inputTokens: 1, outputTokens: 2, promptTokens: 1 },
139
+ },
140
+ ]), undefined, { onTextDelta: () => {} });
141
+
142
+ assert.deepEqual(
143
+ {
144
+ inputTokens: result.usage.inputTokens,
145
+ outputTokens: result.usage.outputTokens,
146
+ promptTokens: result.usage.promptTokens,
147
+ },
148
+ { inputTokens: 16, outputTokens: 7, promptTokens: 16 },
149
+ );
150
+ });
151
+
121
152
  test('Gemini MAX_TOKENS continuation preserves provider-scoped replay metadata', async () => {
122
153
  const providerMetadata = {
123
154
  gemini: { thoughtParts: [{ text: 'signed thought', thoughtSignature: 'sig-gemini' }] },
@@ -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 }, {
@@ -5,6 +5,7 @@ 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
7
  import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
8
+ import { applyAskTerminalUsageTotals } from '../src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs';
8
9
  import {
9
10
  _clearWebSocketPoolForTest,
10
11
  _closeAllPooledSockets,
@@ -247,6 +248,42 @@ test('pre-response 1006 opens a fresh WS and replays the same request', async ()
247
248
  assert.doesNotMatch(stderr, /mid-stream recovered|Reconnecting/);
248
249
  });
249
250
 
251
+ test('completed warmup usage survives a same-send WS retry exactly once', async () => {
252
+ let streams = 0;
253
+ const result = await sendViaWebSocket(wsArgs({
254
+ warmupBody: { model: 'gpt-5.5', instructions: 'base', input: [], generate: false },
255
+ _acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
256
+ _streamFn: async ({ state }) => {
257
+ streams += 1;
258
+ if (state.warmup) {
259
+ return {
260
+ content: '',
261
+ model: 'gpt-5.5',
262
+ toolCalls: [],
263
+ usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 4, promptTokens: 10 },
264
+ responseId: 'warm-retry-1',
265
+ responseItems: [],
266
+ };
267
+ }
268
+ if (streams === 2) throw close1006();
269
+ return {
270
+ content: 'recovered',
271
+ model: 'gpt-5.5',
272
+ toolCalls: [],
273
+ usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 8, promptTokens: 20 },
274
+ responseId: 'main-retry-1',
275
+ responseItems: [],
276
+ closeSocket: true,
277
+ };
278
+ },
279
+ }));
280
+ assert.equal(streams, 3, 'one warmup plus two main attempts');
281
+ assert.equal(result.usage.inputTokens, 30);
282
+ assert.equal(result.usage.outputTokens, 2);
283
+ assert.equal(result.usage.mainInputTokens, 20);
284
+ assert.equal(result.usage.warmupInputTokens, 10);
285
+ });
286
+
250
287
  test('successful iteration emits one compact send-spans row', async () => {
251
288
  const rows = [];
252
289
  const result = await sendViaWebSocket(wsArgs({
@@ -311,7 +348,7 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
311
348
  Object.assign(process.env, {
312
349
  MIXDOG_OAI_TRANSPORT: 'auto',
313
350
  MIXDOG_OPENAI_HTTP_FALLBACK: '1',
314
- MIXDOG_OPENAI_OAUTH_WS_WARMUP: '0',
351
+ MIXDOG_OPENAI_OAUTH_WS_WARMUP: '1',
315
352
  MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK: '0',
316
353
  MIXDOG_AGENT_TRACE_DISABLE: '1',
317
354
  });
@@ -327,7 +364,17 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
327
364
  ...args,
328
365
  _acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
329
366
  _sendFrameFn: async () => {},
330
- _streamFn: async () => {
367
+ _streamFn: async ({ state }) => {
368
+ if (state.warmup) {
369
+ return {
370
+ content: '',
371
+ model: 'gpt-5.5',
372
+ toolCalls: [],
373
+ usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 4, promptTokens: 10 },
374
+ responseId: 'warm-fallback-1',
375
+ responseItems: [],
376
+ };
377
+ }
331
378
  streamAttempts += 1;
332
379
  throw close1006();
333
380
  },
@@ -335,7 +382,11 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
335
382
  }),
336
383
  _sendViaHttpSseFn: async () => {
337
384
  httpCalls += 1;
338
- return { content: 'http-recovered', toolCalls: [], usage: {} };
385
+ return {
386
+ content: 'http-recovered',
387
+ toolCalls: [],
388
+ usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 8, promptTokens: 20 },
389
+ };
339
390
  },
340
391
  };
341
392
  const result = await provider.send([], 'gpt-5.5', [], sendOpts);
@@ -345,6 +396,15 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
345
396
  assert.equal(httpCalls, 2);
346
397
  assert.equal(result.content, 'http-recovered');
347
398
  assert.equal(stickyResult.content, 'http-recovered');
399
+ assert.equal(result.usage.inputTokens, 30, 'fallback bills the completed WS warmup once');
400
+ assert.equal(result.usage.mainInputTokens, 20, 'fallback context remains HTTP-main-only');
401
+ assert.equal(result.usage.warmupInputTokens, 10);
402
+ assert.equal(stickyResult.usage.inputTokens, 20, 'sticky HTTP sends do not rebill the old warmup');
403
+ assert.equal(stickyResult.usage.warmupInputTokens, undefined);
404
+ const session = { provider: 'openai-oauth' };
405
+ applyAskTerminalUsageTotals(session, { usage: result.usage, lastTurnUsage: result.usage });
406
+ assert.equal(session.totalInputTokens, 30, 'lifetime billing includes WS warmup plus HTTP main once');
407
+ assert.equal(session.lastContextTokens, 20, 'last context remains HTTP-main-only');
348
408
  assert.equal(provider._httpFallbackUntilByPoolKey.get(sendOpts.sessionId), Number.POSITIVE_INFINITY);
349
409
  } finally {
350
410
  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,107 @@ 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 by process identity 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
+ currentIdentity = JSON.parse(readFileSync(childMarker, 'utf8')).processIdentity;
130
+ if (currentIdentity?.kind === 'linux-start-ticks' || currentIdentity?.method === 'powershell') break;
131
+ }
132
+ await new Promise((resolve) => setTimeout(resolve, 50));
133
+ }
113
134
  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`);
135
+ if (process.platform === 'win32') assert.equal(currentIdentity.method, 'powershell');
136
+
137
+ const reused = join(paths.markerDir, `${child.pid}-reused.json`);
138
+ const matching = join(paths.markerDir, `${child.pid}-matching.json`);
139
+ const uncertain = join(paths.markerDir, `${child.pid}-uncertain.json`);
140
+ const malformed = join(paths.markerDir, `${child.pid}-malformed.json`);
141
+ const clockAmbiguous = join(paths.markerDir, `${child.pid}-clock.json`);
142
+ const differentKind = join(paths.markerDir, `${child.pid}-different-kind.json`);
143
+ const mixedTransient = join(paths.markerDir, `${child.pid}-mixed-transient.json`);
144
+ const samePidForeign = join(paths.markerDir, `${process.pid}-same-pid-foreign.json`);
121
145
  const reusedIdentity = currentIdentity.kind === 'linux-start-ticks'
122
146
  ? { kind: currentIdentity.kind, value: String(BigInt(currentIdentity.value) + 1n) }
123
- : { kind: currentIdentity.kind, value: currentIdentity.value + 1 };
147
+ : { ...currentIdentity, value: currentIdentity.value + 1 };
124
148
  writeFileSync(reused, JSON.stringify({
125
- pid: process.pid,
126
- ppid: process.ppid,
149
+ pid: child.pid,
127
150
  token: 'reused',
128
151
  processIdentity: reusedIdentity,
129
152
  }));
130
153
  writeFileSync(matching, JSON.stringify({
131
- pid: process.pid,
154
+ pid: child.pid,
132
155
  token: 'matching',
133
156
  processIdentity: currentIdentity,
134
157
  }));
135
158
  writeFileSync(uncertain, JSON.stringify({
136
- pid: process.pid,
137
- ppid: process.ppid,
159
+ pid: child.pid,
138
160
  token: 'uncertain',
139
161
  }));
140
162
  writeFileSync(malformed, JSON.stringify({
141
- pid: process.pid,
142
- ppid: process.ppid,
163
+ pid: child.pid,
143
164
  token: 'malformed',
144
165
  processIdentity: process.platform === 'linux'
145
166
  ? { kind: 'linux-start-ticks', value: 'not-a-number' }
146
167
  : { kind: 'start-seconds', value: 'not-a-number' },
147
168
  }));
148
169
  writeFileSync(clockAmbiguous, JSON.stringify({
149
- pid: process.pid,
150
- ppid: process.ppid,
170
+ pid: child.pid,
151
171
  token: 'clock-adjusted',
152
172
  processIdentity: { kind: 'legacy-wall-clock', value: Date.now() + 86400000 },
153
173
  }));
154
174
  writeFileSync(differentKind, JSON.stringify({
155
- pid: process.pid,
175
+ pid: child.pid,
156
176
  token: 'different-kind',
157
177
  processIdentity: currentIdentity.kind === 'linux-start-ticks'
158
178
  ? { kind: 'start-seconds', value: 1 }
159
179
  : { kind: 'linux-start-ticks', value: '1' },
160
180
  }));
181
+ if (currentIdentity.kind === 'start-seconds') {
182
+ writeFileSync(mixedTransient, JSON.stringify({
183
+ pid: child.pid,
184
+ token: 'mixed-transient',
185
+ processIdentity: { kind: currentIdentity.kind, value: currentIdentity.value + 1, method: 'uptime' },
186
+ }));
187
+ }
188
+ writeFileSync(samePidForeign, JSON.stringify({
189
+ pid: process.pid,
190
+ token: 'same-pid-foreign',
191
+ processIdentity: currentIdentity,
192
+ }));
161
193
  beginProcessLifecycle({ directory: root, configureReports: false });
162
194
  finishProcessLifecycle('clean-shutdown', 0);
163
- assert.equal(entries(paths.ledger).filter((row) => row.reason === 'prior-process-vanished').length, 1);
195
+ assert.equal(entries(paths.ledger).filter((row) => row.reason === 'prior-process-vanished').length, 2);
164
196
  assert.equal(existsSync(reused), false);
165
197
  assert.equal(existsSync(matching), true);
166
198
  assert.equal(existsSync(uncertain), true);
167
199
  assert.equal(existsSync(malformed), true);
168
200
  assert.equal(existsSync(clockAmbiguous), true);
169
201
  assert.equal(existsSync(differentKind), true);
202
+ assert.equal(existsSync(samePidForeign), false);
203
+ assert.equal(existsSync(childMarker), true);
204
+ if (currentIdentity.kind === 'start-seconds') assert.equal(existsSync(mixedTransient), true);
170
205
  } finally {
206
+ child?.kill();
207
+ if (child) await childExit(child);
171
208
  rmSync(root, { recursive: true, force: true });
172
209
  }
173
210
  });
@@ -315,6 +352,27 @@ test('cleanup rejection and timeout are forced cleanup, not clean shutdown', asy
315
352
  }
316
353
  });
317
354
 
355
+ test('uncaughtException synchronously restores terminal modes before cleanup', () => {
356
+ const source = `
357
+ import { installProcessSignalCleanup } from './src/runtime/shared/process-shutdown.mjs';
358
+ const reset = '\\x1b[?1000l\\x1b[?1002l\\x1b[?1006l\\x1b[?1049l';
359
+ installProcessSignalCleanup({
360
+ signals: [],
361
+ exit: false,
362
+ restoreTerminal: () => process.stdout.write(reset),
363
+ cleanup: async () => {},
364
+ log: () => {},
365
+ });
366
+ throw new Error('terminal fixture crash');
367
+ `;
368
+ const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
369
+ cwd: REPO_ROOT,
370
+ encoding: 'utf8',
371
+ });
372
+ assert.equal(result.status, 0, result.stderr);
373
+ assert.equal(result.stdout, '\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1049l');
374
+ });
375
+
318
376
  test('Node report is compact, excludes environment, and rotates to one previous report', () => {
319
377
  const root = tempRoot();
320
378
  try {