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
@@ -11,6 +11,20 @@ import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
11
11
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
12
12
  import { randomBytes } from 'crypto';
13
13
  import { createActiveToolItemTracker } from './tool-stream-state.mjs';
14
+ import {
15
+ truncatedCompatStreamError,
16
+ makeInvalidToolArgsMarker,
17
+ isInvalidToolArgsMarker,
18
+ formatInvalidToolArgsResult,
19
+ parseCompletedToolCallArgumentsJson,
20
+ } from './lib/openai-tool-args.mjs';
21
+
22
+ export {
23
+ makeInvalidToolArgsMarker,
24
+ isInvalidToolArgsMarker,
25
+ formatInvalidToolArgsResult,
26
+ parseCompletedToolCallArgumentsJson,
27
+ };
14
28
 
15
29
  // Synthesize a native-shaped OpenAI tool call from a recovered leaked call.
16
30
  // Matches the `call_...` id scheme the native Responses/Chat paths use so the
@@ -25,77 +39,6 @@ function synthLeakedOpenAICall(recovered) {
25
39
  };
26
40
  }
27
41
 
28
- function truncatedCompatStreamError(label, detail) {
29
- return Object.assign(
30
- new Error(`${label} SSE stream truncated${detail ? `: ${detail}` : ''}`),
31
- { name: 'TruncatedStreamError', code: 'TRUNCATED_STREAM', truncatedStream: true },
32
- );
33
- }
34
-
35
- // Invalid-tool-args marker: completed-but-malformed tool_call arguments JSON must NOT throw
36
- // (kills the turn) NOR be silently swallowed to `{}`. Instead the parse
37
- // failure is carried as data on the tool call's `arguments` slot so the
38
- // dispatch loop can turn it into an is_error tool_result and let the model
39
- // re-issue the call with valid JSON in the SAME turn (follow-up retry).
40
- // { __invalidToolArgs: true, __rawArguments: <raw string>, __parseError: <msg> }
41
- export function makeInvalidToolArgsMarker(rawArguments, parseError) {
42
- return {
43
- __invalidToolArgs: true,
44
- __rawArguments: typeof rawArguments === 'string' ? rawArguments : String(rawArguments ?? ''),
45
- __parseError: typeof parseError === 'string' ? parseError : String(parseError ?? 'parse error'),
46
- };
47
- }
48
- export function isInvalidToolArgsMarker(value) {
49
- return !!value && typeof value === 'object' && value.__invalidToolArgs === true;
50
- }
51
- /** Model-facing tool_result text for a tool call whose arguments failed to
52
- * parse; instructs the model to retry with valid JSON in the same turn. */
53
- export function formatInvalidToolArgsResult(call) {
54
- const name = call?.name || 'tool';
55
- const detail = call?.arguments?.__parseError || 'arguments were not valid JSON';
56
- return `The arguments provided to \`${name}\` are invalid JSON and could not be parsed: ${detail}. Re-issue this tool call with valid JSON arguments.`;
57
- }
58
-
59
- /** Completed tool_call.arguments must be valid JSON; empty/missing → {}.
60
- * @param {any} raw - raw arguments value (string or object)
61
- * @param {string} label - provider label for error messages
62
- * @param {{id?:string,name?:string,index?:number,finishReason?:string}} [meta] - optional tool-call identity for diagnostics.
63
- * When `meta.finishReason` is set, a completion/finish signal was observed for
64
- * the call: a JSON.parse failure is then deterministic bad JSON (permanent),
65
- * not a mid-stream truncation (retryable). */
66
- export function parseCompletedToolCallArgumentsJson(raw, label, meta) {
67
- const text = typeof raw === 'string' ? raw : (raw == null ? '' : String(raw));
68
- const src = text === '' ? '{}' : text;
69
- try {
70
- return JSON.parse(src);
71
- } catch (err) {
72
- const preview = text.length <= 64
73
- ? text
74
- : text.slice(0, 32) + '...' + text.slice(-32);
75
- const detailParts = [`invalid tool_call arguments JSON: len=${text.length} preview=${JSON.stringify(preview)}`];
76
- if (meta) {
77
- const m = {};
78
- if (meta.id) m.id = meta.id;
79
- if (meta.name) m.name = meta.name;
80
- if (meta.index != null) m.index = meta.index;
81
- if (meta.finishReason) m.finishReason = meta.finishReason;
82
- detailParts.push(`tool=${JSON.stringify(m)}`);
83
- }
84
- // Invariant: a completion/finish signal was observed for this tool call
85
- // (finish_reason present, or a per-call/response "done" event fired), so
86
- // the arguments are NOT mid-stream-truncated — they are complete but
87
- // malformed. Return an invalid-args MARKER (not a throw) so the
88
- // dispatch loop feeds the parse error back to the model as a
89
- // tool_result and the model self-corrects in the same turn. Only an
90
- // unfinished stream (no finishReason) stays the retryable truncation
91
- // case — that transient behavior is deliberately preserved.
92
- if (meta?.finishReason) {
93
- return makeInvalidToolArgsMarker(text, err instanceof Error ? err.message : String(err));
94
- }
95
- throw truncatedCompatStreamError(label, detailParts.join(' '));
96
- }
97
- }
98
-
99
42
  function firstByteCompatStreamError(label) {
100
43
  const err = providerTimeoutError(`${label} first byte`, PROVIDER_FIRST_BYTE_TIMEOUT_MS);
101
44
  err.firstByteTimeout = true;
@@ -3,6 +3,7 @@ import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
3
3
  import { withRetry } from './retry-classifier.mjs';
4
4
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
5
5
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
6
+ import { _combineUsageWithWarmup } from './openai-ws-events.mjs';
6
7
  import {
7
8
  consumeCompatChatCompletionStream,
8
9
  consumeCompatResponsesStream,
@@ -67,6 +68,36 @@ function loadOpenAI() {
67
68
  }
68
69
  return _OpenAI;
69
70
  }
71
+
72
+ function attachCompletedWarmup(err, warmup) {
73
+ if (!err || !warmup?.usage) return err;
74
+ try {
75
+ Object.defineProperty(err, '__warmup', {
76
+ value: warmup,
77
+ configurable: true,
78
+ enumerable: false,
79
+ });
80
+ } catch {}
81
+ return err;
82
+ }
83
+
84
+ function includeCompletedXaiWarmup(result, warmup) {
85
+ if (!result || !warmup?.usage) return result;
86
+ const usage = _combineUsageWithWarmup(result.usage, warmup.usage, {
87
+ separateMainContext: true,
88
+ });
89
+ const ticks = usage?.raw?.cost_in_usd_ticks;
90
+ const costUsd = typeof ticks === 'number' && ticks >= 0
91
+ ? Number((ticks * 1e-10).toFixed(8))
92
+ : undefined;
93
+ return {
94
+ ...result,
95
+ usage: usage ? {
96
+ ...usage,
97
+ ...(costUsd != null ? { costUsd } : {}),
98
+ } : usage,
99
+ };
100
+ }
70
101
  export { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
71
102
  const PRESETS = OPENAI_COMPAT_PRESETS;
72
103
  const MODEL_LIST_TIMEOUT_MS = resolveTimeoutMs(
@@ -257,7 +288,10 @@ export class OpenAICompatProvider {
257
288
  }
258
289
  process.stderr.write(`[provider] Auth error, re-reading provider authentication...\n`);
259
290
  this.reloadApiKey();
260
- return await this._doSend(messages, model, tools, sendOpts);
291
+ const retryOpts = this.name === 'xai' && err?.__warmup?.usage
292
+ ? { ...(sendOpts || {}), _carriedWarmup: err.__warmup }
293
+ : sendOpts;
294
+ return await this._doSend(messages, model, tools, retryOpts);
261
295
  }
262
296
  throw err;
263
297
  }
@@ -274,6 +308,15 @@ export class OpenAICompatProvider {
274
308
  this._preconnectFn(this.baseURL);
275
309
  }
276
310
  if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
311
+ const carriedWarmup = opts._carriedWarmup?.usage ? opts._carriedWarmup : null;
312
+ const sendHttpWithWarmup = async (warmup = carriedWarmup) => {
313
+ try {
314
+ const result = await this._doSendXaiResponses(messages, useModel, tools, opts);
315
+ return includeCompletedXaiWarmup(result, warmup);
316
+ } catch (err) {
317
+ throw attachCompletedWarmup(err, warmup);
318
+ }
319
+ };
277
320
  // Shared Responses transport switch (MIXDOG_OAI_TRANSPORT), capability-
278
321
  // gated for xAI/Grok. Provider-local HTTP pins still win: Grok
279
322
  // proxy-only models set responsesTransport:'http' because the WS
@@ -315,12 +358,12 @@ export class OpenAICompatProvider {
315
358
  },
316
359
  });
