mixdog 0.9.46 → 0.9.49

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 (90) hide show
  1. package/package.json +14 -4
  2. package/scripts/agent-parallel-smoke.mjs +4 -1
  3. package/scripts/agent-route-batch-test.mjs +40 -0
  4. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  5. package/scripts/code-graph-description-contract.mjs +185 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  7. package/scripts/code-graph-dispatch-test.mjs +96 -0
  8. package/scripts/compact-pressure-test.mjs +40 -0
  9. package/scripts/deferred-tool-loading-test.mjs +233 -0
  10. package/scripts/execution-completion-dedup-test.mjs +48 -0
  11. package/scripts/explore-prompt-policy-test.mjs +88 -3
  12. package/scripts/live-worker-smoke.mjs +68 -16
  13. package/scripts/memory-core-input-test.mjs +33 -13
  14. package/scripts/native-edit-wire-test.mjs +152 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  16. package/scripts/patch-binary-cache-test.mjs +181 -0
  17. package/scripts/prompt-immediate-render-test.mjs +89 -0
  18. package/scripts/provider-toolcall-test.mjs +280 -15
  19. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  20. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  21. package/scripts/streaming-tail-window-test.mjs +29 -0
  22. package/scripts/tool-failures.mjs +21 -3
  23. package/scripts/tool-smoke.mjs +263 -38
  24. package/scripts/tool-tui-presentation-test.mjs +17 -1
  25. package/scripts/tui-perf-run.ps1 +26 -0
  26. package/scripts/tui-transcript-perf-test.mjs +7 -1
  27. package/scripts/verify-release-assets-test.mjs +647 -0
  28. package/scripts/verify-release-assets.mjs +293 -0
  29. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  30. package/src/cli.mjs +1 -0
  31. package/src/defaults/cycle3-review-prompt.md +10 -4
  32. package/src/defaults/memory-promote-prompt.md +4 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  35. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  36. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  43. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  44. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  45. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  49. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  50. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  51. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  52. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  53. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  55. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  56. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  57. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  58. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  59. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  60. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
  61. package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
  62. package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
  63. package/src/runtime/memory/lib/query-handlers.mjs +49 -6
  64. package/src/runtime/memory/lib/recall-format.mjs +21 -6
  65. package/src/runtime/memory/tool-defs.mjs +2 -3
  66. package/src/session-runtime/context-status.mjs +25 -16
  67. package/src/session-runtime/model-route-api.mjs +2 -0
  68. package/src/session-runtime/runtime-core.mjs +2 -2
  69. package/src/session-runtime/session-turn-api.mjs +4 -1
  70. package/src/session-runtime/tool-catalog.mjs +113 -19
  71. package/src/session-runtime/tool-defs.mjs +1 -0
  72. package/src/standalone/agent-tool/helpers.mjs +25 -6
  73. package/src/standalone/agent-tool.mjs +75 -41
  74. package/src/tui/App.jsx +4 -0
  75. package/src/tui/app/input-parsers.mjs +8 -9
  76. package/src/tui/components/Markdown.jsx +6 -1
  77. package/src/tui/components/Message.jsx +11 -2
  78. package/src/tui/components/PromptInput.jsx +19 -21
  79. package/src/tui/components/Spinner.jsx +4 -4
  80. package/src/tui/components/StatusLine.jsx +6 -6
  81. package/src/tui/components/TranscriptItem.jsx +2 -2
  82. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  83. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  84. package/src/tui/dist/index.mjs +130 -45
  85. package/src/tui/engine/agent-job-feed.mjs +21 -2
  86. package/src/tui/engine/turn.mjs +12 -1
  87. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  88. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  89. package/src/ui/statusline.mjs +5 -5
  90. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -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)
@@ -11,11 +11,12 @@ import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
11
11
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
12
12
  import { executeTool } from './loop/tool-exec.mjs';
13
13
  import { crossTurnSignature } from './loop/completion-guards.mjs';
14
- import { getToolKind, isEagerDispatchable } from './loop/tool-helpers.mjs';
14
+ import { getToolKind, isEagerDispatchable, isToolCallDedupEligible } from './loop/tool-helpers.mjs';
15
15
 
