mixdog 0.9.33 → 0.9.34

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 (44) hide show
  1. package/package.json +4 -2
  2. package/scripts/compact-trigger-migration-smoke.mjs +37 -31
  3. package/scripts/provider-toolcall-test.mjs +535 -36
  4. package/src/agents/heavy-worker/AGENT.md +3 -4
  5. package/src/agents/worker/AGENT.md +2 -3
  6. package/src/repl.mjs +9 -1
  7. package/src/rules/shared/01-tool.md +4 -2
  8. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
  9. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
  10. package/src/runtime/agent/orchestrator/providers/gemini.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
  12. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
  15. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
  19. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
  20. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
  23. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
  24. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
  25. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
  27. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +16 -8
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
  29. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -2
  31. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
  32. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
  33. package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
  34. package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
  35. package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
  36. package/src/runtime/memory/lib/memory-embed.mjs +89 -8
  37. package/src/session-runtime/context-status.mjs +7 -6
  38. package/src/session-runtime/runtime-core.mjs +0 -10
  39. package/src/tui/app/use-transcript-window.mjs +13 -1
  40. package/src/tui/components/StatusLine.jsx +5 -5
  41. package/src/tui/dist/index.mjs +5 -3
  42. package/src/ui/statusline.mjs +4 -10
  43. package/src/vendor/statusline/bin/statusline-route.mjs +4 -5
  44. package/src/vendor/statusline/src/gateway/route-meta.mjs +3 -2
package/src/repl.mjs CHANGED
@@ -161,6 +161,11 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
161
161
  let streamedText = '';
162
162
  let printedAny = false;
163
163
  let printedToolCard = false;
