mixdog 0.9.47 → 0.9.50

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 (88) hide show
  1. package/README.md +6 -6
  2. package/package.json +19 -9
  3. package/scripts/agent-parallel-smoke.mjs +4 -1
  4. package/scripts/agent-route-batch-test.mjs +40 -0
  5. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  6. package/scripts/code-graph-description-contract.mjs +185 -0
  7. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  8. package/scripts/code-graph-dispatch-test.mjs +96 -0
  9. package/scripts/compact-pressure-test.mjs +40 -0
  10. package/scripts/deferred-tool-loading-test.mjs +233 -0
  11. package/scripts/embedding-worker-exit-test.mjs +76 -0
  12. package/scripts/execution-completion-dedup-test.mjs +48 -0
  13. package/scripts/explore-prompt-policy-test.mjs +88 -3
  14. package/scripts/live-worker-smoke.mjs +68 -16
  15. package/scripts/memory-core-input-test.mjs +33 -13
  16. package/scripts/native-edit-wire-test.mjs +152 -0
  17. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  18. package/scripts/patch-binary-cache-test.mjs +181 -0
  19. package/scripts/prompt-immediate-render-test.mjs +89 -0
  20. package/scripts/provider-toolcall-test.mjs +280 -15
  21. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  22. package/scripts/smoke-loop.mjs +9 -3
  23. package/scripts/smoke.mjs +5 -106
  24. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  25. package/scripts/streaming-tail-window-test.mjs +29 -0
  26. package/scripts/tool-failures.mjs +21 -3
  27. package/scripts/tool-smoke.mjs +263 -38
  28. package/scripts/tool-tui-presentation-test.mjs +17 -1
  29. package/scripts/tui-perf-run.ps1 +26 -0
  30. package/scripts/tui-transcript-perf-test.mjs +7 -1
  31. package/scripts/verify-release-assets-test.mjs +281 -0
  32. package/scripts/verify-release-assets.mjs +293 -0
  33. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  34. package/src/cli.mjs +1 -0
  35. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  38. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  41. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  43. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  44. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  45. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  46. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  47. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  48. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  50. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  51. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  52. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  53. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  54. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  55. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  57. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  58. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  59. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  61. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  62. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  63. package/src/runtime/memory/tool-defs.mjs +1 -2
  64. package/src/session-runtime/context-status.mjs +25 -16
  65. package/src/session-runtime/model-route-api.mjs +2 -0
  66. package/src/session-runtime/runtime-core.mjs +2 -2
  67. package/src/session-runtime/session-turn-api.mjs +4 -1
  68. package/src/session-runtime/tool-catalog.mjs +113 -19
  69. package/src/session-runtime/tool-defs.mjs +1 -0
  70. package/src/standalone/agent-tool/helpers.mjs +25 -6
  71. package/src/standalone/agent-tool.mjs +75 -41
  72. package/src/tui/App.jsx +4 -0
  73. package/src/tui/app/input-parsers.mjs +8 -9
  74. package/src/tui/components/Markdown.jsx +6 -1
  75. package/src/tui/components/Message.jsx +11 -2
  76. package/src/tui/components/PromptInput.jsx +19 -21
  77. package/src/tui/components/Spinner.jsx +4 -4
  78. package/src/tui/components/StatusLine.jsx +6 -6
  79. package/src/tui/components/TranscriptItem.jsx +2 -2
  80. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  81. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  82. package/src/tui/dist/index.mjs +130 -45
  83. package/src/tui/engine/agent-job-feed.mjs +21 -2
  84. package/src/tui/engine/turn.mjs +12 -1
  85. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  86. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  87. package/src/ui/statusline.mjs +5 -5
  88. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -246,7 +246,7 @@ function _sanitizeInputSchema(schema, toolName) {
246
246
  // None of the branches' required lists are hoisted — callers that relied
247
247
  // on discriminated-union semantics will still function; the model simply
248
248
  // receives a union of the property surface with no hard-required constraint.
249
- const mergedProps = {};
249
+ const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
250
250
  const branchDescs = [];
251
251
  for (const branch of Array.isArray(compound) ? compound : []) {
252
252
  if (branch && typeof branch === 'object' && branch.properties) {
@@ -317,7 +317,26 @@ function nativeAnthropicTools(opts) {
317
317
  function toAnthropicToolChoice(toolChoice) {
318
318
  return toolChoice === 'none' ? { type: 'none' } : undefined;
319
319
  }
320
- function deferredAnthropicTools(activeTools, opts) {
320
+ function discoveredAnthropicToolNames(messages, opts, provider) {
321
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
322
+ const discovered = new Set(
323
+ Array.isArray(opts?.session?.deferredDiscoveredTools)
324
+ ? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
325
+ : [],
326
+ );
327
+ for (const message of Array.isArray(messages) ? messages : []) {
328
+ const native = message?.nativeToolSearch;
329
+ const source = String(native?.provider || '').toLowerCase();
330
+ if (source && source !== provider
331
+ && !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
332
+ for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
333
+ const key = String(name || '').trim();
334
+ if (key) discovered.add(key);
335
+ }
336
+ }
337
+ return discovered;
338
+ }
339
+ function deferredAnthropicTools(activeTools, messages, opts) {
321
340
  if (opts?.session?.deferredNativeTools !== true) return [];
322
341
  // A request whose ONLY tools are deferred is rejected by the API with
323
342
  // `400: At least one tool must have defer_loading=false` — happens on the
@@ -325,9 +344,10 @@ function deferredAnthropicTools(activeTools, opts) {
325
344
  // No active tools ⇒ send no deferred catalog either.
326
345
  if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
327
346
  const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
347
+ const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic-oauth');
328
348
  const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
329
349
  return catalog
330
- .filter((tool) => tool?.name && !active.has(String(tool.name)))
350
+ .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
331
351
  .map((tool) => ({ ...tool, deferLoading: true }));
332
352
  }
333
353
 
@@ -374,15 +394,19 @@ function toAnthropicMessages(messages) {
374
394
 
375
395
  if (m.role === 'tool') {
376
396
  const last = result[result.length - 1];
377
- // Do not replay native deferred-loader `tool_reference` blocks from
378
- // prior turns. They are tied to the exact Anthropic deferred-tool
379
- // request that produced them; after /model or provider switches the
380
- // new request may not carry the matching deferred tool surface and
381
- // Anthropic rejects the history as a request validation error.
397
+ const native = m.nativeToolSearch;
398
+ const nativeProvider = String(native?.provider || '').toLowerCase();
399
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
400
+ const references = (!nativeProvider || anthropicNative.has(nativeProvider))
401
+ && Array.isArray(native?.toolReferences)
402
+ ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
403
+ : [];
382
404
  const block = {
383
405
  type: 'tool_result',
384
406
  tool_use_id: m.toolCallId || '',
385
- content: normalizeContentForAnthropic(m.content),
407
+ content: references.length
408
+ ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
409
+ : normalizeContentForAnthropic(m.content),
386
410
  };
387
411
  if (last?.role === 'user' && Array.isArray(last.content)) {
388
412
  last.content.push(block);
@@ -627,7 +651,7 @@ function buildRequestBody(messages, model, tools, sendOpts) {
627
651
  if (systemBlocks.length) body.system = systemBlocks;
628
652
 
629
653
  const nativeTools = nativeAnthropicTools(opts);
630
- const deferredTools = deferredAnthropicTools(tools || [], opts);
654
+ const deferredTools = deferredAnthropicTools(tools || [], chatMsgs, opts);
631
655
  if (tools?.length || nativeTools.length || deferredTools.length) {
632
656
  // No cache_control on tools — the systemBase BP already covers the
633
657
  // tools prefix via Anthropic's prompt cache prefix semantics (order:
@@ -1329,4 +1353,4 @@ export { parseSSEStream, _classifyMidstreamError, ANTHROPIC_MAX_MIDSTREAM_RETRIE
1329
1353
 
1330
1354
  // Test-only escape hatch for scripts/tool-smoke.mjs to verify the
1331
1355
  // catalog-driven max-tokens resolution without duplicating its logic.
1332
- export const _test = { resolveMaxTokens };
1356
+ export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
@@ -231,7 +231,7 @@ function resolveMaxTokens(model) {
231
231
  }
232
232
 
233
233
  // Test-only escape hatch for scripts/anthropic-maxtokens-test.mjs.
234
- export const _test = { resolveMaxTokens };
234
+ export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
235
235
 
236
236
  const MIN_THINKING_BUDGET = 1024;
237
237
  const THINKING_OUTPUT_RESERVE = 1024;
@@ -252,7 +252,7 @@ function _sanitizeInputSchema(schema, toolName) {
252
252
  }
253
253
  const compound = schema.oneOf || schema.anyOf || schema.allOf;
254
254
  if (!compound) return structuredClone(schema);
255
- const mergedProps = {};
255
+ const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
256
256
  const branchDescs = [];
257
257
  for (const branch of Array.isArray(compound) ? compound : []) {
258
258
  if (branch && typeof branch === 'object' && branch.properties) {
@@ -324,16 +324,36 @@ function nativeAnthropicTools(opts) {
324
324
  function toAnthropicToolChoice(toolChoice) {
325
325
  return toolChoice === 'none' ? { type: 'none' } : undefined;
326
326
  }
327
- function deferredAnthropicTools(activeTools, opts) {
327
+ function discoveredAnthropicToolNames(messages, opts, provider) {
328
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
329
+ const discovered = new Set(
330
+ Array.isArray(opts?.session?.deferredDiscoveredTools)
331
+ ? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
332
+ : [],
333
+ );
334
+ for (const message of Array.isArray(messages) ? messages : []) {
335
+ const native = message?.nativeToolSearch;
336
+ const source = String(native?.provider || '').toLowerCase();
337
+ if (source && source !== provider
338
+ && !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
339
+ for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
340
+ const key = String(name || '').trim();
341
+ if (key) discovered.add(key);
342
+ }
343
+ }
344
+ return discovered;
345
+ }
346
+ function deferredAnthropicTools(activeTools, messages, opts) {
328
347
  if (opts?.session?.deferredNativeTools !== true) return [];
329
348
  // Guard against an all-deferred tools array — the API rejects it with
330
349
  // `400: At least one tool must have defer_loading=false` (iteration-cap
331
350
  // final turn sends tools: []). See anthropic-oauth.mjs counterpart.
332
351
  if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
333
352
  const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
353
+ const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic');
334
354
  const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
335
355
  return catalog
336
- .filter((tool) => tool?.name && !active.has(String(tool.name)))
356
+ .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
337
357
  .map((tool) => ({ ...tool, deferLoading: true }));
338
358
  }
339
359
  function toAnthropicMessages(messages) {
@@ -376,15 +396,19 @@ function toAnthropicMessages(messages) {
376
396
  }
377
397
  if (m.role === 'tool') {
378
398
  const last = result[result.length - 1];
379
- // Do not replay native deferred-loader `tool_reference` blocks from
380
- // prior turns. They are tied to the exact Anthropic deferred-tool
381
- // request that produced them; after /model or provider switches the
382
- // new request may not carry the matching deferred tool surface and
383
- // Anthropic rejects the history as a request validation error.
399
+ const native = m.nativeToolSearch;
400
+ const nativeProvider = String(native?.provider || '').toLowerCase();
401
+ const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
402
+ const references = (!nativeProvider || anthropicNative.has(nativeProvider))
403
+ && Array.isArray(native?.toolReferences)
404
+ ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
405
+ : [];
384
406
  const block = {
385
407
  type: 'tool_result',
386
408
  tool_use_id: m.toolCallId || '',
387
- content: normalizeContentForAnthropic(m.content),
409
+ content: references.length
410
+ ? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
411
+ : normalizeContentForAnthropic(m.content),
388
412
  };
389
413
  if (last?.role === 'user' && Array.isArray(last.content)) {
390
414
  last.content.push(block);
@@ -620,7 +644,7 @@ export class AnthropicProvider {
620
644
  if (tools?.length || nativeTools.length) {
621
645
  // No cache_control on tools — the system BP covers tools via
622
646
  // Anthropic prefix semantics (order: tools → system → messages).
623
- params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
647
+ params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], chatMsgs, opts)])];
624
648
  }
625
649
  // tool_choice only when tools are actually present (Anthropic rejects
626
650
  // tool_choice without tools). 'none' rides the hard-cap final turn to
@@ -47,3 +47,31 @@ export function customToolCallFromResponseItem(item) {
47
47
  export function isCustomToolCallRecord(call) {
48
48
  return call?.nativeType === 'custom_tool_call';
49
49
  }
50
+
51
+ export function nativeToolSearchCallInput(call) {
52
+ if (call?.nativeType !== 'tool_search_call') return null;
53
+ return {
54
+ type: 'tool_search_call',
55
+ call_id: call.id || '',
56
+ execution: 'client',
57
+ arguments: call.arguments && typeof call.arguments === 'object' ? call.arguments : {},
58
+ };
59
+ }
60
+
61
+ export function nativeToolSearchOutputInput(message, provider) {
62
+ const native = message?.nativeToolSearch;
63
+ const source = String(native?.provider || '').toLowerCase();
64
+ const target = String(provider || '').toLowerCase();
65
+ const openaiNative = new Set(['openai', 'openai-oauth']);
66
+ const sameNativeFamily = source === target
67
+ || (openaiNative.has(source) && openaiNative.has(target));
68
+ if (!native || (source && !sameNativeFamily)) return null;
69
+ if (!Array.isArray(native.openaiTools)) return null;
70
+ return {
71
+ type: 'tool_search_output',
72
+ call_id: message.toolCallId || '',
73
+ status: 'completed',
74
+ execution: 'client',
75
+ tools: native.openaiTools,
76
+ };
77
+ }
@@ -117,7 +117,7 @@ function functionToolFromSessionTool(t, name = t?.name) {
117
117
  export function toResponsesTools(tools, options = {}) {
118
118
  const provider = String(options?.provider || '').toLowerCase();
119
119
  const allowNativeToolSearch = options?.nativeToolSearch === true
120
- || (options?.nativeToolSearch !== false && provider !== 'xai');
120
+ || (options?.nativeToolSearch !== false && (provider === 'openai' || provider === 'openai-oauth'));
121
121
  return tools.map((t) => {
122
122
  // load_tool advertises as the OpenAI-native `tool_search` wire type
123
123
  // (legacy 'tool_search' name still accepted for back-compat). xAI/Grok
@@ -570,6 +570,7 @@ export async function _acquireWithRetry({
570
570
  if (err && typeof err === 'object') {
571
571
  try { err.attempts = attempt; } catch {}
572
572
  try { err.retryClassifier = classifier; } catch {}
573
+ try { err.wsRetriesExhausted = true; } catch {}
573
574
  }
574
575
  try {
575
576
  if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
@@ -580,15 +581,10 @@ export async function _acquireWithRetry({
580
581
  }
581
582
  // Schedule backoff and emit progress.
582
583
  const backoff = _backoffFor(attempt);
583
- try {
584
- if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
585
- `[openai-oauth-ws] worker retry ${attempt}/${attemptCap} (transient: ${classifier}, backoff ${backoff}ms)\n`,
586
- );
587
- } catch {}
588
584
  try {
589
585
  onRetry?.({
590
586
  attempt,
591
- max: attemptCap,
587
+ max: attemptCap - 1,
592
588
  classifier,
593
589
  backoffMs: backoff,
594
590
  error: err,
@@ -652,12 +648,6 @@ export async function sendViaWebSocket({
652
648
  traceProvider = 'openai-oauth',
653
649
  logSuppressedReasoningDeltas = true,
654
650
  warmupBody = null,
655
- // Fast-fallback: when the caller has HTTP/SSE fallback enabled, cap the
656
- // handshake acquire retry loop to a single attempt so a first
657
- // acquire/first-byte failure surfaces immediately instead of burning the
658
- // full exponential-backoff budget (measured 16.4s) before the caller can
659
- // race HTTP. WS-only callers leave this false → full HANDSHAKE_MAX_ATTEMPTS.
660
- fastFallback = false,
661
651
  // Test seams (undefined in production). Let the unit test drive the
662
652
  // retry state machine without opening real sockets or touching the
663
653
  // handshake-retry layer.
@@ -714,7 +704,6 @@ export async function sendViaWebSocket({
714
704
  // warmed. openai-oauth / openai-direct anchor by per-socket session_id, where
715
705
  // this carry-forward would not help and is therefore gated to xAI.
716
706
  let carryForwardCache = null;
717
- const emittedProgress = [];
718
707
  const useCodexWsClientMetadata = traceProvider === 'openai-oauth';
719
708
  const codexMetadataContext = { poolKey, cacheKey, sendOpts };
720
709
  const codexHandshakeHeaders = useCodexWsClientMetadata
@@ -760,6 +749,22 @@ export async function sendViaWebSocket({
760
749
  });
761
750
  } catch {}
762
751
  };
752
+ // Single caller-visible recovery path for both handshake/acquire retries
753
+ // and retryable stream failures. The session/TUI stage bridge renders this
754
+ // as non-terminal reconnect progress; transport code must not also print it
755
+ // to stderr.
756
+ const emitReconnectProgress = ({ attempt, max, classifier }) => {
757
+ const retryAttempt = Number(attempt) || 1;
758
+ const retryMax = Number(max) || 1;
759
+ try {
760
+ onStageChange?.('reconnecting', {
761
+ attempt: retryAttempt,
762
+ max: retryMax,
763
+ classifier: classifier || null,
764
+ message: `Reconnecting... ${retryAttempt}/${retryMax}`,
765
+ });
766
+ } catch {}
767
+ };
763
768
 
764
769
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
765
770
  const handshakeStart = performance.now();
@@ -778,16 +783,14 @@ export async function sendViaWebSocket({
778
783
  // one is either torn down or in an unknown state.
779
784
  forceFresh: forceFresh || attemptIndex > 0,
780
785
  externalSignal,
781
- // Fast-fallback caps the handshake acquire loop at 1 attempt so
782
- // the caller can race HTTP immediately instead of waiting out
783
- // the full backoff budget. Only the FIRST midstream attempt is
784
- // capped — a midstream retry (attemptIndex>0) is already past
785
- // the fallback decision and keeps the normal budget.
786
- maxAttempts: (fastFallback && attemptIndex === 0) ? 1 : HANDSHAKE_MAX_ATTEMPTS,
786
+ maxAttempts: HANDSHAKE_MAX_ATTEMPTS,
787
787
  onRetry: (info) => {
788
788
  handshakeRetries += 1;
789
789
  sendSpan.handshakeRetries += 1;
790
790
  if (info?.classifier) handshakeRetryClassifiers.push(info.classifier);
791
+ const attempt = Number(info?.attempt) || handshakeRetries;
792
+ const max = Number(info?.max) || Math.max(HANDSHAKE_MAX_ATTEMPTS - 1, 1);
793
+ emitReconnectProgress({ attempt, max, classifier: info?.classifier });
791
794
  },
792
795
  onBackoffSlept: (ms) => { sendSpan.retryBackoffMs += ms; },
793
796
  });
@@ -813,6 +816,9 @@ export async function sendViaWebSocket({
813
816
  // the caller's turn actually tripped on).
814
817
  if (attemptIndex > 0 && firstAttemptError) {
815
818
  try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
819
+ if (err?.wsRetriesExhausted === true) {
820
+ try { firstAttemptError.wsRetriesExhausted = true; } catch {}
821
+ }
816
822
  emitSendSpan('error');
817
823
  throw _stampTool(_stampLiveText(firstAttemptError));
818
824
  }
@@ -1109,16 +1115,7 @@ export async function sendViaWebSocket({
1109
1115
  _stampTool(err);
1110
1116
  const classifier = _classifyMidstreamError(err, midState);
1111
1117
  const retryLimit = classifier ? _midstreamRetryLimit(classifier) : 0;
1112
- // Fast-fallback: a first-byte timeout on the FIRST attempt means the
1113
- // socket opened but the server never ACKed — race HTTP now instead
1114
- // of burning a midstream retry (fresh acquire + first-byte window).
1115
- // Only suppresses the first_byte_timeout bucket on attemptIndex 0;
1116
- // ws_1006/1011 mid-stream drops still retry (a live socket that died
1117
- // is cheaper to re-acquire than to cold-fall-back).
1118
- const fastFallbackSkipRetry = fastFallback
1119
- && attemptIndex === 0
1120
- && (classifier === 'first_byte_timeout' || err?.firstByteTimeout === true);
1121
- if (classifier && attemptIndex < retryLimit && !fastFallbackSkipRetry) {
1118
+ if (classifier && attemptIndex < retryLimit) {
1122
1119
  // Retry-eligible: stash the first-attempt error, emit progress,
1123
1120
  // and loop. The subsequent acquire uses forceFresh so no socket
1124
1121
  // is shared between attempts.
@@ -1127,11 +1124,11 @@ export async function sendViaWebSocket({
1127
1124
  try { err.midstreamClassifier = classifier; } catch {}
1128
1125
  const retryNumber = attemptIndex + 1;
1129
1126
  const backoff = _midstreamBackoffFor(retryNumber);
1130
- try {
1131
- const line = `[openai-oauth-ws] mid-stream recovered: retry ${retryNumber}/${retryLimit} (cause: ${classifier}, backoff ${backoff}ms)\n`;
1132
- if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(line);
1133
- emittedProgress.push(line);
1134
- } catch {}
1127
+ emitReconnectProgress({
1128
+ attempt: retryNumber,
1129
+ max: retryLimit,
1130
+ classifier,
1131
+ });
1135
1132
  const sleepStart = performance.now();
1136
1133
  try {
1137
1134
  await _sleepWithAbort(backoff, externalSignal, _sleepFn);
@@ -1149,6 +1146,9 @@ export async function sendViaWebSocket({
1149
1146
  // the user's turn actually tripped on), tag actual retry count.
1150
1147
  try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
1151
1148
  try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
1149
+ if (attemptIndex >= _midstreamRetryLimit(firstAttemptClassifier)) {
1150
+ try { firstAttemptError.wsRetriesExhausted = true; } catch {}
1151
+ }
1152
1152
  // Attach the retry attempt's error so post-mortem diagnostics
1153
1153
  // can see WHY the retry also failed instead of silently
1154
1154
  // dropping it. Use `cause` if free, else `suppressed`.
@@ -51,6 +51,8 @@ import {
51
51
  customToolInputFromArguments,
52
52
  isCustomToolCallRecord,
53
53
  isResponsesFreeformTool,
54
+ nativeToolSearchCallInput,
55
+ nativeToolSearchOutputInput,
54
56
  toResponsesCustomTool,
55
57
  } from './custom-tool-wire.mjs';
56
58
  import {
@@ -401,6 +403,15 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
401
403
  if (mediaContent) pendingToolMedia.push(...mediaContent);
402
404
  continue;
403
405
  }
406
+ const nativeSearchOutput = nativeToolSearchOutputInput(
407
+ m,
408
+ opts.nativeToolSearchProvider || 'openai-oauth',
409
+ );
410
+ if (nativeSearchOutput) {
411
+ out.push(nativeSearchOutput);
412
+ if (mediaContent) pendingToolMedia.push(...mediaContent);
413
+ continue;
414
+ }
404
415
  out.push({
405
416
  type: 'function_call_output',
406
417
  call_id: m.toolCallId || '',
@@ -419,7 +430,10 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
419
430
  // reasoning in `input` triggers "Duplicate item".
420
431
  if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
421
432
  for (const tc of m.toolCalls) {
422
- if (isCustomToolCallRecord(tc)) {
433
+ const nativeSearchCall = nativeToolSearchCallInput(tc);
434
+ if (nativeSearchCall) {
435
+ out.push(nativeSearchCall);
436
+ } else if (isCustomToolCallRecord(tc)) {
423
437
  if (tc.id) customToolCallNameById.set(tc.id, tc.name || '');
424
438
  out.push({
425
439
  type: 'custom_tool_call',
@@ -450,8 +464,8 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
450
464
  function toOpenAIResponsesTool(t) {
451
465
  if (t?.name === 'load_tool' || t?.name === 'tool_search') {
452
466
  return {
453
- type: 'function',
454
- name: 'load_tool',
467
+ type: 'tool_search',
468
+ execution: 'client',
455
469
  description: t.description,
456
470
  parameters: t.inputSchema,
457
471
  };
@@ -465,6 +479,8 @@ function toOpenAIResponsesTool(t) {
465
479
  };
466
480
  }
467
481
 
482
+ export const _convertMessagesToResponsesInputForTest = convertMessagesToResponsesInput;
483
+
468
484
  // codex build_reasoning() (core/src/client.rs:785-805) only attaches the
469
485
  // reasoning object when model_info.supports_reasoning_summaries; models
470
486
  // without summary support get NO reasoning field at all. Mirror that via the
@@ -501,6 +517,7 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
501
517
  const input = convertMessagesToResponsesInput(messages, {
502
518
  providerState: opts.providerState,
503
519
  model,
520
+ nativeToolSearchProvider: opts.promptCacheProvider || 'openai-oauth',
504
521
  });
505
522
  // Match the request body shape the OAuth backend expects so the
506
523
  // server-side auto-cache routes correctly. text.verbosity / include /
@@ -625,8 +642,11 @@ export class OpenAIOAuthProvider {
625
642
  name = 'openai-oauth';
626
643
  tokens = null;
627
644
  _refreshFallbackUntil = 0;
628
- _forceHttpFallback = false;
629
- _forceHttpFallbackUntil = 0;
645
+ // Sticky transport fallback is isolated by the WS pool/session key. A
646
+ // provider instance is shared across concurrent sessions, so singleton
647
+ // booleans here would let one unhealthy session force every other session
648
+ // onto HTTP.
649
+ _httpFallbackUntilByPoolKey = new Map();
630
650
  config;
631
651
  constructor(config) {
632
652
  this.config = config || {};
@@ -801,17 +821,17 @@ export class OpenAIOAuthProvider {
801
821
  return result;
802
822
  };
803
823
  const httpFallbackActive = () => {
804
- if (this._forceHttpFallbackUntil > Date.now()) return true;
805
- if (this._forceHttpFallback || this._forceHttpFallbackUntil) {
806
- this._forceHttpFallback = false;
807
- this._forceHttpFallbackUntil = 0;
824
+ if (!poolKey) return false;
825
+ const now = Date.now();
826
+ for (const [key, expiresAt] of this._httpFallbackUntilByPoolKey) {
827
+ if (!(expiresAt > now)) this._httpFallbackUntilByPoolKey.delete(key);
808
828
  }
809
- return false;
829
+ return (this._httpFallbackUntilByPoolKey.get(poolKey) || 0) > now;
810
830
  };
811
831
  const markStickyHttpFallback = () => {
832
+ if (!poolKey) return;
812
833
  const ttlMs = _envPositiveInt('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK_STICKY_MS', 60_000);
813
- this._forceHttpFallback = true;
814
- this._forceHttpFallbackUntil = Date.now() + ttlMs;
834
+ this._httpFallbackUntilByPoolKey.set(poolKey, Date.now() + ttlMs);
815
835
  };
816
836
  const traceWsError = (err, stage = 'primary') => {
817
837
  try {
@@ -898,9 +918,8 @@ export class OpenAIOAuthProvider {
898
918
  // a fresh WS entry; only after the bounded WS retry budget is
899
919
  // exhausted does openai-oauth fall back to HTTP/SSE. This preserves
900
920
  // the hot WS/cache path for temporary blips while still preventing
901
- // TUI-level hangs. Operators can opt into immediate HTTP fallback
902
- // for diagnostics with MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK=1.
903
- fastFallback: httpFallbackEnabled && _envFlag('MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK', false),
921
+ // TUI-level hangs. Sticky HTTP fallback is only armed after this
922
+ // bounded reconnect budget is exhausted.
904
923
  // codex-parity prewarm (generate:false full frame on a fresh
905
924
  // socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
906
925
  // R20(on) A/B shows prewarm removes ALL early-session zero-cache
@@ -951,7 +970,9 @@ export class OpenAIOAuthProvider {
951
970
  return recordLiveModel(result);
952
971
  } catch (retryErr) {
953
972
  traceWsError(retryErr, 'auth_retry');
954
- if (httpFallbackEnabled && _shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
973
+ if (httpFallbackEnabled
974
+ && retryErr?.wsRetriesExhausted === true
975
+ && _shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
955
976
  try {
956
977
  return await dispatchHttp(
957
978
  retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed',
@@ -973,32 +994,18 @@ export class OpenAIOAuthProvider {
973
994
  const msg = err?.message || '';
974
995
  const isUnknownModel = status === 404
975
996
  || /unknown[_\s-]?model|model[_\s-]?not[_\s-]?found/i.test(msg);
976
- if (isUnknownModel && !opts._modelRetry) {
997
+ // Catalog recovery reissues the full turn. Once any text/tool
998
+ // output has escaped, that replay can duplicate rendered output or
999
+ // a dispatched side effect, so honor the same unsafe gate as auth
1000
+ // retry and transport fallback.
1001
+ if (isUnknownModel && !opts._modelRetry && !liveTextEmitted) {
977
1002
  process.stderr.write(`[openai-oauth-ws] unknown model — refreshing catalog + 1 retry\n`);
978
1003
  await this._refreshModelCache();
979
1004
  return this.send(messages, model, tools, { ...opts, _modelRetry: true });
980
1005
  }
981
- if (httpFallbackEnabled && _shouldUseOpenAIHttpFallback(err, externalSignal)) {
982
- // Fast-path trace: the WS handshake acquire/first-byte failed on
983
- // its FIRST attempt while fast-fallback was armed, so the
984
- // remaining backoff retries were skipped and HTTP starts now.
985
- // err.attempts===1 means the capped single-attempt path fired.
986
- if (httpFallbackEnabled && Number(err?.attempts) === 1) {
987
- appendAgentTrace({
988
- sessionId: poolKey,
989
- iteration,
990
- kind: 'ws_fallback_fast',
991
- provider: 'openai-oauth',
992
- model: useModel,
993
- transport: 'websocket',
994
- elapsed_ms: Date.now() - _t1,
995
- payload: {
996
- elapsed_ms: Date.now() - _t1,
997
- classifier: err?.retryClassifier || err?.midstreamClassifier || null,
998
- code: err?.code || null,
999
- },
1000
- });
1001
- }
1006
+ if (httpFallbackEnabled
1007
+ && err?.wsRetriesExhausted === true
1008
+ && _shouldUseOpenAIHttpFallback(err, externalSignal)) {
1002
1009
  try {
1003
1010
  return await dispatchHttp(
1004
1011
  err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed',
@@ -41,6 +41,11 @@ export const WS_IDLE_MS = resolveTimeoutMs(
41
41
  );
42
42
  const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
43
43
  const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
44
+ // A write that never reaches the ws callback is indistinguishable from a
45
+ // wedged transport. Keep it under the same short bound as socket acquisition
46
+ // so the caller can discard the entry and reconnect before arming stream
47
+ // watchdogs.
48
+ const WS_SEND_TIMEOUT_MS = WS_ACQUIRE_TIMEOUT_MS;
44
49
  const WS_PING_INTERVAL_MS = PROVIDER_WS_PING_INTERVAL_MS;
45
50
  const WS_PONG_TIMEOUT_MS = PROVIDER_WS_PONG_TIMEOUT_MS;
46
51
  const WS_LIVENESS_STALE_MS = PROVIDER_WS_LIVENESS_STALE_MS;
@@ -386,7 +391,7 @@ function _armLiveness(poolKey, entry) {
386
391
  // for a server event that will never arrive. Tag any failure with
387
392
  // `wsSendFailed=true` so _classifyMidstreamError routes the next attempt
388
393
  // through a fresh socket.
389
- export function _sendFrame(entry, frame, sendSpan = null) {
394
+ export function _sendFrame(entry, frame, sendSpan = null, timeoutMs = WS_SEND_TIMEOUT_MS) {
390
395
  return new Promise((resolve, reject) => {
391
396
  const socket = entry?.socket;
392
397
  if (!socket || socket.readyState !== WebSocket.OPEN) {
@@ -409,18 +414,38 @@ export function _sendFrame(entry, frame, sendSpan = null) {
409
414
  + (performance.now() - serializeStart);
410
415
  }
411
416
  _dumpFrame(payload);
417
+ let settled = false;
418
+ let timer = null;
419
+ const finish = (err = null) => {
420
+ if (settled) return;
421
+ settled = true;
422
+ if (timer) clearTimeout(timer);
423
+ if (!err) {
424
+ resolve();
425
+ return;
426
+ }
427
+ const sendErr = err instanceof Error ? err : new Error(String(err));
428
+ sendErr.wsSendFailed = true;
429
+ // A callback error/timeout leaves the transport state unknown.
430
+ // Drop it immediately; releaseWebSocket will also remove the entry
431
+ // from its pool, and the retry path force-acquires a fresh socket.
432
+ try { entry.closing = true; } catch {}
433
+ try { socket.terminate?.(); } catch {}
434
+ reject(sendErr);
435
+ };
436
+ const boundedTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
437
+ ? timeoutMs
438
+ : WS_SEND_TIMEOUT_MS;
439
+ timer = setTimeout(() => {
440
+ finish(Object.assign(
441
+ new Error(`WS send callback timed out after ${boundedTimeoutMs}ms`),
442
+ { code: 'EWSSENDTIMEOUT', wsSendTimeoutMs: boundedTimeoutMs },
443
+ ));
444
+ }, boundedTimeoutMs);
412
445
  try {
413
- // Do NOT await the send callback: on a wedged-but-OPEN socket the
414
- // ws write callback may never fire, which would hang this Promise
415
- // before _streamResponse arms its first-byte watchdog. Fire and
416
- // resolve immediately; transport failures surface via the socket
417
- // 'error'/'close' handlers and the first-byte watchdog.
418
- socket.send(payload, () => {});
419
- resolve();
446
+ socket.send(payload, (err) => finish(err || null));
420
447
  } catch (e) {
421
- const err = e instanceof Error ? e : new Error(String(e));
422
- err.wsSendFailed = true;
423
- reject(err);
448
+ finish(e);
424
449
  }
425
450
  });
426
451
  }
@@ -292,10 +292,10 @@ export function classifyMidstreamError(err, signals, policy = {}) {
292
292
  // userAbort / watchdogAbort / responseFailedPayload exactly as before.
293
293
  function _classifyMidstreamWs(err, state, attemptIndex, policy) {
294
294
  if (state.sawCompleted) return null
295
- if (state.emittedToolCall) {
296
- const _cc = Number(err?.wsCloseCode || state.wsCloseCode || 0)
297
- if (!(_cc === 1000 && state.sawResponseCreated && !state.sawCompleted)) return null
298
- }
295
+ // Once a tool call has been dispatched, no transport outcome is replay-safe.
296
+ // This includes a nominal close-1000 before response.completed: the tool may
297
+ // already be executing, so retry/fallback could duplicate its side effect.
298
+ if (state.emittedToolCall) return null
299
299
  if (state.emittedText || err?.liveTextEmitted) return null
300
300
  if (state.firstByteTimeout || err?.firstByteTimeout) {
301
301
  return _allowMidstream('first_byte_timeout', attemptIndex, policy)