16
16
  export function createEagerDispatcher({
17
17
  tools, cwd, sessionId, sessionRef, signal, opts,
18
18
  crossTurnCalls, getIterations, getNextIteration, repeatFailLimit,
19
+ executeToolFn = executeTool,
19
20
  }) {
20
21
  const pending = new Map();
21
22
  // Streaming-time intra-turn dedup. When the LLM emits two
@@ -24,7 +25,9 @@ export function createEagerDispatcher({
24
25
  // the iter for-body runs, so the batch-level pre-pass would be
25
26
  // too late to prevent the eager dispatch of the second one.
26
27
  // Track signatures of in-flight eager calls and skip starting a
27
- // second one for the same sig. The duplicate's executeTool is
28
+ // second one for the same sig. Loader calls are the narrow exception:
29
+ // each invocation must run and report loaded vs already-active state.
30
+ // Every other duplicate's executeTool is
28
31
  // never invoked; the for-body's pre-pass marks it as a duplicate
29
32
  // and emits a stub tool_result. The sig is NOT cleared when the
30
33
  // eager promise settles (see finally below): a streaming onToolCall
@@ -43,7 +46,8 @@ export function createEagerDispatcher({
43
46
  // body handles it via the invalid-args feedback path.
44
47
  if (isInvalidToolArgsMarker(call.arguments)) return null;
45
48
  const _sig = _intraTurnSig(call.name, call.arguments);
46
- if (_eagerInFlightSigs.has(_sig)) return null;
49
+ const _dedupEligible = isToolCallDedupEligible(call.name, tools);
50
+ if (_dedupEligible && _eagerInFlightSigs.has(_sig)) return null;
47
51
  // Repeat-failure guard also gates eager dispatch (reviewer-flagged):
48
52
  // streaming onToolCall / startEagerRun would otherwise re-run an
49
53
  // identical read-only call that already failed repeatFailLimit
@@ -59,7 +63,7 @@ export function createEagerDispatcher({
59
63
  // re-executed — the serial for-body pushes the [cross-turn-dedup]
60
64
  // stub instead. Without this gate startEagerRun/onToolCall would
61
65
  // re-run the call before the serial dedup check ever sees it.
62
- {
66
+ if (isToolCallDedupEligible(call.name, tools)) {
63
67
  const _ctSig = crossTurnSignature(call.name, call.arguments);
64
68
  const _prior = crossTurnCalls.get(_ctSig);
65
69
  if (_prior && _prior.firstIteration < getIterations()) return null;
@@ -87,10 +91,10 @@ export function createEagerDispatcher({
87
91
  // this call there, never start it eagerly here.
88
92
  if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
89
93
  const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: epoch.mutation };
90
- _eagerInFlightSigs.set(_sig, call.id);
94
+ if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
91
95
  entry.promise = (async () => {
92
96
  try {
93
- return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
97
+ return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
94
98
  } catch (error) {
95
99
  return { ok: false, error };
96
100
  }
@@ -15,7 +15,7 @@ function isActiveSessionTool(session, name) {
15
15
  function lookupDeferredCatalogTool(session, name) {
16
16
  // Union of the boot-frozen catalog and the late-connected MCP catalog so a
17
17
  // direct call to a tool whose server connected after boot resolves and
18
- // auto-loads (selectDeferredTools promotes it onto session.tools).
18
+ // records it in the independent callable registry.
19
19
  const catalog = deferredCatalogUnion(session);
20
20
  const key = clean(name);
21
21
  if (!key) return null;
@@ -54,7 +54,8 @@ function denyDeferredCallThrough(message) {
54
54
  }
55
55
 
56
56
  /**
57
- * Inactive deferred catalog hits: readonly/mode gate + promoteToActive, or deny.
57
+ * Inactive deferred catalog hits: readonly/mode gate + callable registration,
58
+ * or deny. Native provider schema arrays are never mutated here.
58
59
  * Returns null when not applicable (not in catalog, already active, infra allowlist).
59
60
  */
60
61
  export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
@@ -77,6 +78,6 @@ export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
77
78
  }
78
79
 
79
80
  const selectMode = resolvedMode === null ? 'readonly' : resolvedMode;
80
- selectDeferredTools(sessionRef, [toolLabel], selectMode, { promoteToActive: true });
81
+ selectDeferredTools(sessionRef, [toolLabel], selectMode);
81
82
  return null;
82
83
  }
@@ -42,15 +42,17 @@ export function truncateToKb(text, maxKb) {
42
42
  const s = String(text || '');
43
43
  if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
44
44
  const lines = s.split('\n');
45
+ const marker = '[digest truncated at ' + maxKb + 'KB]';
46
+ const contentBudget = maxBytes - Buffer.byteLength(marker, 'utf8');
45
47
  const out = [];
46
48
  let used = 0;
47
49
  for (const line of lines) {
48
50
  const cost = Buffer.byteLength(line, 'utf8') + 1;
49
- if (used + cost > maxBytes) break;
51
+ if (used + cost > contentBudget) break;
50
52
  out.push(line);
51
53
  used += cost;
52
54
  }
53
- return out.join('\n') + '\n[digest truncated at ' + maxKb + 'KB — pull the rest via recall]';
55
+ return out.length ? out.join('\n') + '\n' + marker : marker;
54
56
  }
55
57
 
56
58
  function buildRecallDigestText(sessionId, digestBody, maxKb) {
@@ -61,7 +63,6 @@ function buildRecallDigestText(sessionId, digestBody, maxKb) {
61
63
  // the model needs for a scoped recall.
62
64
  return [
63
65
  `[context compacted — session ${sessionId}]`,
64
- `Full history is in memory — use the recall tool for details beyond this digest.`,
65
66
  `Recent digest (newest first):`,
66
67
  truncateToKb(digestBody, maxKb),
67
68
  ].join('\n');
@@ -29,12 +29,25 @@ export function isEagerDispatchable(name, tools) {
29
29
  for (const t of tools) {
30
30
  if (t?.annotations?.readOnlyHint === true && typeof t.name === 'string') {
31
31
  set.add(t.name);
32
+ // `tool_search` is the legacy provider/runtime call name for the
33
+ // active `load_tool` schema; both must share eager semantics.
34
+ if (t.name === 'load_tool') set.add('tool_search');
35
+ else if (t.name === 'tool_search') set.add('load_tool');
32
36
  }
33
37
  }
34
38
  _eagerNameSetByTools.set(tools, set);
35
39
  }
36
40
  return set.has(name);
37
41
  }
42
+
43
+ // Read-only is necessary but not sufficient for result deduplication. Loader
44
+ // calls mutate the active session tool surface and must execute on every
45
+ // explicit invocation so repeats can truthfully report `alreadyActive`.
46
+ export function isToolCallDedupEligible(name, tools) {
47
+ return name !== 'load_tool'
48
+ && name !== 'tool_search'
49
+ && isEagerDispatchable(name, tools);
50
+ }
38
51
  export function messagesArrayChanged(before, after) {
39
52
  if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
40
53
  if (before.length !== after.length) return true;
@@ -114,7 +127,8 @@ export function parseNativeToolSearchPayload(toolName, result) {
114
127
  && tool.name.trim()
115
128
  && (tool.type === undefined || typeof tool.type === 'string')
116
129
  ));
117
- if (!toolReferences.length && !openaiTools.length) return null;
130
+ const provider = typeof native.provider === 'string' ? native.provider.trim().toLowerCase() : '';
131
+ if (!toolReferences.length && !openaiTools.length && !provider) return null;
118
132
  const baseSummary = typeof native.summary === 'string' && native.summary
119
133
  ? native.summary
120
134
  : `Loaded deferred tools: ${toolReferences.join(', ') || openaiTools.map((tool) => tool.name).filter(Boolean).join(', ')}`;
@@ -140,6 +154,7 @@ export function parseNativeToolSearchPayload(toolName, result) {
140
154
  if (blocked.length) extraLines.push(`blocked: ${blocked.join(', ')}`);
141
155
  const summary = extraLines.length ? `${baseSummary}\n${extraLines.join('; ')}` : baseSummary;
142
156
  return {
157
+ provider,
143
158
  toolReferences,
144
159
  openaiTools,
145
160
  summary,
@@ -397,9 +397,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
397
397
  // don't get overridden by defaults. When session has no profile,
398
398
  // providerCacheOpts is null and this spread is a no-op.
399
399
  ...(session.providerCacheOpts || {}),
400
- onStageChange: (stage) => {
400
+ onStageChange: (stage, detail) => {
401
401
  updateSessionStage(sessionId, stage);
402
- try { askOpts?.onStageChange?.(stage); } catch {}
402
+ try { askOpts?.onStageChange?.(stage, detail); } catch {}
403
403
  },
404
404
  onStreamDelta: (kind = 'semantic') => {
405
405
  markSessionStreamDelta(sessionId, kind).catch(() => {});
@@ -213,7 +213,6 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
213
213
  querySha,
214
214
  recallText: [
215
215
  `session_id=${sessionId}`,
216
- `Full history is in memory — use the recall tool for details beyond this digest.`,
217
216
  // Same byte cap as the loop digest path (recallDigestMaxKb,
218
217
  // default = shared tool-output limit) — without it the memory
219
218
  // renderer bounds the browse at ~200 rows × 1000 chars, letting a
@@ -29,7 +29,12 @@ import {
29
29
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
30
30
  import { executeTool } from './loop/tool-exec.mjs';
31
31
  import { crossTurnSignature, crossTurnDedupStub, isEditProgressTool } from './loop/completion-guards.mjs';
32
- import { getToolKind, isEagerDispatchable, parseNativeToolSearchPayload } from './loop/tool-helpers.mjs';
32
+ import {
33
+ getToolKind,
34
+ isEagerDispatchable,
35
+ isToolCallDedupEligible,
36
+ parseNativeToolSearchPayload,
37
+ } from './loop/tool-helpers.mjs';
33
38
  import { restoreToolCallBodyForId } from './loop/stored-tool-args.mjs';
34
39
 
35
40
  function classifyToolReturn(value) {
@@ -51,8 +56,8 @@ export async function processToolBatch(ctx) {
51
56
  // Intra-turn duplicate suppression: when an LLM emits two tool_use
52
57
  // blocks with identical (name, args) inside the SAME assistant turn,
53
58
  // re-executing wastes tokens. Restricted to tools with
54
- // `readOnlyHint:true` (= isEagerDispatchable)bash/apply_patch
55
- // may be intentional repeats with distinct side effects.
59
+ // `readOnlyHint:true` plus result-dedup eligibility loader calls
60
+ // are read-only-dispatchable but must execute to report state.
56
61
  // Pre-pass identifies duplicates BEFORE startEagerRun so eager
57
62
  // dispatch also skips them, not just the for-body.
58
63
  const _duplicateCallIds = new Set();
@@ -61,7 +66,7 @@ export async function processToolBatch(ctx) {
61
66
  const _firstIdBySig = new Map();
62
67
  for (const c of calls) {
63
68
  if (!c?.id) continue;
64
- if (!isEagerDispatchable(c.name, tools)) {
69
+ if (!isToolCallDedupEligible(c.name, tools)) {
65
70
  _firstIdBySig.clear();
66
71
  continue;
67
72
  }
@@ -115,12 +120,12 @@ export async function processToolBatch(ctx) {
115
120
  continue;
116
121
  }
117
122
  // Cross-turn identical-call stub (Step 2): a SUCCESSFUL read-only
118
- // (isEagerDispatchable) call whose (name,args) signature already ran
123
+ // dedup-eligible call whose (name,args) signature already ran
119
124
  // in an EARLIER turn is not re-executed — its result is unchanged and
120
125
  // already in context. Warn at the 2nd occurrence; append the "stuck"
121
126
  // escalation tail once the session has emitted 5+ dedup stubs total.
122
127
  // Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
123
- if (isEagerDispatchable(call.name, tools)) {
128
+ if (isToolCallDedupEligible(call.name, tools)) {
124
129
  _ctSig = crossTurnSignature(call.name, call.arguments);
125
130
  const _prior = crossTurnCalls.get(_ctSig);
126
131
  if (_prior && _prior.firstIteration < iterations) {
@@ -582,7 +587,7 @@ export async function processToolBatch(ctx) {
582
587
  // Read-only successful calls seed the cross-turn dedup map.
583
588
  if (_executeOk) {
584
589
  const _isEager = isEagerDispatchable(call.name, tools);
585
- if (_isEager) {
590
+ if (isToolCallDedupEligible(call.name, tools)) {
586
591
  if (_ctSig === null) _ctSig = crossTurnSignature(call.name, call.arguments);
587
592
  if (!crossTurnCalls.has(_ctSig)) {
588
593
  crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
@@ -591,7 +596,7 @@ export async function processToolBatch(ctx) {
591
596
  crossTurnCalls.delete(_oldest);
592
597
  }
593
598
  }
594
- } else {
599
+ } else if (!_isEager) {
595
600
  // A successful mutating (non-eager) tool invalidates the
596
601
  // cross-turn dedup map wholesale: any prior read/grep may
597
602
  // now return different content, so a post-edit