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
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import test from 'node:test';
3
3
  import assert from 'node:assert/strict';
4
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
4
8
 
5
9
  import {
6
10
  OpenAICompatProvider,
@@ -10,6 +14,7 @@ import {
10
14
  } from '../src/runtime/agent/orchestrator/providers/openai-compat.mjs';
11
15
  import { consumeCompatChatCompletionStream } from '../src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs';
12
16
  import { GrokOAuthProvider } from '../src/runtime/agent/orchestrator/providers/grok-oauth.mjs';
17
+ import { sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
13
18
  import {
14
19
  OpenCodeGoProvider,
15
20
  isAnthropicGoModel,
@@ -17,6 +22,11 @@ import {
17
22
  resolveOpenCodeGoBaseURLs,
18
23
  } from '../src/runtime/agent/orchestrator/providers/opencode-go.mjs';
19
24
  import { uncachedInputTokensForProvider } from '../src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs';
25
+ import { resolveTraceUsageInput } from '../src/runtime/agent/orchestrator/agent-trace.mjs';
26
+ import {
27
+ billableInputTokensForProvider,
28
+ isInclusiveProvider,
29
+ } from '../src/runtime/shared/llm/cost.mjs';
20
30
 
21
31
  function stream(events) {
22
32
  return {
@@ -173,17 +183,143 @@ test('OpenCode Go normalizes both route families to inclusive provider usage', a
173
183
  provider.openai = { send: async () => openaiRaw };
174
184
  const anthropic = await provider.send([], 'minimax-m3', [], {});
175
185
  assert.equal(OpenCodeGoProvider.inputExcludesCache, false);
186
+ assert.equal(isInclusiveProvider('opencode-go'), true);
176
187
  assert.equal(anthropic.usage.inputTokens, 100);
177
188
  assert.equal(anthropic.usage.promptTokens, 100);
178
189
  assert.equal(uncachedInputTokensForProvider('opencode-go', anthropic.usage.inputTokens, 35, 5), 60);
190
+ assert.equal(billableInputTokensForProvider('opencode-go', 100, 35, 5), 60);
191
+ assert.deepEqual(resolveTraceUsageInput({
192
+ provider: 'opencode-go',
193
+ inputTokens: 60,
194
+ cachedTokens: 35,
195
+ cacheWriteTokens: 5,
196
+ inputTokensInclusive: false,
197
+ }), {
198
+ uncachedInputTokens: 60,
199
+ promptTokens: 100,
200
+ });
179
201
  assert.equal(anthropic.usage.inputTokens, 100, 'context footprint is inclusive');
180
202
 
181
203
  const openai = await provider.send([], 'glm-5.2', [], {});
182
204
  assert.equal(openai, openaiRaw);
183
205
  assert.equal(uncachedInputTokensForProvider('opencode-go', openai.usage.inputTokens, 40, 0), 60);
206
+ assert.equal(billableInputTokensForProvider('opencode-go', 100, 40, 0), 60);
207
+ assert.deepEqual(resolveTraceUsageInput({
208
+ provider: 'opencode-go',
209
+ inputTokens: 100,
210
+ cachedTokens: 40,
211
+ cacheWriteTokens: 0,
212
+ }), {
213
+ uncachedInputTokens: 60,
214
+ promptTokens: 100,
215
+ });
184
216
  assert.equal(openai.usage.inputTokens, 100, 'context footprint remains inclusive');
185
217
  });
186
218
 
219
+ test('OpenCode Go Anthropic delegation traces additive inner usage before inclusive outer normalization', (t) => {
220
+ const tempDir = mkdtempSync(join(tmpdir(), 'mixdog-opencode-go-trace-'));
221
+ const tracePath = join(tempDir, 'agent-trace.jsonl');
222
+ t.after(() => rmSync(tempDir, { recursive: true, force: true }));
223
+
224
+ const openCodeGoUrl = new URL('../src/runtime/agent/orchestrator/providers/opencode-go.mjs', import.meta.url).href;
225
+ const anthropicUrl = new URL('../src/runtime/agent/orchestrator/providers/anthropic.mjs', import.meta.url).href;
226
+ const traceUrl = new URL('../src/runtime/agent/orchestrator/agent-trace.mjs', import.meta.url).href;
227
+ const fixture = `
228
+ const { OpenCodeGoProvider } = await import(${JSON.stringify(openCodeGoUrl)});
229
+ const { AnthropicProvider } = await import(${JSON.stringify(anthropicUrl)});
230
+ const { drainAgentTrace } = await import(${JSON.stringify(traceUrl)});
231
+
232
+ const encoder = new TextEncoder();
233
+ const events = [
234
+ { type: 'message_start', message: {
235
+ model: 'minimax-m3',
236
+ usage: {
237
+ input_tokens: 60,
238
+ cache_read_input_tokens: 35,
239
+ cache_creation_input_tokens: 5,
240
+ },
241
+ } },
242
+ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 5 } },
243
+ { type: 'message_stop' },
244
+ ];
245
+ const chunks = events.map((event) => encoder.encode(
246
+ 'event: ' + event.type + '\\ndata: ' + JSON.stringify(event) + '\\n\\n'
247
+ ));
248
+ let chunkIndex = 0;
249
+ const response = {
250
+ ok: true,
251
+ status: 200,
252
+ headers: new Map(),
253
+ body: { getReader() {
254
+ return {
255
+ read() {
256
+ return chunkIndex < chunks.length
257
+ ? Promise.resolve({ done: false, value: chunks[chunkIndex++] })
258
+ : Promise.resolve({ done: true, value: undefined });
259
+ },
260
+ cancel() { return Promise.resolve(); },
261
+ releaseLock() {},
262
+ };
263
+ } },
264
+ };
265
+
266
+ const inner = Object.create(AnthropicProvider.prototype);
267
+ inner.name = 'opencode-go';
268
+ inner.config = { disableBetaHeaders: true };
269
+ inner.fastModeBetaHeaderLatched = false;
270
+ inner.client = { messages: { create() {
271
+ return { asResponse: async () => response };
272
+ } } };
273
+
274
+ const outer = Object.create(OpenCodeGoProvider.prototype);
275
+ outer.anthropic = inner;
276
+ outer.openai = { send() { throw new Error('wrong OpenCode Go route'); } };
277
+ const result = await outer.send(
278
+ [{ role: 'user', content: 'fixture' }],
279
+ 'minimax-m3',
280
+ [],
281
+ { sessionId: 'opencode-go-additive-trace' },
282
+ );
283
+ await drainAgentTrace();
284
+ process.stdout.write(JSON.stringify(result.usage));
285
+ `;
286
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', fixture], {
287
+ cwd: new URL('..', import.meta.url),
288
+ encoding: 'utf8',
289
+ env: {
290
+ ...process.env,
291
+ MIXDOG_AGENT_TRACE_DISABLE: '0',
292
+ MIXDOG_AGENT_TRACE_LOCAL_DISABLE: '0',
293
+ MIXDOG_AGENT_TRACE_PATH: tracePath,
294
+ },
295
+ });
296
+ assert.equal(child.status, 0, child.stderr || child.stdout);
297
+
298
+ const outerUsage = JSON.parse(child.stdout);
299
+ assert.deepEqual(outerUsage, {
300
+ inputTokens: 100,
301
+ outputTokens: 5,
302
+ cachedTokens: 35,
303
+ cacheWriteTokens: 5,
304
+ promptTokens: 100,
305
+ });
306
+ const traceRows = readFileSync(tracePath, 'utf8')
307
+ .trim()
308
+ .split(/\r?\n/)
309
+ .map((line) => JSON.parse(line));
310
+ const innerUsage = traceRows.find((row) => (
311
+ row.kind === 'usage_raw'
312
+ && row.session_id === 'opencode-go-additive-trace'
313
+ ));
314
+ assert.ok(innerUsage, 'delegated Anthropic route must emit usage_raw');
315
+ assert.equal(innerUsage.payload.provider, 'opencode-go');
316
+ assert.equal(innerUsage.input_tokens, 60);
317
+ assert.equal(innerUsage.cached_tokens, 35);
318
+ assert.equal(innerUsage.cache_write_tokens, 5);
319
+ assert.equal(innerUsage.uncached_input_tokens, 60);
320
+ assert.equal(innerUsage.prompt_tokens, 100);
321
+ });
322
+
187
323
  test('constructing Grok OAuth is network inert', () => {
188
324
  const provider = new GrokOAuthProvider({
189
325
  preconnectFn: () => { throw new Error('constructor attempted outbound preconnect'); },
@@ -266,3 +402,124 @@ test('Grok OAuth does not refresh/replay a 401 after visible tool dispatch', asy
266
402
  assert.equal(dispatched, 1);
267
403
  assert.equal(authCalls, 1);
268
404
  });
405
+
406
+ test('xAI WS to HTTP fallback preserves completed warmup usage and cost ticks', async (t) => {
407
+ const priorTransport = process.env.MIXDOG_OAI_TRANSPORT;
408
+ process.env.MIXDOG_OAI_TRANSPORT = 'auto';
409
+ t.after(() => {
410
+ if (priorTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
411
+ else process.env.MIXDOG_OAI_TRANSPORT = priorTransport;
412
+ });
413
+ const provider = new OpenAICompatProvider('xai', {
414
+ apiKey: 'fixture',
415
+ preconnect: false,
416
+ responsesTransport: 'websocket',
417
+ });
418
+ const warmup = {
419
+ requestBody: { generate: false },
420
+ usage: { inputTokens: 10, outputTokens: 1, cachedTokens: 4, promptTokens: 10, raw: { cost_in_usd_ticks: 100 } },
421
+ };
422
+ provider._doSendXaiResponsesWebSocket = async () => {
423
+ const err = Object.assign(new Error('upgrade required'), { httpStatus: 426 });
424
+ Object.defineProperty(err, '__warmup', { value: warmup });
425
+ throw err;
426
+ };
427
+ provider._doSendXaiResponses = async () => ({
428
+ content: 'fallback',
429
+ usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 5, promptTokens: 20, raw: { cost_in_usd_ticks: 200 }, costUsd: 0.00000002 },
430
+ });
431
+ const result = await provider._doSend([], 'grok-4.5', [], {});
432
+ assert.equal(result.usage.inputTokens, 30);
433
+ assert.equal(result.usage.mainInputTokens, 20);
434
+ assert.equal(result.usage.raw.cost_in_usd_ticks, 300);
435
+ assert.equal(result.usage.costUsd, 0.00000003);
436
+ });
437
+
438
+ test('xAI WS warmup billing exposes only main request usage as context', async () => {
439
+ let streams = 0;
440
+ const result = await sendViaWebSocket({
441
+ auth: { type: 'xai', apiKey: 'fixture' },
442
+ body: { model: 'grok-4.5', input: [{ role: 'user', content: 'main' }] },
443
+ sendOpts: {},
444
+ externalSignal: null,
445
+ poolKey: 'xai-warmup-accounting',
446
+ cacheKey: 'xai-warmup-accounting',
447
+ iteration: 1,
448
+ useModel: 'grok-4.5',
449
+ traceProvider: 'xai',
450
+ includeResponseId: true,
451
+ warmupBody: { model: 'grok-4.5', input: [], generate: false },
452
+ _acquireWithRetryFn: async () => ({ entry: { socket: { close() {} } }, reused: false }),
453
+ _sendFrameFn: async () => {},
454
+ _streamFn: async ({ state }) => {
455
+ streams += 1;
456
+ return state.warmup
457
+ ? {
458
+ content: '',
459
+ model: 'grok-4.5',
460
+ toolCalls: [],
461
+ usage: { inputTokens: 10, outputTokens: 1, cachedTokens: 4, promptTokens: 10, raw: { cost_in_usd_ticks: 100 } },
462
+ responseId: 'warm',
463
+ responseItems: [],
464
+ }
465
+ : {
466
+ content: 'done',
467
+ model: 'grok-4.5',
468
+ toolCalls: [],
469
+ usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 5, promptTokens: 20, raw: { cost_in_usd_ticks: 200 } },
470
+ responseId: 'main',
471
+ responseItems: [],
472
+ closeSocket: true,
473
+ };
474
+ },
475
+ _agentTraceFn: () => {},
476
+ _sendSpanTraceFn: () => {},
477
+ });
478
+ assert.equal(streams, 2);
479
+ assert.equal(result.usage.inputTokens, 30);
480
+ assert.equal(result.usage.mainInputTokens, 20);
481
+ assert.equal(result.usage.raw.cost_in_usd_ticks, 300);
482
+ });
483
+
484
+ test('xAI safe 401 replay carries completed warmup into the retry', async () => {
485
+ const provider = new OpenAICompatProvider('xai', { apiKey: 'fixture', preconnect: false });
486
+ const warmup = { usage: { inputTokens: 10 } };
487
+ let attempts = 0;
488
+ provider.reloadApiKey = () => {};
489
+ provider._doSend = async (_messages, _model, _tools, opts) => {
490
+ attempts += 1;
491
+ if (attempts === 1) {
492
+ const err = Object.assign(new Error('401'), { httpStatus: 401 });
493
+ Object.defineProperty(err, '__warmup', { value: warmup });
494
+ throw err;
495
+ }
496
+ assert.equal(opts._carriedWarmup, warmup);
497
+ return { content: 'retried' };
498
+ };
499
+ assert.equal((await provider.send([], 'grok-4.5', [], {})).content, 'retried');
500
+ assert.equal(attempts, 2);
501
+ });
502
+
503
+ test('Grok OAuth safe 401 replay carries completed warmup to refreshed xAI inner', async () => {
504
+ const provider = Object.create(GrokOAuthProvider.prototype);
505
+ provider.config = { preconnect: false };
506
+ const warmup = { usage: { inputTokens: 10 } };
507
+ let authCalls = 0;
508
+ provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
509
+ authCalls += 1;
510
+ return { access_token: forceRefresh ? 'fresh' : 'stale' };
511
+ };
512
+ provider._ensureInner = (token) => ({
513
+ _doSend: async (_messages, _model, _tools, opts) => {
514
+ if (token === 'stale') {
515
+ const err = Object.assign(new Error('401'), { httpStatus: 401 });
516
+ Object.defineProperty(err, '__warmup', { value: warmup });
517
+ throw err;
518
+ }
519
+ assert.equal(opts._carriedWarmup, warmup);
520
+ return { content: 'retried' };
521
+ },
522
+ });
523
+ assert.equal((await provider.send([], 'grok-4.5', [], {})).content, 'retried');
524
+ assert.equal(authCalls, 2);
525
+ });
@@ -71,7 +71,10 @@ import {
71
71
  import { buildAnthropicBetaHeaders } from '../src/runtime/agent/orchestrator/providers/anthropic-betas.mjs';
72
72
  import { PATCH_TOOL_DEFS } from '../src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs';
73
73
  import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
74
- import { buildRequestBody as buildOpenAIOAuthRequestBody } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
74
+ import {
75
+ OpenAIOAuthProvider,
76
+ buildRequestBody as buildOpenAIOAuthRequestBody,
77
+ } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
75
78
  import { _convertMessagesToResponsesInputForTest } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
76
79
  import { OpenAIDirectProvider } from '../src/runtime/agent/orchestrator/providers/openai-ws.mjs';
77
80
 
@@ -1053,6 +1056,46 @@ test('gemini cache usage: official cached token fields are subsets of prompt tok
1053
1056
  assert.notEqual(sdkAlias.inputTokens, 0, 'snake_case SDK aliases must remain visible to provider return usage');
1054
1057
  });
1055
1058
 
1059
+ test('gemini cache usage: total fallback excludes candidate and thought output tokens', () => {
1060
+ const camelCase = _resolveGeminiCacheUsage({
1061
+ usageMetadata: {
1062
+ totalTokenCount: 1000,
1063
+ candidatesTokenCount: 150,
1064
+ thoughtsTokenCount: 50,
1065
+ },
1066
+ });
1067
+ assert.equal(camelCase.inputTokens, 800);
1068
+
1069
+ const snakeCase = _resolveGeminiCacheUsage({
1070
+ usageMetadata: {
1071
+ total_token_count: 1200,
1072
+ candidates_token_count: 175,
1073
+ thoughts_token_count: 25,
1074
+ },
1075
+ });
1076
+ assert.equal(snakeCase.inputTokens, 1000);
1077
+
1078
+ const promptWins = _resolveGeminiCacheUsage({
1079
+ usageMetadata: {
1080
+ promptTokenCount: 900,
1081
+ totalTokenCount: 1200,
1082
+ candidatesTokenCount: 250,
1083
+ thoughtsTokenCount: 50,
1084
+ },
1085
+ });
1086
+ assert.equal(promptWins.inputTokens, 900);
1087
+
1088
+ const explicitZero = _resolveGeminiCacheUsage({
1089
+ usageMetadata: {
1090
+ prompt_token_count: 0,
1091
+ total_token_count: 1200,
1092
+ candidates_token_count: 175,
1093
+ thoughts_token_count: 25,
1094
+ },
1095
+ });
1096
+ assert.equal(explicitZero.inputTokens, 0);
1097
+ });
1098
+
1056
1099
  test('gemini cache usage: clamps over-reported cache and falls back only for attached cachedContent', () => {
1057
1100
  const clamped = _resolveGeminiCacheUsage({
1058
1101
  usageMetadata: { promptTokenCount: 100, cachedContentTokenCount: 150 },
@@ -2237,21 +2280,28 @@ test('openai oauth ws delta: warmup generate:false does not force request_proper
2237
2280
  const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
2238
2281
  try {
2239
2282
  process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
2240
- const warmupBody = { model: 'gpt-5.5', generate: false, input: [{ type: 'message', role: 'user', content: 'prev' }] };
2241
- const body = { model: 'gpt-5.5', input: [warmupBody.input[0], { type: 'message', role: 'user', content: 'next' }] };
2283
+ const warmupBody = { model: 'gpt-5.5', generate: false, input: [] };
2284
+ const body = { model: 'gpt-5.5', input: [{ type: 'message', role: 'user', content: 'first real input' }] };
2242
2285
  const entry = {
2243
- lastRequestSansInput: _stableStringify(_sansInput(warmupBody)),
2286
+ lastRequestSansInput: _stableStringify(_sansInput(warmupBody, { normalizeWarmupGenerate: true })),
2244
2287
  lastResponseId: 'resp_warm',
2245
- lastRequestInput: [warmupBody.input[0]],
2288
+ lastRequestInput: [],
2246
2289
  lastResponseItems: [],
2247
2290
  };
2248
- assert.equal(_sansInput(warmupBody).generate, undefined); // warmup marker normalized out
2249
- const delta = _computeDelta({ entry, body });
2291
+ assert.equal(_sansInput(warmupBody, { normalizeWarmupGenerate: true }).generate, undefined);
2292
+ const delta = _computeDelta({ entry, body, traceProvider: 'openai-oauth' });
2250
2293
  assert.equal(delta.mode, 'delta');
2251
- assert.deepEqual(delta.frame.input, [body.input[1]]);
2294
+ assert.equal(delta.frame.previous_response_id, 'resp_warm');
2295
+ assert.deepEqual(delta.frame.input, body.input);
2252
2296
  // Non-warmup generate difference still breaks the delta.
2253
- const genEntry = { ...entry, lastRequestSansInput: _stableStringify(_sansInput({ ...warmupBody, generate: true })) };
2254
- const genChanged = _computeDelta({ entry: genEntry, body });
2297
+ const genEntry = {
2298
+ ...entry,
2299
+ lastRequestSansInput: _stableStringify(_sansInput(
2300
+ { ...warmupBody, generate: true },
2301
+ { normalizeWarmupGenerate: true },
2302
+ )),
2303
+ };
2304
+ const genChanged = _computeDelta({ entry: genEntry, body, traceProvider: 'openai-oauth' });
2255
2305
  assert.equal(genChanged.mode, 'full');
2256
2306
  assert.equal(genChanged.reason, 'request_properties_changed');
2257
2307
  } finally {
@@ -2260,6 +2310,118 @@ test('openai oauth ws delta: warmup generate:false does not force request_proper
2260
2310
  }
2261
2311
  });
2262
2312
 
2313
+ test('xAI keeps generate:false in the property guard and falls back to a full frame', () => {
2314
+ const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
2315
+ try {
2316
+ process.env.MIXDOG_OAI_TRANSPORT = 'ws-delta';
2317
+ const warmupBody = { model: 'grok-4', generate: false, input: [] };
2318
+ const body = { model: 'grok-4', input: [{ role: 'user', content: 'real request' }] };
2319
+ const entry = {
2320
+ lastRequestSansInput: _stableStringify(_sansInput(warmupBody)),
2321
+ lastResponseId: 'xai-warm',
2322
+ lastRequestInput: [],
2323
+ lastResponseItems: [],
2324
+ };
2325
+ assert.equal(_sansInput(warmupBody).generate, false);
2326
+ const delta = _computeDelta({ entry, body, traceProvider: 'xai' });
2327
+ assert.equal(delta.mode, 'full');
2328
+ assert.equal(delta.reason, 'request_properties_changed');
2329
+ assert.equal(delta.frame.previous_response_id, undefined);
2330
+ assert.deepEqual(delta.frame.input, body.input);
2331
+ } finally {
2332
+ if (prevTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
2333
+ else process.env.MIXDOG_OAI_TRANSPORT = prevTransport;
2334
+ }
2335
+ });
2336
+
2337
+ test('OpenAI OAuth startup prewarm sends no transcript and anchors the first real WS request', async () => {
2338
+ const savedEnv = Object.fromEntries([
2339
+ 'MIXDOG_OAI_TRANSPORT',
2340
+ 'MIXDOG_OPENAI_OAUTH_WS_WARMUP',
2341
+ 'MIXDOG_AGENT_TRACE_DISABLE',
2342
+ ].map((name) => [name, process.env[name]]));
2343
+ Object.assign(process.env, {
2344
+ MIXDOG_OAI_TRANSPORT: 'ws-delta',
2345
+ MIXDOG_OPENAI_OAUTH_WS_WARMUP: '1',
2346
+ MIXDOG_AGENT_TRACE_DISABLE: '1',
2347
+ });
2348
+ try {
2349
+ const provider = new OpenAIOAuthProvider({});
2350
+ provider.ensureAuth = async () => ({ access_token: 'test-token' });
2351
+ const liveInput = [{
2352
+ type: 'message',
2353
+ role: 'user',
2354
+ content: [{ type: 'input_text', text: 'live-transcript-must-appear-once' }],
2355
+ }];
2356
+ const body = {
2357
+ model: 'gpt-5.5',
2358
+ instructions: 'base instructions',
2359
+ input: liveInput,
2360
+ tools: [{ type: 'function', name: 'read', parameters: { type: 'object' } }],
2361
+ stream: true,
2362
+ background: false,
2363
+ prompt_cache_key: 'prewarm-parity-test',
2364
+ };
2365
+ const frames = [];
2366
+ let streams = 0;
2367
+ const result = await provider.send([], 'gpt-5.5', [], {
2368
+ sessionId: 'prewarm-parity-test',
2369
+ _prebuiltBody: body,
2370
+ _sendViaWebSocketFn: (args) => sendViaWebSocket({
2371
+ ...args,
2372
+ _acquireWithRetryFn: async () => ({ entry: { socket: { close() {} } }, reused: false }),
2373
+ _sendFrameFn: async (_entry, frame) => frames.push(JSON.parse(JSON.stringify(frame))),
2374
+ _streamFn: async ({ state }) => {
2375
+ streams += 1;
2376
+ return state.warmup
2377
+ ? {
2378
+ content: '',
2379
+ model: 'gpt-5.5',
2380
+ toolCalls: [],
2381
+ usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 0, promptTokens: 10 },
2382
+ responseId: 'warm-1',
2383
+ responseItems: [],
2384
+ }
2385
+ : {
2386
+ content: 'done',
2387
+ model: 'gpt-5.5',
2388
+ toolCalls: [],
2389
+ usage: { inputTokens: 20, outputTokens: 1, cachedTokens: 10, promptTokens: 20 },
2390
+ responseId: 'resp-1',
2391
+ responseItems: [],
2392
+ closeSocket: true,
2393
+ };
2394
+ },
2395
+ _agentTraceFn: () => {},
2396
+ _sendSpanTraceFn: () => {},
2397
+ }),
2398
+ });
2399
+
2400
+ assert.equal(streams, 2);
2401
+ assert.equal(frames.length, 2);
2402
+ const [warmup, followUp] = frames;
2403
+ assert.equal(warmup.type, 'response.create');
2404
+ assert.equal(warmup.generate, false);
2405
+ assert.deepEqual(warmup.input, []);
2406
+ assert.equal(warmup.instructions, body.instructions);
2407
+ assert.deepEqual(warmup.tools, body.tools);
2408
+ assert.equal(warmup.stream, undefined);
2409
+ assert.equal(warmup.background, undefined);
2410
+ assert.equal(JSON.stringify(warmup).includes('live-transcript-must-appear-once'), false);
2411
+ assert.equal(followUp.type, 'response.create');
2412
+ assert.equal(followUp.previous_response_id, 'warm-1');
2413
+ assert.deepEqual(followUp.input, liveInput);
2414
+ assert.equal(JSON.stringify(followUp).match(/live-transcript-must-appear-once/g)?.length, 1);
2415
+ assert.equal(result.usage.inputTokens, 30, 'warmup remains included in billable totals');
2416
+ assert.equal(result.usage.mainInputTokens, 20, 'main context snapshot remains separate');
2417
+ } finally {
2418
+ for (const [name, value] of Object.entries(savedEnv)) {
2419
+ if (value == null) delete process.env[name];
2420
+ else process.env[name] = value;
2421
+ }
2422
+ }
2423
+ });
2424
+
2263
2425
  test('openai oauth ws delta: native tool_search output replays with canonical fields', () => {
2264
2426
  const prevTransport = process.env.MIXDOG_OAI_TRANSPORT;
2265
2427
  try {
@@ -55,13 +55,26 @@ test('mature closed on-disk orphan absent from summary index gets tombstone-swep
55
55
  };
56
56
  writeFileSync(join(sessionsDir, `${orphan.id}.json`), JSON.stringify(orphan));
57
57
 
58
+ // (3) A shared-store tombstone whose id still has local in-flight work.
59
+ // It must be excluded before unlink, otherwise that work's late save
60
+ // could recreate an open session after the tombstone disappears.
61
+ const locallyLive = {
62
+ ...orphan,
63
+ id: 'sess_shared_tombstone_locally_live',
64
+ };
65
+ writeFileSync(join(sessionsDir, `${locallyLive.id}.json`), JSON.stringify(locallyLive));
66
+
58
67
  // Index contains ONLY the indexed session — the orphan is absent.
59
68
  const rows = _writeSummaryIndex([_sessionSummary(indexed)]);
60
69
  assert.equal(rows.length, 1, 'index seeded with exactly one row');
61
70
  assert.ok(!rows.some((r) => r.id === orphan.id), 'orphan is absent from the summary index');
62
71
 
63
72
  // Tombstone-only pass (no idle sweep) with a 1h maturity threshold.
64
- const result = sweepStaleSessions({ sweepIdle: false, tombstoneMaxAgeMs: ONE_HOUR });
73
+ const result = sweepStaleSessions({
74
+ sweepIdle: false,
75
+ tombstoneMaxAgeMs: ONE_HOUR,
76
+ isSessionLive: (id) => id === locallyLive.id,
77
+ });
65
78
 
66
79
  assert.equal(result.tombstonesCleaned, 1, 'the orphan tombstone was reclaimed');
67
80
  assert.ok(
@@ -76,6 +89,19 @@ test('mature closed on-disk orphan absent from summary index gets tombstone-swep
76
89
  existsSync(join(sessionsDir, `${indexed.id}.json`)),
77
90
  'fresh open indexed session was preserved',
78
91
  );
92
+ assert.ok(
93
+ existsSync(join(sessionsDir, `${locallyLive.id}.json`)),
94
+ 'locally live shared-store tombstone was excluded before unlink',
95
+ );
96
+ const afterLocalWork = sweepStaleSessions({
97
+ sweepIdle: false,
98
+ tombstoneMaxAgeMs: ONE_HOUR,
99
+ isSessionLive: () => false,
100
+ });
101
+ assert.ok(
102
+ afterLocalWork.tombstoneDetails.some((d) => d.id === locallyLive.id),
103
+ 'protected tombstone is reclaimed once local work settles',
104
+ );
79
105
  } finally {
80
106
  try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
81
107
  delete process.env.MIXDOG_DATA_DIR;
@@ -12,36 +12,46 @@ const KEYCHAIN_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 150
12
12
  const POWERSHELL_TIMEOUT_MS = KEYCHAIN_TIMEOUT_MS;
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
- // getSecret read cache — avoid redundant platform-calls within TTL.
15
+ // getSecret read cache — collapse repeated reads during a turn while bounding
16
+ // cross-process staleness. Misses are never cached, so a token saved by another
17
+ // process becomes visible immediately; hits expire so rotations are observed.
16
18
  // ---------------------------------------------------------------------------
17
19
  const KEYCHAIN_CACHE_TTL_MS = (() => {
18
- const v = parseInt(process.env.MIXDOG_KEYCHAIN_CACHE_TTL_MS, 10);
19
- return Number.isFinite(v) ? v : 300000;
20
+ const value = Number(process.env.MIXDOG_KEYCHAIN_CACHE_TTL_MS);
21
+ return Number.isFinite(value) && value >= 0 ? value : 30000;
20
22
  })();
21
- /** @type {Map<string, {value: (string|null), ts: number}> | null} */
22
- let _secretCache = null;
23
+ /** @type {Map<string, {value: string, expiresAt: number}>} */
24
+ const _secretCache = new Map();
23
25
 
24
26
  function _cacheGet(account) {
25
- if (KEYCHAIN_CACHE_TTL_MS <= 0) return undefined;
26
- if (!_secretCache) return undefined;
27
- const entry = _secretCache.get(account);
27
+ const key = `${SERVICE}\0${account}`;
28
+ const entry = _secretCache.get(key);
28
29
  if (!entry) return undefined;
29
- if (Date.now() - entry.ts >= KEYCHAIN_CACHE_TTL_MS) {
30
- _secretCache.delete(account);
30
+ if (entry.expiresAt <= Date.now()) {
31
+ _secretCache.delete(key);
31
32
  return undefined;
32
33
  }
33
- return entry.value; // may be null (cached miss)
34
+ return entry.value;
34
35
  }
35
36
 
36
37
  function _cacheSet(account, value) {
37
- if (KEYCHAIN_CACHE_TTL_MS <= 0) return;
38
- if (!_secretCache) _secretCache = new Map();
39
- _secretCache.set(account, { value, ts: Date.now() });
38
+ if (value == null || KEYCHAIN_CACHE_TTL_MS === 0) return;
39
+ _secretCache.set(`${SERVICE}\0${account}`, {
40
+ value,
41
+ expiresAt: Date.now() + KEYCHAIN_CACHE_TTL_MS,
42
+ });
40
43
  }
41
44
 
42
45
  function _cacheInvalidate(account) {
43
- if (!_secretCache) return;
44
- _secretCache.delete(account);
46
+ _secretCache.delete(`${SERVICE}\0${account}`);
47
+ }
48
+
49
+ function invalidateSecretCache(account) {
50
+ if (account == null) {
51
+ _secretCache.clear();
52
+ return;
53
+ }
54
+ _cacheInvalidate(account);
45
55
  }
46
56
 
47
57
  // CommonJS module: cannot import the ESM src/shared/wsl.mjs, so inline an
@@ -332,13 +342,16 @@ function hasSecret(account) {
332
342
  }
333
343
 
334
344
  function setSecret(account, value) {
335
- switch (platform()) {
336
- case 'darwin': darwinSet(account, value); break;
337
- case 'linux': linuxSet(account, value); break;
338
- case 'win32': win32Set(account, value); break;
339
- default: throw new Error(`[keychain] unsupported platform: ${process.platform}`);
345
+ try {
346
+ switch (platform()) {
347
+ case 'darwin': darwinSet(account, value); break;
348
+ case 'linux': linuxSet(account, value); break;
349
+ case 'win32': win32Set(account, value); break;
350
+ default: throw new Error(`[keychain] unsupported platform: ${process.platform}`);
351
+ }
352
+ } finally {
353
+ _cacheInvalidate(account);
340
354
  }
341
- _cacheSet(account, value);
342
355
  }
343
356
 
344
357
  function deleteSecret(account) {
@@ -354,4 +367,4 @@ function deleteSecret(account) {
354
367
  }
355
368
  }
356
369
 
357
- module.exports = { getSecret, setSecret, deleteSecret, hasSecret, SERVICE };
370
+ module.exports = { getSecret, setSecret, deleteSecret, hasSecret, invalidateSecretCache, SERVICE };