164
+ // After the blank line emitted above, the cursor sits at a fresh line
165
+ // start. Track this so tool cards don't insert extra blank-line "pops":
166
+ // a leading newline is only needed to break away from un-terminated
167
+ // streamed text, never between consecutive cards.
168
+ let atLineStart = true;
164
169
  try {
165
170
  const runtime = await ensureRuntime();
166
171
  const { result } = await runtime.ask(
@@ -169,13 +174,16 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
169
174
  onToolCall: async (_iter, calls) => {
170
175
  for (const c of calls || []) {
171
176
  printedToolCard = true;
172
- stdout.write('\n' + (await renderToolCardLazy(c)) + '\n');
177
+ const lead = atLineStart ? '' : '\n';
178
+ stdout.write(lead + (await renderToolCardLazy(c)) + '\n');
179
+ atLineStart = true;
173
180
  }
174
181
  },
175
182
  onTextDelta: (chunk) => {
176
183
  printedAny = true;
177
184
  streamedText += chunk;
178
185
  stdout.write(chunk);
186
+ atLineStart = chunk.endsWith('\n');
179
187
  },
180
188
  onUsageDelta: (delta) => applyUsageDelta(stats, delta),
181
189
  },
@@ -39,5 +39,7 @@
39
39
  `apply_patch` and the `shell` that checks it in separate turns — not for
40
40
  ordering (same-turn calls run in emit order), but because you must SEE the
41
41
  patch result before verifying: a same-turn shell is already emitted and
42
- can't react to a failed or partial patch. Batch the patches this turn,
43
- verify them all in ONE shell call the next. Anything else stays parallel.
42
+ can't react to a failed or partial patch. Put all known edits in ONE patch
43
+ this turn, in the order they should apply; later mutations skip after an
44
+ earlier patch failure. Verify all edits in ONE shell call next turn. Anything
45
+ else stays parallel.
@@ -69,13 +69,35 @@ function _arch() {
69
69
  return a;
70
70
  }
71
71
 
72
+ /**
73
+ * Originator token codex sends on every request (`codex_cli_rs`).
74
+ *
75
+ * Opt-in parity override: an operator can pin the exact originator a known-good
76
+ * codex build reports via MIXDOG_CODEX_ORIGINATOR when the backend fingerprints
77
+ * on it. Unset (default) keeps `codex_cli_rs`, so wire behavior is unchanged.
78
+ */
79
+ export function codexOriginator() {
80
+ const override = String(process.env.MIXDOG_CODEX_ORIGINATOR || '').trim();
81
+ return override || 'codex_cli_rs';
82
+ }
83
+
72
84
  /** codex_cli_rs/<version> (<os> <ver>; <arch>) <terminal> */
73
85
  export function codexUserAgent() {
86
+ // Opt-in parity override: pin an exact codex User-Agent string
87
+ // (MIXDOG_CODEX_USER_AGENT) when the auto-derived os/arch/terminal tuple
88
+ // drifts from the real codex build the backend expects. Unset = default.
89
+ const override = String(process.env.MIXDOG_CODEX_USER_AGENT || '').trim();
90
+ if (override) return override;
74
91
  const terminal = String(process.env.TERM_PROGRAM || 'unknown').trim() || 'unknown';
75
92
  return `codex_cli_rs/${codexClientVersionSync()} (${_osType()} ${os.release()}; ${_arch()}) ${terminal}`;
76
93
  }
77
94
 
78
95
  /** Bare version header value — codex built-in provider http_headers "version". */
79
96
  export function codexVersionHeader() {
97
+ // Opt-in parity override: pin an exact `version` header
98
+ // (MIXDOG_CODEX_VERSION) instead of the npm-derived value. Unset = default
99
+ // (live npm version, floor fallback), so behavior is unchanged.
100
+ const override = String(process.env.MIXDOG_CODEX_VERSION || '').trim();
101
+ if (override) return override;
80
102
  return codexClientVersionSync();
81
103
  }
@@ -177,8 +177,27 @@ export function _attachGeminiCacheState(opts, entry, currentIter) {
177
177
  }
178
178
 
179
179
  export function _resolveGeminiCacheUsage({ usageMetadata, cachedContent, providerState }) {
180
- const inputTokens = Number(usageMetadata?.promptTokenCount || usageMetadata?.totalTokenCount || 0) || 0;
181
- const reportedCachedTokens = Number(usageMetadata?.cachedContentTokenCount || 0) || 0;
180
+ const firstFinite = (...values) => {
181
+ for (const value of values) {
182
+ const n = Number(value);
183
+ if (Number.isFinite(n) && n > 0) return n;
184
+ }
185
+ return 0;
186
+ };
187
+ const inputTokens = firstFinite(
188
+ usageMetadata?.promptTokenCount,
189
+ usageMetadata?.prompt_token_count,
190
+ usageMetadata?.totalTokenCount,
191
+ usageMetadata?.total_token_count,
192
+ );
193
+ const reportedCachedTokens = firstFinite(
194
+ // generateContent UsageMetadata field.
195
+ usageMetadata?.cachedContentTokenCount,
196
+ usageMetadata?.cached_content_token_count,
197
+ // Newer SDK convenience alias documented for implicit cache hits.
198
+ usageMetadata?.totalCachedTokens,
199
+ usageMetadata?.total_cached_tokens,
200
+ );
182
201
  const cachedFallbackTokens = cachedContent
183
202
  ? Number(providerState?.gemini?.cacheTokenSize || 0) || 0
184
203
  : 0;
@@ -782,7 +782,8 @@ export class GeminiProvider {
782
782
  const um = response.usageMetadata || null;
783
783
  // Hoist cachedTokens so the returned usage block can reuse the
784
784
  // exact value the trace already recorded (including the
785
- // cachedFallback when cachedContentTokenCount under-reports).
785
+ // cachedFallback when cachedContentTokenCount / total_cached_tokens
786
+ // under-reports).
786
787
  let cachedTokens = 0;
787
788
  if (um) {
788
789
  const {
@@ -88,17 +88,24 @@ function proxyHeaders() {
88
88
  };
89
89
  }
90
90
 
91
- function resolveGrokOAuthResponsesTransport(config, proxy) {
92
- // Proxy-only models route through cli-chat-proxy.grok.com; the shared xAI
93
- // WebSocket connector targets api.x.ai, so keep proxy models on HTTP.
94
- if (proxy) return 'http';
91
+ function resolveGrokOAuthResponsesTransport(config) {
92
+ // An explicit Grok-specific transport setting is the escape hatch and wins
93
+ // for EVERY Grok model, api.x.ai and proxy-only alike e.g. pin 'http' to
94
+ // force proxy-only models back onto cli-chat-proxy.grok.com over HTTP.
95
95
  const raw = config?.responsesTransport
96
96
  ?? config?.transport
97
97
  ?? process.env.MIXDOG_GROK_OAUTH_RESPONSES_TRANSPORT
98
98
  ?? process.env.MIXDOG_GROK_OAUTH_TRANSPORT
99
99
  ?? '';
100
100
  const mode = String(raw).trim().toLowerCase();
101
- return mode || 'http';
101
+ if (mode) return mode;
102
+ // No Grok-specific override: leave the field unset so ALL api.x.ai-capable
103
+ // Grok models honor the shared MIXDOG_OAI_TRANSPORT switch. Proxy-only
104
+ // grok-build is included: the shared xAI WebSocket connector targets the
105
+ // fixed wss://api.x.ai/v1/responses endpoint (not the proxy baseURL), and a
106
+ // live probe confirmed grok-build serves there with the OAuth token. No
107
+ // implicit HTTP pin — the proxy baseURL is only used when HTTP is selected.
108
+ return undefined;
102
109
  }
103
110
 
104
111
  // Retired model aliases xAI no longer exposes by their old ids. The live
@@ -639,10 +646,9 @@ export class GrokOAuthProvider {
639
646
  ...this.config,
640
647
  apiKey: token,
641
648
  baseURL: proxy ? PROXY_BASE_URL : INFERENCE_BASE_URL,
642
- // Default to the proven HTTP Responses transport for Grok OAuth.
643
- // Non-proxy api.x.ai models can opt into the shared xAI WebSocket
644
- // connector via config/env; proxy-only models stay HTTP because
645
- // the WS connector targets api.x.ai, not the Grok CLI proxy.
649
+ // All Grok models proxy-only grok-build included inherit the
650
+ // shared xAI transport switch (WS→api.x.ai by default). An explicit
651
+ // Grok-specific http setting is the escape hatch back to the proxy.
646
652
  responsesTransport: resolveGrokOAuthResponsesTransport(this.config, proxy),
647
653
  // Proxy-only models additionally need the Grok CLI client headers to
648
654
  // clear the proxy version gate (HTTP 426 otherwise).
@@ -18,6 +18,10 @@ import {
18
18
  resolveTimeoutMs,
19
19
  } from '../stall-policy.mjs';
20
20
  import { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
21
+ import {
22
+ resolveResponsesTransportPolicy,
23
+ RESPONSES_TRANSPORT_CAPABILITIES,
24
+ } from './openai-transport-policy.mjs';
21
25
  import {
22
26
  summarizeTraceMessages,
23
27
  extractCompatCachedTokens,
@@ -191,11 +195,27 @@ export class OpenAICompatProvider {
191
195
  // TLS handshake after an idle gap. Fire-and-forget; never awaited.
192
196
  preconnect(this.baseURL);
193
197
  if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
194
- if (useXaiResponsesWebSocket(opts, this.config)) {
198
+ // Shared Responses transport switch (MIXDOG_OAI_TRANSPORT), capability-
199
+ // gated for xAI/Grok. Provider-local HTTP pins still win: Grok
200
+ // proxy-only models set responsesTransport:'http' because the WS
201
+ // connector targets api.x.ai, not cli-chat-proxy.grok.com.
202
+ const xaiTransportPolicy = resolveResponsesTransportPolicy(
203
+ process.env,
204
+ RESPONSES_TRANSPORT_CAPABILITIES.xai,
205
+ );
206
+ const configuredPreferWebSocket = useXaiResponsesWebSocket(opts, this.config);
207
+ const preferWebSocket = configuredPreferWebSocket === false
208
+ ? false
209
+ : xaiTransportPolicy.mode === 'http-sse'
210
+ ? false
211
+ : xaiTransportPolicy.transport === 'ws'
212
+ ? true
213
+ : configuredPreferWebSocket;
214
+ if (preferWebSocket) {
195
215
  try {
196
216
  return await this._doSendXaiResponsesWebSocket(messages, useModel, tools, opts);
197
217
  } catch (err) {
198
- if (_shouldFallbackXaiWsToHttp(err, opts.signal)) {
218
+ if (xaiTransportPolicy.allowHttpFallback && _shouldFallbackXaiWsToHttp(err, opts.signal)) {
199
219
  const reason = err?.midstreamClassifier || err?.retryClassifier || err?.code || err?.message || 'ws_failed';
200
220
  process.stderr.write(`[xai:responses] WebSocket unhealthy (${reason}); falling back to HTTP/SSE\n`);
201
221
  try {
@@ -659,6 +679,16 @@ export class OpenAICompatProvider {
659
679
  : null;
660
680
  const iteration = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : null;
661
681
  let cacheLane = null;
682
+ // Fast-fallback only shortens the WS handshake retry budget when the
683
+ // HTTP/SSE fallback is actually enabled for this call. With no-fallback
684
+ // (allowHttpFallback=false) the WS path must keep its FULL retry budget,
685
+ // mirroring openai-oauth's httpFallbackEnabled gate.
686
+ const xaiTransportPolicy = resolveResponsesTransportPolicy(
687
+ process.env,
688
+ RESPONSES_TRANSPORT_CAPABILITIES.xai,
689
+ );
690
+ const httpFallbackEnabled = xaiTransportPolicy.allowHttpFallback
691
+ && _envFlag('MIXDOG_XAI_WS_HTTP_FALLBACK', true);
662
692
  const scheduled = await withXaiResponsesCacheLane({
663
693
  opts,
664
694
  config: this.config,
@@ -692,8 +722,9 @@ export class OpenAICompatProvider {
692
722
  // enabled (outer catch → _shouldFallbackXaiWsToHttp), a first
693
723
  // acquire/first-byte failure should skip remaining WS
694
724
  // handshake retries instead of burning the retry budget
695
- // before HTTP starts.
696
- fastFallback: _envFlag('MIXDOG_XAI_WS_HTTP_FALLBACK', true),
725
+ // before HTTP starts. Gated on httpFallbackEnabled so a
726
+ // no-fallback config keeps the full WS retry budget.
727
+ fastFallback: httpFallbackEnabled,
697
728
  });
698
729
  });
699
730
  const result = scheduled.value;
@@ -63,6 +63,8 @@ import {
63
63
  parseToolSearchArgs,
64
64
  _streamResponse,
65
65
  } from './openai-ws-stream.mjs';
66
+ import { _buildResponseCreateFrame } from './openai-ws-delta.mjs';
67
+ import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
66
68
 
67
69
  // Legacy import paths for scripts/tool-smoke.mjs, mixdog-session-runtime.mjs
68
70
  // (drainOpenaiWsPool), scripts/provider-toolcall-test.mjs (parseToolSearchArgs,
@@ -74,6 +76,7 @@ export {
74
76
  parseToolSearchArgs,
75
77
  _streamResponse,
76
78
  _cacheObservation as _cacheObservationForTest,
79
+ _warmupContinuityTrace as _warmupContinuityTraceForTest,
77
80
  };
78
81
 
79
82
  globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
@@ -234,33 +237,53 @@ function _codexInstallationId(sendOpts) {
234
237
  || `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
235
238
  }
236
239
 
237
- function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts } = {}) {
240
+ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts, handshake = false } = {}) {
238
241
  const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
239
242
  || 'mixdog-session';
240
243
  const threadId = _cleanMetaString(sendOpts?.threadId || sendOpts?.codexThreadId || sendOpts?.session?.threadId || cacheKey || sessionId)
241
244
  || sessionId;
242
- const turnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId || sessionId)
243
- || sessionId;
244
- const windowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId)
245
- || `${threadId}:1`;
246
245
  const installationId = _codexInstallationId(sendOpts);
247
246
  const startedAt = Number.isFinite(Number(sendOpts?.turnStartedAtUnixMs))
248
247
  ? Math.floor(Number(sendOpts.turnStartedAtUnixMs))
249
248
  : _sessionStartedAtUnixMs(sessionId);
250
249
  const requestKind = _codexRequestKind(sendOpts, sessionId);
250
+ const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
251
+ // codex opens the WS with a prewarm (empty turn_id) BEFORE issuing the
252
+ // real turn (client.rs). Our handshake headers are built once, up-front,
253
+ // and were carrying a turn_id (= sessionId) — i.e. presenting the
254
+ // handshake as a live turn. Under wire parity, treat the handshake as the
255
+ // prewarm so its turn_id is empty, matching codex. Default (parity off) is
256
+ // unchanged: the wireParity gate below leaves turnId as before.
257
+ const isPrewarm = requestKind === 'prewarm' || handshake === true;
258
+ const explicitTurnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId);
259
+ const explicitWindowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId);
260
+ const turnId = wireParity && isPrewarm
261
+ ? ''
262
+ : (explicitTurnId || sessionId);
263
+ // Under wire parity the handshake IS the prewarm (empty turn_id above), so
264
+ // its request_kind must be 'prewarm' too — codex tags the prewarm request
265
+ // as prewarm, not turn (client.rs). Previously we emptied turn_id but left
266
+ // request_kind='turn', presenting the prewarm as a live turn. Default
267
+ // (parity off) is unchanged: requestKind passes through untouched.
268
+ const effectiveRequestKind = wireParity && isPrewarm ? 'prewarm' : requestKind;
269
+ const windowId = explicitWindowId
270
+ || `${threadId}:${wireParity ? 0 : 1}`;
251
271
  const turnMetadata = {
252
272
  installation_id: installationId,
253
273
  session_id: sessionId,
254
274
  thread_id: threadId,
255
275
  turn_id: turnId,
256
276
  window_id: windowId,
257
- request_kind: requestKind,
277
+ request_kind: effectiveRequestKind,
258
278
  // Richer codex turn-metadata fields (responses_metadata.rs:264-280:
259
279
  // thread_source "user" per protocol.rs:2751-2765, sandbox label).
260
280
  // A/B 2026-07-04 (rvA/rvB interleaved, 24 sessions/arm): no effect
261
281
  // (it2 full 3 vs 2, miss 2 vs 3 — noise). Default OFF; keep the knob
262
282
  // for future probes if the backend starts gating on payload richness.
263
- ...(process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' ? {
283
+ // Codex WS metadata parity (opt-in): the richer turn-metadata fields
284
+ // codex emits (responses_metadata.rs:264-280) also go on the wire under
285
+ // MIXDOG_OAI_CODEX_WIRE_PARITY. Default (both flags off) is unchanged.
286
+ ...((process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' || wireParity) ? {
264
287
  thread_source: 'user',
265
288
  sandbox: 'read-only',
266
289
  } : {}),
@@ -332,10 +355,22 @@ function _codexWsCompatibilityHeaders(context = {}) {
332
355
  delete headers['x-codex-turn-metadata'];
333
356
  }
334
357
  // probe === '1' | 'true' | 'yes' | 'on' | 'turn-metadata' => full set as built above.
358
+ // Turn-state gate experiment bundle (MIXDOG_OAI_CODEX_TURN_STATE_GATE): the
359
+ // debugger's hypothesis is that x-codex-turn-state issuance also wants the
360
+ // parent-thread header present, independent of MIXDOG_OAI_TURN_METADATA.
361
+ // Attach it (= thread_id) when the gate is explicitly enabled and the probe
362
+ // above didn't already set it. Default OFF; composes with the probe.
363
+ const gate = String(process.env.MIXDOG_OAI_CODEX_TURN_STATE_GATE || '').trim().toLowerCase();
364
+ if (['1', 'true', 'yes', 'on'].includes(gate) && !headers['x-codex-parent-thread-id']) {
365
+ const parentThreadId = _cleanMetaString(context?.sendOpts?.parentThreadId
366
+ || context?.sendOpts?.codexParentThreadId
367
+ || metadata.thread_id);
368
+ if (parentThreadId) headers['x-codex-parent-thread-id'] = parentThreadId;
369
+ }
335
370
  return headers;
336
371
  }
337
372
 
338
- function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
373
+ export function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
339
374
  if (!enabled || !frame || typeof frame !== 'object') return frame;
340
375
  const base = _codexMetadataBase(entry, context);
341
376
  const metadata = {
@@ -347,10 +382,24 @@ function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
347
382
  // codex scopes x-codex-turn-state to ONE turn (client.rs:263-279 —
348
383
  // "must not send it between different turns"). Pooled sockets span
349
384
  // turns, so key the stored token by the turn that captured it and
350
- // drop it when the turn changes.
351
- if (entry.turnState && entry.turnStateTurnId && entry.turnStateTurnId !== base.turn_id) {
352
- entry.turnState = null;
353
- entry.turnStateTurnId = null;
385
+ // drop it when the turn changes. turnState is captured at handshake
386
+ // (pool) / mid-stream (ws-stream) without knowing the owning turn, so
387
+ // attribute it to the FIRST turn that observes it here; once turn_id
388
+ // moves off that owner, drop the stale token. (Previously
389
+ // turnStateTurnId was checked but never assigned, so this guard was
390
+ // dead and stale turn-state could leak across turns under parity.)
391
+ if (entry.turnState) {
392
+ // An empty turn_id (parity prewarm) is a VALID owner attribution, not
393
+ // "unassigned". Test against null/undefined so a prewarm-owned token
394
+ // (turn_id '') is retired once turn_id advances to the real turn,
395
+ // instead of being reattributed onto it. Default (parity off) never
396
+ // produces an empty turn_id, so this is unchanged there.
397
+ if (entry.turnStateTurnId == null) {
398
+ entry.turnStateTurnId = base.turn_id;
399
+ } else if (entry.turnStateTurnId !== base.turn_id) {
400
+ entry.turnState = null;
401
+ entry.turnStateTurnId = null;
402
+ }
354
403
  }
355
404
  entry.currentTurnId = base.turn_id;
356
405
  }
@@ -402,6 +451,33 @@ function _cacheObservation({ entry, result }) {
402
451
  };
403
452
  }
404
453
 
454
+ // Warmup→first-real continuity trace (Codex prewarm_websocket parity
455
+ // observability). Pure/deterministic so it unit-tests without a live socket.
456
+ // The R23 finding forbids the post-warmup request rewrite, so parity is
457
+ // asserted via metrics instead of behavior: does the warmup's response_id
458
+ // become the anchor the FIRST real request chains from, and what is the
459
+ // hit/miss outcome of the first up-to-3 real requests on the socket.
460
+ export function _warmupContinuityTrace({
461
+ warmupUsed,
462
+ warmupResponseId,
463
+ priorEntryResponseId,
464
+ sentPrevResponseId,
465
+ earlyCacheMisses,
466
+ } = {}) {
467
+ const misses = Array.isArray(earlyCacheMisses) ? earlyCacheMisses.slice(0, 3) : [];
468
+ // The first real request is a full frame (no prev_id, per R23), so its
469
+ // anchor is what the entry held at build time — which the warmup wrote.
470
+ const firstRealPrevId = sentPrevResponseId || priorEntryResponseId || null;
471
+ return {
472
+ warmup_first_real_prev_id: firstRealPrevId,
473
+ warmup_chain_continuous: !!warmupUsed
474
+ && !!warmupResponseId
475
+ && firstRealPrevId === warmupResponseId,
476
+ early_cache_misses: misses,
477
+ early_cache_miss_count: misses.filter(Boolean).length,
478
+ };
479
+ }
480
+
405
481
  /**
406
482
  * Run `_acquire({auth, poolKey, cacheKey})` with bounded exponential-backoff
407
483
  * retry on transient handshake failures. The injection seams (`_acquire`,
@@ -600,7 +676,7 @@ export async function sendViaWebSocket({
600
676
  const useCodexWsClientMetadata = traceProvider === 'openai-oauth';
601
677
  const codexMetadataContext = { poolKey, cacheKey, sendOpts };
602
678
  const codexHandshakeHeaders = useCodexWsClientMetadata
603
- ? _codexWsCompatibilityHeaders(codexMetadataContext)
679
+ ? _codexWsCompatibilityHeaders({ ...codexMetadataContext, handshake: true })
604
680
  : null;
605
681
 
606
682
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
@@ -725,8 +801,27 @@ export async function sendViaWebSocket({
725
801
  // has no prior request state. A reused pooled socket with a live
726
802
  // chain must go straight to the real request.
727
803
  if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0 && !entry.lastResponseId) {
728
- const warmupFrame = { type: 'response.create', ...warmupBody };
729
- const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, codexMetadataContext);
804
+ // Codex WS prewarm parity (opt-in): codex's prewarm frame is a
805
+ // minimal generate:false request that omits transport-only
806
+ // fields (stream/background) on the wire (prewarm_websocket,
807
+ // client.rs:1673-1705). Under MIXDOG_OAI_CODEX_WIRE_PARITY force
808
+ // that omission for the warmup frame too; default is unchanged.
809
+ const warmupWireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
810
+ const parityWarmupBody = warmupWireParity
811
+ ? { ...warmupBody, input: [], generate: false }
812
+ : warmupBody;
813
+ const warmupFrame = _buildResponseCreateFrame(parityWarmupBody, { omitTransportFields: warmupWireParity });
814
+ const warmupMetadataContext = warmupWireParity
815
+ ? {
816
+ ...codexMetadataContext,
817
+ sendOpts: {
818
+ ...(codexMetadataContext?.sendOpts || {}),
819
+ requestKind: 'prewarm',
820
+ codexRequestKind: 'prewarm',
821
+ },
822
+ }
823
+ : codexMetadataContext;
824
+ const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, warmupMetadataContext);
730
825
  wireFrameHadTurnState = !!wireWarmupFrame?.client_metadata?.['x-codex-turn-state'];
731
826
  wireFrameMetadataTrace = _metadataTrace(wireWarmupFrame?.client_metadata);
732
827
  await _sendFrameFn(entry, wireWarmupFrame);
@@ -761,8 +856,8 @@ export async function sendViaWebSocket({
761
856
  throw new Error('Responses WS warmup completed without response id');
762
857
  }
763
858
  entry.lastResponseId = warmupResult.responseId;
764
- entry.lastRequestSansInput = _stableStringify(_sansInput(warmupBody));
765
- const warmupInputArr = Array.isArray(warmupBody.input) ? warmupBody.input : [];
859
+ entry.lastRequestSansInput = _stableStringify(_sansInput(parityWarmupBody));
860
+ const warmupInputArr = Array.isArray(parityWarmupBody.input) ? parityWarmupBody.input : [];
766
861
  entry.lastRequestInput = _cloneJson(warmupInputArr);
767
862
  entry.lastResponseItems = _cloneJson(Array.isArray(warmupResult.responseItems) ? warmupResult.responseItems : []);
768
863
  entry.lastInputLen = warmupInputArr.length;
@@ -800,7 +895,7 @@ export async function sendViaWebSocket({
800
895
  // Delta opt-in still chains via entry.lastResponseId above.
801
896
  }
802
897
 
803
- const delta = _computeDelta({ entry, body: requestBody });
898
+ const delta = _computeDelta({ entry, body: requestBody, traceProvider });
804
899
  ({ mode, frame } = delta);
805
900
  deltaReason = delta.reason || null;
806
901
  strippedResponseItems = delta.strippedResponseItems || 0;
@@ -1093,6 +1188,22 @@ export async function sendViaWebSocket({
1093
1188
  _num(entry.promptCacheMaxCachedTokens, 0),
1094
1189
  cacheObservation.cachedTokens,
1095
1190
  );
1191
+ // Early-session cache-miss ledger (first up-to-3 real requests on this
1192
+ // socket) for the warmup→first-real continuity trace below. Warmup
1193
+ // itself is excluded — this block only runs on the real send.
1194
+ if (!Array.isArray(entry.earlyCacheMisses)) entry.earlyCacheMisses = [];
1195
+ if (entry.earlyCacheMisses.length < 3) {
1196
+ entry.earlyCacheMisses.push(
1197
+ cacheObservation.actualMiss ? (cacheObservation.missReason || 'miss') : false,
1198
+ );
1199
+ }
1200
+ const warmupContinuity = _warmupContinuityTrace({
1201
+ warmupUsed: !!warmupResult,
1202
+ warmupResponseId: warmupResult?.responseId || null,
1203
+ priorEntryResponseId,
1204
+ sentPrevResponseId,
1205
+ earlyCacheMisses: entry.earlyCacheMisses,
1206
+ });
1096
1207
  // Extra WS-specific observability: transport + per-iteration delta bytes.
1097
1208
  try {
1098
1209
  const transportPayload = {
@@ -1140,6 +1251,7 @@ export async function sendViaWebSocket({
1140
1251
  frame_has_instructions: typeof frame.instructions === 'string' && frame.instructions.length > 0,
1141
1252
  warmup_used: !!warmupResult,
1142
1253
  warmup_response_id: warmupResult?.responseId || null,
1254
+ ...warmupContinuity,
1143
1255
  tool_call_count: resultToolCallCount,
1144
1256
  keep_socket: keepSocket,
1145
1257
  keep_response_chain: keepResponseChain,
@@ -17,6 +17,7 @@ import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
17
17
  import { makeModelCache } from './model-cache.mjs';
18
18
 
19
19
  import { sendViaWebSocket } from './openai-oauth-ws.mjs';
20
+ import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
20
21
  import {
21
22
  buildStableProviderPromptCacheKey,
22
23
  resolveProviderPromptCacheLane,
@@ -398,9 +399,13 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
398
399
  const out = [];
399
400
  const pendingToolMedia = [];
400
401
  const customToolCallNameById = new Map();
402
+ const wireParity = process.env.MIXDOG_OAI_CODEX_WIRE_PARITY === '1';
403
+ const wireMessage = (role, content) => (wireParity
404
+ ? { type: 'message', role, content, internal_chat_message_metadata_passthrough: {} }
405
+ : { role, content });
401
406
  const flushToolMedia = () => {
402
407
  if (!pendingToolMedia.length) return;
403
- out.push({ role: 'user', content: pendingToolMedia.splice(0) });
408
+ out.push(wireMessage('user', pendingToolMedia.splice(0)));
404
409
  };
405
410
  for (const m of messages) {
406
411
  if (!m || m.role === 'system') continue;
@@ -442,7 +447,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
442
447
  // for the WS_IDLE_MS window even after a socket close).
443
448
  // Server-side state already preserves the prefix; sending
444
449
  // reasoning in `input` triggers "Duplicate item".
445
- if (m.content) out.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
450
+ if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
446
451
  for (const tc of m.toolCalls) {
447
452
  if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
448
453
  out.push({
@@ -470,10 +475,10 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
470
475
  }
471
476
  continue;
472
477
  }
473
- out.push({
474
- role: m.role === 'assistant' ? 'assistant' : 'user',
475
- content: normalizeContentForOpenAIResponses(m.content, { role: m.role }),
476
- });
478
+ out.push(wireMessage(
479
+ m.role === 'assistant' ? 'assistant' : 'user',
480
+ normalizeContentForOpenAIResponses(m.content, { role: m.role }),
481
+ ));
477
482
  }
478
483
  flushToolMedia();
479
484
  return out;
@@ -812,7 +817,9 @@ export class OpenAIOAuthProvider {
812
817
  // Fast-fallback is only meaningful when HTTP/SSE fallback is actually
813
818
  // configured for this provider; WS-only paths keep the full handshake
814
819
  // retry budget. This mirrors _shouldUseOpenAIHttpFallback's `enabled`.
815
- const httpFallbackEnabled = _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true);
820
+ const transportPolicy = resolveOpenAiTransportPolicy();
821
+ const httpFallbackEnabled = transportPolicy.allowHttpFallback
822
+ && _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
816
823
  const _t1 = Date.now();
817
824
  const recordLiveModel = (result) => {
818
825
  if (result?.model && !_codexCatalogHas(result.model)) {
@@ -936,9 +943,12 @@ export class OpenAIOAuthProvider {
936
943
  ? { ...body, generate: false }
937
944
  : null,
938
945
  });
939
- if (opts.forceHttpFallback === true
940
- || httpFallbackActive()
941
- || _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)) {
946
+ if (transportPolicy.transport === 'http'
947
+ || (transportPolicy.allowHttpFallback && (
948
+ opts.forceHttpFallback === true
949
+ || httpFallbackActive()
950
+ || _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)
951
+ ))) {
942
952
  return dispatchHttp('forced');
943
953
  }
944
954
 
@@ -970,7 +980,7 @@ export class OpenAIOAuthProvider {
970
980
  return recordLiveModel(result);
971
981
  } catch (retryErr) {
972
982
  traceWsError(retryErr, 'auth_retry');
973
- if (_shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
983
+ if (httpFallbackEnabled && _shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
974
984
  try {
975
985
  return await dispatchHttp(
976
986
  retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed',
@@ -997,7 +1007,7 @@ export class OpenAIOAuthProvider {
997
1007
  await this._refreshModelCache();
998
1008
  return this.send(messages, model, tools, { ...opts, _modelRetry: true });
999
1009
  }
1000
- if (_shouldUseOpenAIHttpFallback(err, externalSignal)) {
1010
+ if (httpFallbackEnabled && _shouldUseOpenAIHttpFallback(err, externalSignal)) {
1001
1011
  // Fast-path trace: the WS handshake acquire/first-byte failed on
1002
1012
  // its FIRST attempt while fast-fallback was armed, so the
1003
1013
  // remaining backoff retries were skipped and HTTP starts now.