mixdog 0.9.50 → 0.9.52

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 (134) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -40,9 +40,6 @@ import {
40
40
  MIDSTREAM_RETRY_POLICY,
41
41
  sleepWithAbort,
42
42
  } from './retry-classifier.mjs';
43
- import {
44
- PROVIDER_RETRY_MAX_ATTEMPTS,
45
- } from '../stall-policy.mjs';
46
43
  import {
47
44
  WS_IDLE_MS,
48
45
  acquireWebSocket,
@@ -84,12 +81,14 @@ export {
84
81
  globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
85
82
 
86
83
  // --- WS_PRE_RESPONSE_CREATED_MS / WS_INTER_CHUNK_MS: extracted to openai-ws-stream.mjs ---
87
- // Mid-stream retry budgets + backoff now live in the shared MIDSTREAM_RETRY_POLICY
88
- // table (retry-classifier.mjs). These aliases keep the local call sites readable
89
- // and ensure the numbers exist in exactly ONE place.
84
+ // The official Codex Responses policy has one five-retry stream budget shared
85
+ // by connect/handshake and pre-output stream failures.
90
86
  const MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.transientCloseRetries;
91
87
  const MIDSTREAM_DEFAULT_RETRY_LIMIT = MIDSTREAM_RETRY_POLICY.ws.defaultRetries;
92
- const MIDSTREAM_BACKOFF_MS = MIDSTREAM_RETRY_POLICY.ws.backoff;
88
+ // Codex core/src/util.rs uses a 200ms base, factor 2, and symmetric ±10%
89
+ // jitter for each of its five stream retries.
90
+ const MIDSTREAM_BACKOFF_MS = Object.freeze([200, 400, 800, 1600, 3200]);
91
+ const CODEX_RETRY_JITTER_RATIO = 0.1;
93
92
  // Policy object passed to the shared classifyMidstreamError for the WS path.
94
93
  const WS_MIDSTREAM_POLICY = {
95
94
  mode: 'ws',
@@ -97,20 +96,12 @@ const WS_MIDSTREAM_POLICY = {
97
96
  defaultRetries: MIDSTREAM_RETRY_POLICY.ws.defaultRetries,
98
97
  };
99
98
 
100
- // Handshake retry policy. The `ws` library surfaces a bare
101
- // `Opening handshake has timed out` Error after handshakeTimeout; transient
102
- // network blips (DNS, reset, 5xx) similarly produce single-shot failures that
103
- // waste the caller's turn when they'd succeed on retry. We wrap the acquire
104
- // step with bounded exponential backoff. Permanent auth/quota (4xx) must NOT
105
- // retry because a second attempt will hit the same deterministic server
106
- // decision and just double the user-visible latency.
107
- // Aligned to the cross-provider default (retry-classifier DEFAULT_MAX_ATTEMPTS=5,
108
- // anthropic-oauth MAX_ATTEMPTS=5, withRetry-using providers all default to 5).
109
- // Bumped from 3 so this provider exhausts the same number of transient-5xx
110
- // attempts as the others before surfacing failure to the caller.
111
- const HANDSHAKE_MAX_ATTEMPTS = PROVIDER_RETRY_MAX_ATTEMPTS;
112
- const HANDSHAKE_BACKOFF_BASE_MS = 500;
113
- const HANDSHAKE_BACKOFF_CAP_MS = 5000;
99
+ // Kept for the exported _acquireWithRetry test seam and non-Codex callers.
100
+ // sendViaWebSocket deliberately invokes it with maxAttempts:1 so handshake
101
+ // failures consume the same stream budget as pre-output disconnects.
102
+ const HANDSHAKE_MAX_ATTEMPTS = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT + 1;
103
+ const HANDSHAKE_BACKOFF_BASE_MS = 200;
104
+ const HANDSHAKE_BACKOFF_CAP_MS = 3200;
114
105
 
115
106
  // --- WS pool/handshake/acquire/release: extracted to openai-ws-pool.mjs ---
116
107
 
@@ -152,22 +143,21 @@ export function _classifyHandshakeError(err) {
152
143
  * after the 101 upgrade but before the first response.created event,
153
144
  * AND the pre-`response.created` first-byte timeout
154
145
  * (state.firstByteTimeout), which is permitted a bounded retry here
155
- * - HTTP 401 / 403 / 429 surfaced on the error
146
+ * - HTTP 401 / 403 / 429 surfaced after the WS handshake
156
147
  * - state.attemptIndex has reached the classifier-specific retry budget
157
148
  */
158
149
  // Thin wrapper: the full WS mid-stream decision tree now lives in the shared
159
150
  // classifyMidstreamError (retry-classifier.mjs, policy.mode='ws'). Kept as a
160
151
  // named export so internal call sites and any external importer keep resolving
161
- // the same symbol. Behavior is byte-identical the shared function is the
162
- // relocated original, with the per-classifier budget gating supplied by
163
- // WS_MIDSTREAM_POLICY (transientCloseRetries=4, defaultRetries=2).
152
+ // the same symbol. The per-classifier gates both use the official five-retry
153
+ // stream budget.
164
154
  export function _classifyMidstreamError(err, state) {
165
155
  return classifyMidstreamError(err, state, WS_MIDSTREAM_POLICY);
166
156
  }
167
157
 
168
158
  // Per-classifier retry budget, used by the sendViaWebSocket loop to bound the
169
159
  // attempt count once classifyMidstreamError returns a bucket. Mirrors the
170
- // shared _midstreamLimitFor(ws) — the numbers come from MIDSTREAM_RETRY_POLICY.
160
+ // shared _midstreamLimitFor(ws) — both values are the same unified budget.
171
161
  function _midstreamRetryLimit(classifier) {
172
162
  return classifier === 'ws_1006' || classifier === 'ws_1011'
173
163
  ? MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT
@@ -176,13 +166,13 @@ function _midstreamRetryLimit(classifier) {
176
166
 
177
167
  function _midstreamBackoffFor(retryNumber) {
178
168
  const raw = MIDSTREAM_BACKOFF_MS[Math.min(Math.max(retryNumber, 1), MIDSTREAM_BACKOFF_MS.length) - 1];
179
- return jitterDelayMs(raw);
169
+ return jitterDelayMs(raw, CODEX_RETRY_JITTER_RATIO);
180
170
  }
181
171
 
182
172
  function _backoffFor(attempt) {
183
173
  // attempt is 1-based. retry 1 → 500, retry 2 → 1000, retry 3 → 2000 … capped.
184
174
  const raw = HANDSHAKE_BACKOFF_BASE_MS * (1 << (attempt - 1));
185
- return jitterDelayMs(Math.min(raw, HANDSHAKE_BACKOFF_CAP_MS));
175
+ return jitterDelayMs(Math.min(raw, HANDSHAKE_BACKOFF_CAP_MS), CODEX_RETRY_JITTER_RATIO);
186
176
  }
187
177
 
188
178
  const _defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -648,6 +638,11 @@ export async function sendViaWebSocket({
648
638
  traceProvider = 'openai-oauth',
649
639
  logSuppressedReasoningDeltas = true,
650
640
  warmupBody = null,
641
+ // Provider-specific handshake policy seam. OAuth leaves this null and
642
+ // retains the Codex retry budget. Direct OpenAI uses it to surface
643
+ // unsupported-WS handshake statuses immediately so its wrapper can make
644
+ // the single WS→HTTP decision without nested WS retries.
645
+ handshakeErrorPolicy = null,
651
646
  // Test seams (undefined in production). Let the unit test drive the
652
647
  // retry state machine without opening real sockets or touching the
653
648
  // handshake-retry layer.
@@ -656,18 +651,11 @@ export async function sendViaWebSocket({
656
651
  _sendFrameFn = _sendFrame,
657
652
  _sleepFn = _defaultSleep,
658
653
  _sendSpanTraceFn = appendAgentTrace,
654
+ _agentTraceFn = appendAgentTrace,
659
655
  }) {
660
- // Bounded mid-stream retry: if an attempt's stream dies after
661
- // response.created but before response.completed from a transient cause
662
- // (watchdog abort / ws 1006/1011/1012/4000 / response.failed with network
663
- // error), tear down the socket and reissue the full request from scratch
664
- // with a classifier-specific budget. ws_1006/ws_1011 get two retries with
665
- // 250ms/1s backoff; other legacy transient buckets keep the prior one retry.
666
- // No delta resume — content restarts, which is the accepted tradeoff for
667
- // reviewer/worker flows that need the complete answer.
668
- // Retries are layered ABOVE the handshake retry loop (_acquireWithRetry
669
- // owns connect-level transience); the two never interleave because we
670
- // force a brand-new acquire for the retry attempt.
656
+ // One bounded Codex stream retry budget covers transient handshake and
657
+ // pre-output stream failures. Every retry acquires a fresh connection.
658
+ // No replay is permitted after live text or an emitted tool call.
671
659
  const MAX_MIDSTREAM_RETRIES = MIDSTREAM_WS_TRANSIENT_RETRY_LIMIT;
672
660
  let firstAttemptError = null;
673
661
  let firstAttemptClassifier = null;
@@ -783,20 +771,28 @@ export async function sendViaWebSocket({
783
771
  // one is either torn down or in an unknown state.
784
772
  forceFresh: forceFresh || attemptIndex > 0,
785
773
  externalSignal,
786
- maxAttempts: HANDSHAKE_MAX_ATTEMPTS,
774
+ // No nested connect retry budget: the outer stream loop owns
775
+ // all retries for this logical request.
776
+ maxAttempts: 1,
787
777
  onRetry: (info) => {
788
778
  handshakeRetries += 1;
789
779
  sendSpan.handshakeRetries += 1;
790
780
  if (info?.classifier) handshakeRetryClassifiers.push(info.classifier);
791
781
  const attempt = Number(info?.attempt) || handshakeRetries;
792
- const max = Number(info?.max) || Math.max(HANDSHAKE_MAX_ATTEMPTS - 1, 1);
782
+ const max = Number(info?.max) || MAX_MIDSTREAM_RETRIES;
793
783
  emitReconnectProgress({ attempt, max, classifier: info?.classifier });
794
784
  },
795
785
  onBackoffSlept: (ms) => { sendSpan.retryBackoffMs += ms; },
796
786
  });
797
787
  } catch (err) {
798
788
  sendSpan.poolAcquireMs += performance.now() - handshakeStart;
799
- const classifier = err?.retryClassifier || (err?.code === 'EWSACQUIRETIMEOUT' ? 'acquire_timeout' : null);
789
+ // Provenance only; policy remains provider-owned below. This lets
790
+ // the direct wrapper distinguish an upgrade rejection from an
791
+ // application error carrying the same HTTP status.
792
+ try { err.wsFailurePhase = 'handshake'; } catch {}
793
+ const classifier = err?.retryClassifier
794
+ || _classifyHandshakeError(err)
795
+ || (err?.code === 'EWSACQUIRETIMEOUT' ? 'acquire_timeout' : null);
800
796
  const classifiers = [...handshakeRetryClassifiers];
801
797
  if (classifier && !classifiers.includes(classifier)) classifiers.push(classifier);
802
798
  if (err?.httpStatus != null || classifier || handshakeRetries > 0 || classifiers.length > 0) {
@@ -811,12 +807,70 @@ export async function sendViaWebSocket({
811
807
  handshakeRetryClassifiers: classifiers,
812
808
  });
813
809
  }
814
- // Handshake-layer failure. Don't double-wrap: if this is the retry
815
- // attempt, surface the ORIGINAL first-attempt error (which is what
816
- // the caller's turn actually tripped on).
810
+ const handshakeDecision = typeof handshakeErrorPolicy === 'function'
811
+ ? handshakeErrorPolicy({
812
+ error: err,
813
+ status: Number(err?.httpStatus || 0),
814
+ classifier,
815
+ attempt: attemptIndex + 1,
816
+ maxAttempts: MAX_MIDSTREAM_RETRIES + 1,
817
+ })
818
+ : null;
819
+ if (handshakeDecision?.retry === false) {
820
+ try {
821
+ err.wsFailurePhase = 'handshake';
822
+ err.wsHttpFallbackEligible = handshakeDecision.httpFallback === true;
823
+ if (classifier) err.retryClassifier = classifier;
824
+ } catch {}
825
+ emitSendSpan('error');
826
+ throw _stampTool(_stampLiveText(err));
827
+ }
828
+ // HTTP 401 is reserved for caller-owned auth refresh. HTTP 426 is
829
+ // caller-owned immediate HTTPS fallback. Every other recognized
830
+ // transport failure spends the shared stream retry budget.
831
+ const retryable = classifier
832
+ && Number(err?.httpStatus || 0) !== 401
833
+ && Number(err?.httpStatus || 0) !== 426
834
+ && !externalSignal?.aborted;
835
+ if (retryable && attemptIndex < MAX_MIDSTREAM_RETRIES) {
836
+ if (!firstAttemptError) {
837
+ firstAttemptError = err;
838
+ firstAttemptClassifier = classifier;
839
+ }
840
+ try { err.midstreamClassifier = classifier; } catch {}
841
+ const retryNumber = attemptIndex + 1;
842
+ emitReconnectProgress({
843
+ attempt: retryNumber,
844
+ max: MAX_MIDSTREAM_RETRIES,
845
+ classifier,
846
+ });
847
+ const sleepStart = performance.now();
848
+ try {
849
+ await _sleepWithAbort(_midstreamBackoffFor(retryNumber), externalSignal, _sleepFn);
850
+ } catch (sleepErr) {
851
+ sendSpan.retryBackoffMs += performance.now() - sleepStart;
852
+ emitSendSpan('error');
853
+ throw sleepErr;
854
+ }
855
+ sendSpan.retryBackoffMs += performance.now() - sleepStart;
856
+ continue;
857
+ }
858
+ // A later auth/upgrade decision must win over an earlier transient
859
+ // failure so the caller can refresh or switch transport. Likewise,
860
+ // never replace a current cancellation with stale retry history.
861
+ const currentStatus = Number(err?.httpStatus || 0);
862
+ const surfaceCurrent = currentStatus === 401
863
+ || currentStatus === 426
864
+ || externalSignal?.aborted
865
+ || err?.unsafeToRetry === true;
866
+ if (surfaceCurrent) {
867
+ emitSendSpan('error');
868
+ throw _stampTool(_stampLiveText(err));
869
+ }
817
870
  if (attemptIndex > 0 && firstAttemptError) {
818
871
  try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
819
- if (err?.wsRetriesExhausted === true) {
872
+ try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
873
+ if (attemptIndex >= MAX_MIDSTREAM_RETRIES) {
820
874
  try { firstAttemptError.wsRetriesExhausted = true; } catch {}
821
875
  }
822
876
  emitSendSpan('error');
@@ -885,6 +939,7 @@ export async function sendViaWebSocket({
885
939
  let deltaReason = null;
886
940
  let strippedResponseItems = 0;
887
941
  let skippedResponseItems = 0;
942
+ let responseOutputMismatch = null;
888
943
  let wireFrameHadTurnState = false;
889
944
  let wireFrameMetadataTrace = _metadataTrace(null);
890
945
  let framePrefixHash = null;
@@ -976,7 +1031,7 @@ export async function sendViaWebSocket({
976
1031
  output_tokens: warmupResult.usage?.outputTokens || 0,
977
1032
  prompt_tokens: warmupResult.usage?.promptTokens || 0,
978
1033
  };
979
- appendAgentTrace({
1034
+ _agentTraceFn({
980
1035
  sessionId: poolKey,
981
1036
  iteration,
982
1037
  kind: 'cache_warmup',
@@ -1020,6 +1075,7 @@ export async function sendViaWebSocket({
1020
1075
  deltaReason = delta.reason || null;
1021
1076
  strippedResponseItems = delta.strippedResponseItems || 0;
1022
1077
  skippedResponseItems = delta.skippedResponseItems || 0;
1078
+ responseOutputMismatch = delta.responseOutputMismatch || null;
1023
1079
  const wireFrame = _withCodexWsClientMetadata(frame, entry, useCodexWsClientMetadata, codexMetadataContext);
1024
1080
  wireFrameHadTurnState = !!wireFrame?.client_metadata?.['x-codex-turn-state'];
1025
1081
  wireFrameMetadataTrace = _metadataTrace(wireFrame?.client_metadata);
@@ -1082,6 +1138,10 @@ export async function sendViaWebSocket({
1082
1138
  knownToolNames,
1083
1139
  });
1084
1140
  } catch (err) {
1141
+ // Preserve failure provenance for the direct provider. This marker
1142
+ // is observational for OAuth and does not change its classifier or
1143
+ // retry budget.
1144
+ try { err.wsFailurePhase = 'stream'; } catch {}
1085
1145
  // Snapshot the xAI conversation anchor BEFORE releasing the
1086
1146
  // entry. release closes the socket but leaves state fields
1087
1147
  // intact; the next forceFresh acquire creates a new entry into
@@ -1111,9 +1171,24 @@ export async function sendViaWebSocket({
1111
1171
  if (midState.emittedToolCall) {
1112
1172
  _safetyStamps.markTool();
1113
1173
  }
1174
+ // Reasoning deltas and an in-progress tool input are already
1175
+ // observable turn progress even though neither is final assistant
1176
+ // text nor a dispatched tool call. Reissuing from the beginning can
1177
+ // duplicate exposed thinking/tool argument streams and can make a
1178
+ // partially generated side-effecting call diverge. Keep the Codex
1179
+ // retry budget only for failures before any such model output.
1180
+ if (midState.emittedReasoning || midState.startedToolCall) {
1181
+ try {
1182
+ err.unsafeToRetry = true;
1183
+ if (midState.emittedReasoning) err.partialReasoningEmitted = true;
1184
+ if (midState.startedToolCall) err.partialToolCallStarted = true;
1185
+ } catch {}
1186
+ }
1114
1187
  _stampLiveText(err);
1115
1188
  _stampTool(err);
1116
- const classifier = _classifyMidstreamError(err, midState);
1189
+ const classifier = err?.unsafeToRetry === true
1190
+ ? null
1191
+ : _classifyMidstreamError(err, midState);
1117
1192
  const retryLimit = classifier ? _midstreamRetryLimit(classifier) : 0;
1118
1193
  if (classifier && attemptIndex < retryLimit) {
1119
1194
  // Retry-eligible: stash the first-attempt error, emit progress,
@@ -1141,6 +1216,17 @@ export async function sendViaWebSocket({
1141
1216
  continue;
1142
1217
  }
1143
1218
  // Not retryable, OR we've already exhausted the retry budget.
1219
+ // Do not let stale retry history mask a current auth/upgrade
1220
+ // decision, cancellation, or newly unsafe-to-replay outcome.
1221
+ const currentStatus = Number(err?.httpStatus || 0);
1222
+ const surfaceCurrent = currentStatus === 401
1223
+ || currentStatus === 426
1224
+ || externalSignal?.aborted
1225
+ || err?.unsafeToRetry === true;
1226
+ if (surfaceCurrent) {
1227
+ emitSendSpan('error');
1228
+ throw _stampTool(_stampLiveText(err));
1229
+ }
1144
1230
  if (attemptIndex > 0 && firstAttemptError) {
1145
1231
  // Exhausted path: surface the first-attempt error (the one
1146
1232
  // the user's turn actually tripped on), tag actual retry count.
@@ -1292,7 +1378,7 @@ export async function sendViaWebSocket({
1292
1378
  : null;
1293
1379
  if (cacheObservation.actualMiss) {
1294
1380
  try {
1295
- appendAgentTrace({
1381
+ _agentTraceFn({
1296
1382
  sessionId: poolKey,
1297
1383
  iteration,
1298
1384
  kind: 'cache_miss',
@@ -1389,6 +1475,7 @@ export async function sendViaWebSocket({
1389
1475
  chain_delta_reason: mode === 'delta' ? null : deltaReason,
1390
1476
  chain_stripped_response_items: strippedResponseItems,
1391
1477
  chain_skipped_response_items: skippedResponseItems,
1478
+ ...(responseOutputMismatch || {}),
1392
1479
  chain_response_items: Array.isArray(result.responseItems) ? result.responseItems.length : 0,
1393
1480
  body_input_items: Array.isArray(requestBody.input) ? requestBody.input.length : null,
1394
1481
  frame_input_items: Array.isArray(frame.input) ? frame.input.length : null,
@@ -1400,7 +1487,7 @@ export async function sendViaWebSocket({
1400
1487
  keep_socket: keepSocket,
1401
1488
  keep_response_chain: keepResponseChain,
1402
1489
  };
1403
- appendAgentTrace({
1490
+ _agentTraceFn({
1404
1491
  sessionId: poolKey,
1405
1492
  iteration,
1406
1493
  kind: 'transport',
@@ -1411,7 +1498,10 @@ export async function sendViaWebSocket({
1411
1498
  && deltaReason
1412
1499
  && !['no_anchor', 'full_forced', 'full_default', 'delta_missing_turn_state'].includes(deltaReason);
1413
1500
  if (chainFallback || (mode === 'delta' && deltaReason)) {
1414
- appendAgentTrace({
1501
+ const intentionalTransition = typeof sendOpts?.cacheBreakIntent === 'string'
1502
+ ? sendOpts.cacheBreakIntent
1503
+ : null;
1504
+ _agentTraceFn({
1415
1505
  sessionId: poolKey,
1416
1506
  iteration,
1417
1507
  kind: 'cache_break',
@@ -1423,6 +1513,8 @@ export async function sendViaWebSocket({
1423
1513
  transport: 'websocket',
1424
1514
  ws_mode: mode,
1425
1515
  reason: mode === 'delta' ? deltaReason : (deltaReason || 'full_frame'),
1516
+ intentional_transition: intentionalTransition,
1517
+ request_tool_choice: requestBody.tool_choice ?? null,
1426
1518
  cache_key_hash: transportCacheKeyHash,
1427
1519
  cached_tokens: cacheObservation.cachedTokens,
1428
1520
  prompt_tokens: cacheObservation.promptTokens,
@@ -1432,6 +1524,7 @@ export async function sendViaWebSocket({
1432
1524
  request_has_previous_response_id: transportPayload.request_has_previous_response_id,
1433
1525
  chain_stripped_response_items: strippedResponseItems,
1434
1526
  chain_skipped_response_items: skippedResponseItems,
1527
+ ...(responseOutputMismatch || {}),
1435
1528
  chain_response_items: Array.isArray(result.responseItems) ? result.responseItems.length : 0,
1436
1529
  body_input_items: Array.isArray(requestBody.input) ? requestBody.input.length : null,
1437
1530
  frame_input_items: Array.isArray(frame.input) ? frame.input.length : null,
@@ -58,7 +58,6 @@ import {
58
58
  import {
59
59
  sendViaHttpSse,
60
60
  _envFlag,
61
- _envPositiveInt,
62
61
  _shouldUseOpenAIHttpFallback,
63
62
  } from './openai-oauth-http-sse.mjs';
64
63
  import { createOpenAIOAuthLogin } from './openai-oauth-login.mjs';
@@ -830,8 +829,9 @@ export class OpenAIOAuthProvider {
830
829
  };
831
830
  const markStickyHttpFallback = () => {
832
831
  if (!poolKey) return;
833
- const ttlMs = _envPositiveInt('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK_STICKY_MS', 60_000);
834
- this._httpFallbackUntilByPoolKey.set(poolKey, Date.now() + ttlMs);
832
+ // Codex disables WebSockets for the remainder of this session after
833
+ // stream retry exhaustion (or an explicit 426 upgrade rejection).
834
+ this._httpFallbackUntilByPoolKey.set(poolKey, Number.POSITIVE_INFINITY);
835
835
  };
836
836
  const traceWsError = (err, stage = 'primary') => {
837
837
  try {
@@ -854,6 +854,9 @@ export class OpenAIOAuthProvider {
854
854
  } catch {}
855
855
  };
856
856
  const dispatchHttp = async (reason, originalErr = null, { sticky = false } = {}) => {
857
+ // Transport switching is a session decision, not an HTTP-success
858
+ // side effect. Persist it before the fallback can fail or abort.
859
+ if (sticky) markStickyHttpFallback();
857
860
  appendAgentTrace({
858
861
  sessionId: poolKey,
859
862
  iteration,
@@ -892,7 +895,6 @@ export class OpenAIOAuthProvider {
892
895
  useModel,
893
896
  fetchFn: opts._fetchFn,
894
897
  });
895
- if (sticky) markStickyHttpFallback();
896
898
  if (process.env.MIXDOG_DEBUG_AGENT) {
897
899
  process.stderr.write(`[agent-trace] provider-send-end elapsed=${Date.now() - _t1}ms result=ok transport=http-fallback\n`);
898
900
  }
@@ -933,6 +935,13 @@ export class OpenAIOAuthProvider {
933
935
  ? { ...body, generate: false }
934
936
  : null,
935
937
  });
938
+ const mustSurfaceFallbackError = (fallbackErr) => externalSignal?.aborted
939
+ || fallbackErr?.name === 'AbortError'
940
+ || fallbackErr?.code === 'ABORT_ERR'
941
+ || fallbackErr?.liveTextEmitted === true
942
+ || fallbackErr?.emittedToolCall === true
943
+ || fallbackErr?.toolCallEmitted === true
944
+ || fallbackErr?.unsafeToRetry === true;
936
945
  if (transportPolicy.transport === 'http'
937
946
  || (transportPolicy.allowHttpFallback && (
938
947
  opts.forceHttpFallback === true
@@ -959,7 +968,7 @@ export class OpenAIOAuthProvider {
959
968
  // the retry (and the HTTP fallback below already refuses) and
960
969
  // surface the original error.
961
970
  const liveTextEmitted = err?.liveTextEmitted === true || err?.unsafeToRetry === true;
962
- if ((status === 401 || status === 403) && !liveTextEmitted) {
971
+ if (status === 401 && !liveTextEmitted) {
963
972
  process.stderr.write(`[openai-oauth-ws] ${status} — forcing refresh and retrying once over WS\n`);
964
973
  if (process.env.MIXDOG_DEBUG_AGENT) { process.stderr.write(`[agent-trace] provider-${status}-retry attempt=1\n`); }
965
974
  this._refreshFallbackUntil = 0;
@@ -971,7 +980,7 @@ export class OpenAIOAuthProvider {
971
980
  } catch (retryErr) {
972
981
  traceWsError(retryErr, 'auth_retry');
973
982
  if (httpFallbackEnabled
974
- && retryErr?.wsRetriesExhausted === true
983
+ && (retryErr?.httpStatus === 426 || retryErr?.wsRetriesExhausted === true)
975
984
  && _shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
976
985
  try {
977
986
  return await dispatchHttp(
@@ -980,6 +989,10 @@ export class OpenAIOAuthProvider {
980
989
  { sticky: true },
981
990
  );
982
991
  } catch (fallbackErr) {
992
+ // Cancellation and post-output failures are the
993
+ // current terminal outcome; replacing either with
994
+ // stale WS history could permit upstream replay.
995
+ if (mustSurfaceFallbackError(fallbackErr)) throw fallbackErr;
983
996
  try { retryErr.fallbackError = fallbackErr; } catch {}
984
997
  throw retryErr;
985
998
  }
@@ -988,7 +1001,7 @@ export class OpenAIOAuthProvider {
988
1001
  }
989
1002
  }
990
1003
  // Auth failure after live text already emitted: never reissue.
991
- if ((status === 401 || status === 403) && liveTextEmitted) {
1004
+ if (status === 401 && liveTextEmitted) {
992
1005
  throw err;
993
1006
  }
994
1007
  const msg = err?.message || '';
@@ -1004,7 +1017,7 @@ export class OpenAIOAuthProvider {
1004
1017
  return this.send(messages, model, tools, { ...opts, _modelRetry: true });
1005
1018
  }
1006
1019
  if (httpFallbackEnabled
1007
- && err?.wsRetriesExhausted === true
1020
+ && (status === 426 || err?.wsRetriesExhausted === true)
1008
1021
  && _shouldUseOpenAIHttpFallback(err, externalSignal)) {
1009
1022
  try {
1010
1023
  return await dispatchHttp(
@@ -1013,6 +1026,7 @@ export class OpenAIOAuthProvider {
1013
1026
  { sticky: true },
1014
1027
  );
1015
1028
  } catch (fallbackErr) {
1029
+ if (mustSurfaceFallbackError(fallbackErr)) throw fallbackErr;
1016
1030
  try { err.fallbackError = fallbackErr; } catch {}
1017
1031
  throw err;
1018
1032
  }
@@ -15,6 +15,7 @@ import {
15
15
  RESPONSES_TRANSPORT_CAPABILITIES,
16
16
  FULL_RESPONSES_TRANSPORT_CAPS,
17
17
  } from './openai-transport-policy.mjs';
18
+ import { createHash } from 'node:crypto';
18
19
 
19
20
  // If the cached request (sans input) matches the current one and the current
20
21
  // input starts with the cached input, return only the tail. Otherwise return
@@ -157,6 +158,67 @@ function _isReplayLikeHead(item, responseItem) {
157
158
  return inputType === responseType;
158
159
  }
159
160
 
161
+ // The hash input follows the same per-item normalizations used by
162
+ // _logicalResponseItemMatch, but is never emitted itself. This retains a
163
+ // stable comparison fingerprint after history compaction without putting
164
+ // message content, tool arguments, or custom-tool input into traces.
165
+ function _normalizedResponseItemDiagnosticShape(item) {
166
+ if (!item || typeof item !== 'object') return item || null;
167
+ const type = item.type || (item.role === 'assistant' ? 'message' : '');
168
+ if (type === 'function_call' || type === 'tool_search_call') {
169
+ return {
170
+ type,
171
+ call_id: String(item.call_id || ''),
172
+ name: type === 'function_call' ? String(item.name || '') : undefined,
173
+ arguments: _normalizeArguments(item.arguments),
174
+ };
175
+ }
176
+ if (type === 'custom_tool_call') {
177
+ return {
178
+ type,
179
+ call_id: String(item.call_id || ''),
180
+ name: String(item.name || ''),
181
+ input: String(item.input || ''),
182
+ };
183
+ }
184
+ if (type === 'message') {
185
+ return {
186
+ type,
187
+ role: item.role || 'assistant',
188
+ content: Array.isArray(item.content) ? item.content.map(_normalizeContentPart) : [],
189
+ };
190
+ }
191
+ if (type === 'reasoning') {
192
+ return {
193
+ type,
194
+ id: item.id || null,
195
+ encrypted_content: item.encrypted_content || null,
196
+ };
197
+ }
198
+ if (type === 'web_search_call') {
199
+ return { type, id: item.id || null, action: item.action || null };
200
+ }
201
+ const { id: _id, status: _status, ...rest } = item;
202
+ return { type, ...rest };
203
+ }
204
+
205
+ function _normalizedResponseItemDiagnosticHash(item) {
206
+ return createHash('sha256')
207
+ .update(_stableStringify(_normalizedResponseItemDiagnosticShape(item)))
208
+ .digest('hex');
209
+ }
210
+
211
+ function _responseOutputMismatchDiagnostics(inputItem, responseItem, replayItemCount, responseItemCount) {
212
+ return {
213
+ response_output_mismatch_expected_type: responseItem?.type || 'unknown',
214
+ response_output_mismatch_expected_hash: _normalizedResponseItemDiagnosticHash(responseItem),
215
+ response_output_mismatch_expected_response_item_count: responseItemCount,
216
+ response_output_mismatch_actual_type: inputItem?.type || (inputItem?.role === 'assistant' ? 'message' : 'unknown'),
217
+ response_output_mismatch_actual_hash: _normalizedResponseItemDiagnosticHash(inputItem),
218
+ response_output_mismatch_actual_replayed_input_item_count: replayItemCount,
219
+ };
220
+ }
221
+
160
222
  export function _stripResponseItemsFromHead(items, responseItems) {
161
223
  const tail = Array.isArray(items) ? items : [];
162
224
  const outputs = Array.isArray(responseItems) ? responseItems : [];
@@ -177,6 +239,12 @@ export function _stripResponseItemsFromHead(items, responseItems) {
177
239
  tail,
178
240
  stripped,
179
241
  skipped,
242
+ responseOutputMismatch: _responseOutputMismatchDiagnostics(
243
+ tail[cursor],
244
+ output,
245
+ tail.length,
246
+ outputs.length,
247
+ ),
180
248
  };
181
249
  }
182
250
  skipped += 1;
@@ -291,7 +359,12 @@ export function _computeDelta({ entry, body, traceProvider }) {
291
359
  }
292
360
  const stripped = _stripResponseItemsFromHead(afterPreviousInput, entry.lastResponseItems);
293
361
  if (!stripped.ok) {
294
- return { mode: 'full', reason: stripped.reason, frame: buildFrame(body) };
362
+ return {
363
+ mode: 'full',
364
+ reason: stripped.reason,
365
+ responseOutputMismatch: stripped.responseOutputMismatch,
366
+ frame: buildFrame(body),
367
+ };
295
368
  }
296
369
  return {
297
370
  mode: 'delta',