317
360
  } catch {}
318
- return await this._doSendXaiResponses(messages, useModel, tools, opts);
361
+ return await sendHttpWithWarmup(err?.__warmup || carriedWarmup);
319
362
  }
320
363
  throw err;
321
364
  }
322
365
  }
323
- return await this._doSendXaiResponses(messages, useModel, tools, opts);
366
+ return await sendHttpWithWarmup();
324
367
  }
325
368
  const signal = opts.signal || null;
326
369
  if (signal?.aborted) {
@@ -788,6 +831,7 @@ export class OpenAICompatProvider {
788
831
  traceProvider: 'xai',
789
832
  logSuppressedReasoningDeltas: false,
790
833
  warmupBody,
834
+ _carriedWarmup: opts._carriedWarmup || null,
791
835
  // Mirror openai-oauth fast fallback: when the HTTP fallback is
792
836
  // enabled (outer catch → _shouldFallbackXaiWsToHttp), a first
793
837
  // acquire/first-byte failure should skip remaining WS
@@ -33,6 +33,7 @@ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './ant
33
33
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
34
34
  import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL, _displayCodexModel } from './openai-oauth.mjs';
35
35
  import { createActiveToolItemTracker } from './tool-stream-state.mjs';
36
+ export { envPositiveInt as _envPositiveInt } from './lib/env-utils.mjs';
36
37
 
37
38
  // Public OpenAI Responses API endpoint for the api-key `openai` provider.
38
39
  // The openai-direct WS transport hits the same origin (openai-ws-pool
@@ -50,13 +51,6 @@ export function _envFlag(name, fallback = true) {
50
51
  return !['0', 'false', 'off', 'no'].includes(String(raw).toLowerCase());
51
52
  }
52
53
 
53
- export function _envPositiveInt(name, fallback) {
54
- const raw = process.env[name];
55
- if (raw == null || raw === '') return fallback;
56
- const n = Number(raw);
57
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
58
- }
59
-
60
54
  // Completed function_call.arguments parse for the OpenAI Responses stream.
61
55
  // A function_call item arrives only on a completion/done signal, so a
62
56
  // non-empty-but-malformed
@@ -63,6 +63,7 @@ import {
63
63
  } from './openai-ws-stream.mjs';
64
64
  import { _buildResponseCreateFrame } from './openai-ws-delta.mjs';
65
65
  import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
66
+ import { envPositiveInt } from './lib/env-utils.mjs';
66
67
 
67
68
  // Legacy import paths for scripts/tool-smoke.mjs, mixdog-session-runtime.mjs
68
69
  // (drainOpenaiWsPool), scripts/provider-toolcall-test.mjs (parseToolSearchArgs,
@@ -80,7 +81,6 @@ export {
80
81
 
81
82
  globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
82
83
 
83
- // --- WS_PRE_RESPONSE_CREATED_MS / WS_INTER_CHUNK_MS: extracted to openai-ws-stream.mjs ---
84
84
  // The official Codex Responses policy has one five-retry stream budget shared
85
85
  // by connect/handshake and pre-output stream failures.
86
86
  const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries;
@@ -103,10 +103,6 @@ const HANDSHAKE_MAX_ATTEMPTS = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT + 1;
103
103
  const HANDSHAKE_BACKOFF_BASE_MS = 200;
104
104
  const HANDSHAKE_BACKOFF_CAP_MS = 3200;
105
105
 
106
- // --- WS pool/handshake/acquire/release: extracted to openai-ws-pool.mjs ---
107
-
108
- // --- Delta/matching helpers + parseToolSearchArgs: extracted to openai-ws-stream.mjs ---
109
- // --- Stream consumer (_streamResponse): extracted to openai-ws-stream.mjs ---
110
106
  export function _classifyHandshakeError(err) {
111
107
  return classifyHandshakeError(err);
112
108
  }
@@ -189,11 +185,6 @@ function _num(value, fallback = 0) {
189
185
  return Number.isFinite(n) ? n : fallback;
190
186
  }
191
187
 
192
- function _envPositiveInt(name, fallback) {
193
- const n = Number(process.env[name]);
194
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
195
- }
196
-
197
188
  function _envRatio(name, fallback) {
198
189
  const n = Number(process.env[name]);
199
190
  return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
@@ -409,8 +400,8 @@ function _cacheObservation({ entry, result, continuityResetReason = null }) {
409
400
  const promptTokens = _num(result?.usage?.promptTokens, 0) || inputTokens;
410
401
  const cachedTokens = _num(result?.usage?.cachedTokens, 0);
411
402
  const previousMaxCached = _num(entry?.promptCacheMaxCachedTokens, 0);
412
- const warmThreshold = _envPositiveInt('MIXDOG_OAI_CACHE_MISS_WARM_TOKENS', 2048);
413
- const promptThreshold = _envPositiveInt('MIXDOG_OAI_CACHE_MISS_PROMPT_TOKENS', 4096);
403
+ const warmThreshold = envPositiveInt('MIXDOG_OAI_CACHE_MISS_WARM_TOKENS', 2048);
404
+ const promptThreshold = envPositiveInt('MIXDOG_OAI_CACHE_MISS_PROMPT_TOKENS', 4096);
414
405
  const dropRatio = _envRatio('MIXDOG_OAI_CACHE_MISS_DROP_RATIO', 0.6);
415
406
  const dropThreshold = Math.floor(previousMaxCached * dropRatio);
416
407
  // A full-frame chain break (most commonly compaction/input-prefix rewrite)
@@ -456,7 +447,7 @@ function _requestInputExtends(previousInput, currentInput) {
456
447
  );
457
448
  }
458
449
 
459
- function _cacheContinuityResetReason({ mode, deltaReason, entry, body }) {
450
+ function _cacheContinuityResetReason({ mode, deltaReason, entry, body, traceProvider }) {
460
451
  if (mode === 'delta') return null;
461
452
  if (deltaReason && !['no_anchor', 'full_forced', 'full_default'].includes(deltaReason)) {
462
453
  return deltaReason;
@@ -465,7 +456,9 @@ function _cacheContinuityResetReason({ mode, deltaReason, entry, body }) {
465
456
  // full_default. Re-run the two cheap snapshot checks so compaction or any
466
457
  // other prompt rewrite still retires the old prompt's cache high-water.
467
458
  if (deltaReason !== 'full_default' || !entry?.lastResponseId) return null;
468
- const currentSansInput = _stableStringify(_sansInput(body));
459
+ const currentSansInput = _stableStringify(_sansInput(body, {
460
+ normalizeWarmupGenerate: traceProvider === 'openai-oauth',
461
+ }));
469
462
  if (entry.lastRequestSansInput && currentSansInput !== entry.lastRequestSansInput) {
470
463
  return 'request_properties_changed';
471
464
  }
@@ -652,6 +645,7 @@ export async function sendViaWebSocket({
652
645
  _sleepFn = _defaultSleep,
653
646
  _sendSpanTraceFn = appendAgentTrace,
654
647
  _agentTraceFn = appendAgentTrace,
648
+ _carriedWarmup = null,
655
649
  }) {
656
650
  // One bounded Codex stream retry budget covers transient handshake and
657
651
  // pre-output stream failures. Every retry acquires a fresh connection.
@@ -659,6 +653,21 @@ export async function sendViaWebSocket({
659
653
  const MAX_MIDSTREAM_RETRIES = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT;
660
654
  let firstAttemptError = null;
661
655
  let firstAttemptClassifier = null;
656
+ // A generate:false prewarm is billable even if its main request later
657
+ // retries on a fresh socket or falls back to HTTP. Retain one completed
658
+ // result across the whole logical send and attach it to terminal errors.
659
+ let completedWarmup = _carriedWarmup?.usage ? _carriedWarmup : null;
660
+ const _stampWarmup = (err) => {
661
+ if (!err || !completedWarmup?.usage) return err;
662
+ try {
663
+ Object.defineProperty(err, '__warmup', {
664
+ value: completedWarmup,
665
+ configurable: true,
666
+ enumerable: false,
667
+ });
668
+ } catch {}
669
+ return err;
670
+ };
662
671
  // Known tool names for the leaked-tool-call guard in _streamResponse.
663
672
  // Derived from the exact request body so a recovered leaked call only
664
673
  // synthesizes when it names a tool actually offered to this request.
@@ -785,6 +794,7 @@ export async function sendViaWebSocket({
785
794
  onBackoffSlept: (ms) => { sendSpan.retryBackoffMs += ms; },
786
795
  });
787
796
  } catch (err) {
797
+ _stampWarmup(err);
788
798
  sendSpan.poolAcquireMs += performance.now() - handshakeStart;
789
799
  // Provenance only; policy remains provider-owned below. This lets
790
800
  // the direct wrapper distinguish an upgrade rejection from an
@@ -850,7 +860,7 @@ export async function sendViaWebSocket({
850
860
  } catch (sleepErr) {
851
861
  sendSpan.retryBackoffMs += performance.now() - sleepStart;
852
862
  emitSendSpan('error');
853
- throw sleepErr;
863
+ throw _stampWarmup(sleepErr);
854
864
  }
855
865
  sendSpan.retryBackoffMs += performance.now() - sleepStart;
856
866
  continue;
@@ -951,28 +961,24 @@ export async function sendViaWebSocket({
951
961
  // codex prewarm gate (client.rs:1686-1688): only when the session
952
962
  // has no prior request state. A reused pooled socket with a live
953
963
  // chain must go straight to the real request.
954
- if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0 && !entry.lastResponseId) {
964
+ if (warmupBody && typeof warmupBody === 'object' && !completedWarmup
965
+ && attemptIndex === 0 && !entry.lastResponseId) {
955
966
  const warmupBuildStart = performance.now();
956
- // Codex WS prewarm parity (opt-in): codex's prewarm frame is a
957
- // minimal generate:false request that omits transport-only
958
- // fields (stream/background) on the wire (prewarm_websocket,
959
- // client.rs:1673-1705). Under MIXDOG_OAI_CODEX_WIRE_PARITY force
960
- // that omission for the warmup frame too; default is unchanged.
961
- const warmupWireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
962
- const parityWarmupBody = warmupWireParity
963
- ? { ...warmupBody, input: [], generate: false }
964
- : warmupBody;
965
- const warmupFrame = _buildResponseCreateFrame(parityWarmupBody, { omitTransportFields: warmupWireParity });
966
- const warmupMetadataContext = warmupWireParity
967
- ? {
968
- ...codexMetadataContext,
969
- sendOpts: {
970
- ...(codexMetadataContext?.sendOpts || {}),
971
- requestKind: 'prewarm',
972
- codexRequestKind: 'prewarm',
973
- },
974
- }
975
- : codexMetadataContext;
967
+ // Codex startup prewarm contains stable instructions/tools but
968
+ // no live user/transcript input. Enforce that at the transport
969
+ // boundary as well as in the OAuth caller so a future caller
970
+ // cannot accidentally duplicate the transcript. WS-only fields
971
+ // are omitted exactly as in Codex's prewarm request.
972
+ const parityWarmupBody = { ...warmupBody, input: [], generate: false };
973
+ const warmupFrame = _buildResponseCreateFrame(parityWarmupBody, { omitTransportFields: true });
974
+ const warmupMetadataContext = {
975
+ ...codexMetadataContext,
976
+ sendOpts: {
977
+ ...(codexMetadataContext?.sendOpts || {}),
978
+ requestKind: 'prewarm',
979
+ codexRequestKind: 'prewarm',
980
+ },
981
+ };
976
982
  const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, warmupMetadataContext);
977
983
  wireFrameHadTurnState = !!wireWarmupFrame?.client_metadata?.['x-codex-turn-state'];
978
984
  wireFrameMetadataTrace = _metadataTrace(wireWarmupFrame?.client_metadata);
@@ -1010,8 +1016,15 @@ export async function sendViaWebSocket({
1010
1016
  if (!warmupResult?.responseId) {
1011
1017
  throw new Error('Responses WS warmup completed without response id');
1012
1018
  }
1019
+ completedWarmup = {
1020
+ requestBody: parityWarmupBody,
1021
+ responseId: warmupResult.responseId,
1022
+ usage: warmupResult.usage,
1023
+ };
1013
1024
  entry.lastResponseId = warmupResult.responseId;
1014
- entry.lastRequestSansInput = _stableStringify(_sansInput(parityWarmupBody));
1025
+ entry.lastRequestSansInput = _stableStringify(_sansInput(parityWarmupBody, {
1026
+ normalizeWarmupGenerate: useCodexWsClientMetadata,
1027
+ }));
1015
1028
  const warmupInputArr = Array.isArray(parityWarmupBody.input) ? parityWarmupBody.input : [];
1016
1029
  entry.lastRequestInput = _cloneJson(warmupInputArr);
1017
1030
  entry.lastResponseItems = _cloneJson(Array.isArray(warmupResult.responseItems) ? warmupResult.responseItems : []);
@@ -1039,38 +1052,15 @@ export async function sendViaWebSocket({
1039
1052
  payload: warmupPayload,
1040
1053
  });
1041
1054
  } catch {}
1042
- // Do NOT rewrite the main request after warmup (R23 finding).
1043
- // The old prev_id+no-instructions rewrite made it=1's frame a
1044
- // different shape from it=2+ full frames, so the first real
1045
- // full-frame cache write only landed at it=2 and it=3 raced
1046
- // its propagation (cached=0 early misses). Keeping it=1 as a
1047
- // normal full frame makes it byte-identical to the warmup's
1048
- // prefix (instant full hit on the cache warmup just wrote)
1049
- // and keeps every subsequent frame one consistent shape.
1050
- // Delta opt-in still chains via entry.lastResponseId above.
1051
1055
  }
1052
1056
 
1053
- // Warmup writes the same prefix with generate:false, but the first
1054
- // real response must still be a FULL generating frame. Reusing the
1055
- // warmup response_id here would make _computeDelta reduce the frame
1056
- // input to [] when the warmup input matches, and a generate:false
1057
- // warmup is not a chainable response to continue from — so the first
1058
- // real turn would generate from an empty frame. Keep the warmup state
1059
- // for cache/trace, but compute the main frame as cold (full input +
1060
- // instructions).
1061
- const deltaEntry = warmupResult
1062
- ? {
1063
- ...entry,
1064
- lastResponseId: null,
1065
- lastRequestSansInput: null,
1066
- lastRequestInput: null,
1067
- lastResponseItems: null,
1068
- lastInputLen: 0,
1069
- lastInputPrefixHash: null,
1070
- }
1071
- : entry;
1057
+ // A completed generate:false prewarm is a valid continuation
1058
+ // anchor. Compute against its retained empty-input snapshot so the
1059
+ // first real request sends previous_response_id plus exactly the
1060
+ // real incremental input. _computeDelta still retreats to a full
1061
+ // frame on every missing anchor/property/prefix/output mismatch.
1072
1062
  const requestBuildStart = performance.now();
1073
- const delta = _computeDelta({ entry: deltaEntry, body: requestBody, traceProvider });
1063
+ const delta = _computeDelta({ entry, body: requestBody, traceProvider });
1074
1064
  ({ mode, frame } = delta);
1075
1065
  deltaReason = delta.reason || null;
1076
1066
  strippedResponseItems = delta.strippedResponseItems || 0;
@@ -1138,6 +1128,7 @@ export async function sendViaWebSocket({
1138
1128
  knownToolNames,
1139
1129
  });
1140
1130
  } catch (err) {
1131
+ _stampWarmup(err);
1141
1132
  // Preserve failure provenance for the direct provider. This marker
1142
1133
  // is observational for OAuth and does not change its classifier or
1143
1134
  // retry budget.
@@ -1210,7 +1201,7 @@ export async function sendViaWebSocket({
1210
1201
  } catch (sleepErr) {
1211
1202
  sendSpan.retryBackoffMs += performance.now() - sleepStart;
1212
1203
  emitSendSpan('error');
1213
- throw sleepErr;
1204
+ throw _stampWarmup(sleepErr);
1214
1205
  }
1215
1206
  sendSpan.retryBackoffMs += performance.now() - sleepStart;
1216
1207
  continue;
@@ -1298,10 +1289,13 @@ export async function sendViaWebSocket({
1298
1289
  deltaReason,
1299
1290
  entry,
1300
1291
  body: requestBody,
1292
+ traceProvider,
1301
1293
  });
1302
1294
  if (result.responseId && keepResponseChain) {
1303
1295
  entry.lastResponseId = result.responseId;
1304
- entry.lastRequestSansInput = _stableStringify(_sansInput(requestBody));
1296
+ entry.lastRequestSansInput = _stableStringify(_sansInput(requestBody, {
1297
+ normalizeWarmupGenerate: useCodexWsClientMetadata,
1298
+ }));
1305
1299
  const inputArr = Array.isArray(requestBody.input) ? requestBody.input : [];
1306
1300
  entry.lastRequestInput = _cloneJson(inputArr);
1307
1301
  entry.lastResponseItems = _cloneJson(Array.isArray(result.responseItems) ? result.responseItems : []);
@@ -1329,8 +1323,13 @@ export async function sendViaWebSocket({
1329
1323
  result,
1330
1324
  continuityResetReason: cacheContinuityResetReason,
1331
1325
  });
1332
- if (warmupResult?.usage) {
1333
- result.usage = _combineUsageWithWarmup(result.usage, warmupResult.usage);
1326
+ if (completedWarmup?.usage) {
1327
+ result.usage = _combineUsageWithWarmup(result.usage, completedWarmup.usage, {
1328
+ // xAI/Grok prewarm is billable just like Codex prewarm, but it
1329
+ // is not part of the real request's context footprint. Direct
1330
+ // OpenAI intentionally retains its existing usage shape.
1331
+ separateMainContext: useCodexWsClientMetadata || traceProvider === 'xai',
1332
+ });
1334
1333
  }
1335
1334
 
1336
1335
  const requestedServiceTier = body?.service_tier || null;
@@ -1539,14 +1538,10 @@ export async function sendViaWebSocket({
1539
1538
  releaseWebSocket({ entry, poolKey, keep: keepSocket });
1540
1539
  const { responseId: _ignored, responseItems: _responseItemsIgnored, closeSocket: _closeSocketIgnored, ...out } = result;
1541
1540
  if (includeResponseId && result.responseId) out.responseId = result.responseId;
1542
- if (warmupResult) {
1541
+ if (completedWarmup) {
1543
1542
  try {
1544
1543
  Object.defineProperty(out, '__warmup', {
1545
- value: {
1546
- requestBody,
1547
- responseId: warmupResult.responseId,
1548
- usage: warmupResult.usage,
1549
- },
1544
+ value: completedWarmup,
1550
1545
  enumerable: false,
1551
1546
  });
1552
1547
  } catch {}
@@ -1558,5 +1553,5 @@ export async function sendViaWebSocket({
1558
1553
  return out;
1559
1554
  }
1560
1555
  // Unreachable — the loop either returns or throws above.
1561
- throw _stampTool(_stampLiveText(firstAttemptError || new Error('sendViaWebSocket: unreachable')));
1556
+ throw _stampWarmup(_stampTool(_stampLiveText(firstAttemptError || new Error('sendViaWebSocket: unreachable'))));
1562
1557
  }
@@ -18,6 +18,7 @@ import { boundProviderAuthPath } from '../../../shared/provider-auth-binding.mjs
18
18
  import { makeModelCache } from './model-cache.mjs';
19
19
 
20
20
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
21
+ import { _combineUsageWithWarmup } from './openai-ws-events.mjs';
21
22
  import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
22
23
  import {
23
24
  buildStableProviderPromptCacheKey,
@@ -72,7 +73,7 @@ import {
72
73
  } from './openai-codex-model.mjs';
73
74
  export { _displayCodexModel };
74
75
 
75
- // Legacy import path for scripts/tool-smoke.mjs (single-emit SSE smoke).
76
+ // Public test/integration entry retained alongside the transport module export.
76
77
  export { sendViaHttpSse };
77
78
  // --- Constants ---
78
79
  const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
@@ -351,10 +352,6 @@ async function refreshTokens(refreshToken) {
351
352
  }
352
353
  }
353
354
  // --- Build Responses API request ---
354
- function _cloneJson(value) {
355
- try { return JSON.parse(JSON.stringify(value)); } catch { return value; }
356
- }
357
-
358
355
  function _contentTextParts(content, type = 'input_text') {
359
356
  if (typeof content === 'string') return content ? [{ type, text: content }] : [];
360
357
  if (!Array.isArray(content)) {
@@ -631,8 +628,6 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
631
628
  return ordered;
632
629
  }
633
630
 
634
- // --- HTTP/SSE fallback transport: extracted to openai-oauth-http-sse.mjs ---
635
-
636
631
  // --- Provider ---
637
632
  export class OpenAIOAuthProvider {
638
633
  // OpenAI input_tokens already INCLUDES cached_tokens (cached is a subset),
@@ -895,12 +890,17 @@ export class OpenAIOAuthProvider {
895
890
  useModel,
896
891
  fetchFn: opts._fetchFn,
897
892
  });
893
+ if (originalErr?.__warmup?.usage) {
894
+ result.usage = _combineUsageWithWarmup(result.usage, originalErr.__warmup.usage, {
895
+ separateMainContext: true,
896
+ });
897
+ }
898
898
  if (process.env.MIXDOG_DEBUG_AGENT) {
899
899
  process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok transport=http-fallback\n`);
900
900
  }
901
901
  return recordLiveModel(result);
902
902
  };
903
- const dispatchWs = (forceFresh = false) => sendWs({
903
+ const dispatchWs = (forceFresh = false, carriedWarmup = null) => sendWs({
904
904
  auth,
905
905
  body,
906
906
  sendOpts: opts,
@@ -922,18 +922,14 @@ export class OpenAIOAuthProvider {
922
922
  // the hot WS/cache path for temporary blips while still preventing
923
923
  // TUI-level hangs. Sticky HTTP fallback is only armed after this
924
924
  // bounded reconnect budget is exhausted.
925
- // codex-parity prewarm (generate:false full frame on a fresh
926
- // socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
927
- // R20(on) A/B shows prewarm removes ALL early-session zero-cache
928
- // misses (4 -> 0 at it<=3; zero-miss 3 -> 0) by writing the cache
929
- // and waiting for completion before the first real turn, exactly
930
- // like codex prewarm_websocket (client.rs:1673-1705). Cost is one
931
- // small (~5k tok) generate:false request per fresh socket, far
932
- // below the 5-12k uncached tokens each early miss burned.
933
- // Mid-session partial drops are server-side and unaffected.
925
+ // Codex startup-prewarm parity: build from the stable request
926
+ // properties (instructions/tools/etc.) but never send the live
927
+ // transcript/user input. The completed generate:false response is
928
+ // retained by the WS transport and anchors the first real request.
934
929
  warmupBody: _envFlag('MIXDOG_OPENAI_OAUTH_WS_WARMUP', true)
935
- ? { ...body, generate: false }
930
+ ? { ...body, input: [], generate: false }
936
931
  : null,
932
+ _carriedWarmup: carriedWarmup,
937
933
  });
938
934
  const mustSurfaceFallbackError = (fallbackErr) => externalSignal?.aborted
939
935
  || fallbackErr?.name === 'AbortError'
@@ -974,7 +970,7 @@ export class OpenAIOAuthProvider {
974
970
  this._refreshFallbackUntil = 0;
975
971
  auth = await this.ensureAuth({ forceRefresh: true, reason: String(status) });
976
972
  try {
977
- const result = await dispatchWs(true);
973
+ const result = await dispatchWs(true, err?.__warmup || null);
978
974
  if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok\n`); }
979
975
  return recordLiveModel(result);
980
976
  } catch (retryErr) {
@@ -1121,7 +1117,6 @@ export class OpenAIOAuthProvider {
1121
1117
  }
1122
1118
  }
1123
1119
 
1124
- // --- OAuth PKCE login flow: extracted to openai-oauth-login.mjs ---
1125
1120
  const { beginOAuthLogin, loginOAuth } = createOpenAIOAuthLogin({
1126
1121
  clientId: CLIENT_ID,
1127
1122
  originator: CODEX_OAUTH_ORIGINATOR,
@@ -20,16 +20,14 @@ import { createHash } from 'node:crypto';
20
20
  // If the cached request (sans input) matches the current one and the current
21
21
  // input starts with the cached input, return only the tail. Otherwise return
22
22
  // the full input (fresh turn).
23
- export function _sansInput(body) {
23
+ export function _sansInput(body, { normalizeWarmupGenerate = false } = {}) {
24
24
  const { input: _ignored, previous_response_id: _prevIgnored, generate, ...rest } = body;
25
- // Warmup/prewarm frames carry generate:false on the wire (codex prewarm
26
- // marker, openai-oauth.mjs warmupBody). That flag must NOT count as a
27
- // request-property change on the warmup->first-real comparison, or the
28
- // first real turn always retreats to a full frame. Normalize away ONLY the
29
- // warmup-only generate:false; any other generate value stays in the
30
- // comparison snapshot so genuine differences still break the delta. The
31
- // wire body is untouched — frames are built from the raw body, not this.
32
- if (generate !== false && generate !== undefined) rest.generate = generate;
25
+ // Only OpenAI OAuth/Codex startup prewarm treats generate:false as a
26
+ // transport marker. Shared xAI callers retain it as a real request
27
+ // property so their existing property guard still falls back to full.
28
+ if (!(normalizeWarmupGenerate && generate === false) && generate !== undefined) {
29
+ rest.generate = generate;
30
+ }
33
31
  return rest;
34
32
  }
35
33
 
@@ -348,7 +346,9 @@ export function _computeDelta({ entry, body, traceProvider }) {
348
346
  if (!Array.isArray(entry.lastRequestInput)) {
349
347
  return { mode: 'full', reason: 'no_input_snapshot', frame: buildFrame(body) };
350
348
  }
351
- const curSans = _stableStringify(_sansInput(body));
349
+ const curSans = _stableStringify(_sansInput(body, {
350
+ normalizeWarmupGenerate: traceProvider === 'openai-oauth',
351
+ }));
352
352
  if (curSans !== entry.lastRequestSansInput) {
353
353
  return { mode: 'full', reason: 'request_properties_changed', frame: buildFrame(body) };
354
354
  }