mixdog 0.9.35 → 0.9.37

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 (105) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/explore-bench-tmp.mjs +19 -0
  5. package/scripts/explore-bench.mjs +36 -6
  6. package/scripts/explore-prompt-policy-test.mjs +27 -0
  7. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  8. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  9. package/scripts/output-style-smoke.mjs +17 -17
  10. package/scripts/provider-toolcall-test.mjs +333 -14
  11. package/scripts/recall-bench-cases.json +3 -3
  12. package/scripts/recall-bench.mjs +1 -1
  13. package/scripts/recall-quality-cases.json +4 -4
  14. package/scripts/recall-usecase-cases.json +2 -2
  15. package/scripts/session-bench.mjs +13 -13
  16. package/scripts/steering-drain-buckets-test.mjs +72 -11
  17. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  18. package/scripts/tool-smoke.mjs +83 -10
  19. package/src/app.mjs +2 -1
  20. package/src/defaults/cycle3-review-prompt.md +9 -0
  21. package/src/defaults/skills/setup/SKILL.md +93 -293
  22. package/src/lib/rules-builder.cjs +3 -2
  23. package/src/output-styles/default.md +2 -2
  24. package/src/output-styles/extreme-minimal.md +1 -1
  25. package/src/output-styles/minimal.md +1 -1
  26. package/src/output-styles/simple.md +2 -3
  27. package/src/rules/agent/30-explorer.md +15 -7
  28. package/src/rules/lead/01-general.md +4 -0
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  34. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  36. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  37. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  40. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  41. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  42. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +4 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  44. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  45. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  47. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  48. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  49. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -17
  52. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  54. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  58. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  63. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  64. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  65. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  66. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  68. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  69. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  70. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  71. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  72. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  73. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  74. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  75. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  76. package/src/runtime/shared/config.mjs +98 -12
  77. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  78. package/src/session-runtime/cwd-plugins.mjs +6 -3
  79. package/src/session-runtime/model-route-api.mjs +50 -2
  80. package/src/session-runtime/provider-auth-api.mjs +8 -1
  81. package/src/session-runtime/resource-api.mjs +22 -0
  82. package/src/session-runtime/runtime-core.mjs +13 -2
  83. package/src/session-runtime/settings-api.mjs +11 -2
  84. package/src/standalone/agent-tool.mjs +15 -9
  85. package/src/standalone/explore-tool.mjs +4 -1
  86. package/src/tui/App.jsx +6 -5
  87. package/src/tui/app/transcript-window.mjs +60 -10
  88. package/src/tui/app/use-transcript-window.mjs +2 -1
  89. package/src/tui/components/ContextPanel.jsx +4 -1
  90. package/src/tui/components/ToolExecution.jsx +15 -12
  91. package/src/tui/components/TranscriptItem.jsx +1 -1
  92. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  93. package/src/tui/dist/index.mjs +473 -155
  94. package/src/tui/engine/agent-job-feed.mjs +26 -6
  95. package/src/tui/engine/context-state.mjs +13 -4
  96. package/src/tui/engine/notification-plan.mjs +3 -3
  97. package/src/tui/engine/render-timing.mjs +104 -8
  98. package/src/tui/engine/session-api.mjs +58 -3
  99. package/src/tui/engine/session-flow.mjs +78 -28
  100. package/src/tui/engine/tool-card-results.mjs +28 -13
  101. package/src/tui/engine/turn.mjs +232 -156
  102. package/src/tui/engine.mjs +17 -8
  103. package/src/tui/index.jsx +10 -1
  104. package/src/workflows/default/WORKFLOW.md +8 -4
  105. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -16,7 +16,7 @@ import { readServicePort, markServiceUnreachable, isConnRefuseError } from '../.
16
16
  const AUTO_DETECT_PORTS = {
17
17
  'mixdog-memory': { discovery: 'memory', dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
18
18
  };
19
- const DEFAULT_MCP_CALL_TIMEOUT_MS = 0;
19
+ const DEFAULT_MCP_CALL_TIMEOUT_MS = 120000;
20
20
  // Per-server STARTUP handshake budget (connect + listTools). Codex parity: 10s.
21
21
  export const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
22
22
  // --- State ---
@@ -227,12 +227,12 @@ export async function executeMcpTool(name, args) {
227
227
  return capMcpOutput(text);
228
228
  }
229
229
 
230
- // MCP per-tool-call timeout. Disabled by default: external MCP tools can be
231
- // long-running, and replaying an arbitrary tool after a timeout can duplicate
232
- // side effects. Operators may opt in with MIXDOG_MCP_CALL_TIMEOUT_MS or a
233
- // per-server timeoutMs/callTimeoutMs config value. On expiry we close the
234
- // transport so the next dispatch reconnects fresh, but we do not retry the
235
- // timed-out call automatically.
230
+ // MCP per-tool-call timeout. Default 2min: a hung/unresponsive MCP server
231
+ // (e.g. a busy editor) must not stall a tool call indefinitely. Genuinely
232
+ // long-running tools can raise/disable it via MIXDOG_MCP_CALL_TIMEOUT_MS or a
233
+ // per-server timeoutMs/callTimeoutMs config value (0/off/none/false disables).
234
+ // On expiry we close the transport so the next dispatch reconnects fresh, but
235
+ // we do not retry the timed-out call automatically (avoids side-effect dupes).
236
236
  export function resolveMcpCallTimeoutMs(cfg = {}, env = process.env) {
237
237
  const raw = cfg?.timeoutMs ?? cfg?.timeout_ms ?? cfg?.callTimeoutMs ?? cfg?.call_timeout_ms
238
238
  ?? env?.MIXDOG_MCP_CALL_TIMEOUT_MS;
@@ -373,15 +373,15 @@ function toAnthropicMessages(messages) {
373
373
 
374
374
  if (m.role === 'tool') {
375
375
  const last = result[result.length - 1];
376
- const refs = Array.isArray(m.nativeToolSearch?.toolReferences)
377
- ? m.nativeToolSearch.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
378
- : [];
376
+ // Do not replay native deferred-loader `tool_reference` blocks from
377
+ // prior turns. They are tied to the exact Anthropic deferred-tool
378
+ // request that produced them; after /model or provider switches the
379
+ // new request may not carry the matching deferred tool surface and
380
+ // Anthropic rejects the history as a request validation error.
379
381
  const block = {
380
382
  type: 'tool_result',
381
383
  tool_use_id: m.toolCallId || '',
382
- content: refs.length
383
- ? refs.map((name) => ({ type: 'tool_reference', tool_name: name }))
384
- : normalizeContentForAnthropic(m.content),
384
+ content: normalizeContentForAnthropic(m.content),
385
385
  };
386
386
  if (last?.role === 'user' && Array.isArray(last.content)) {
387
387
  last.content.push(block);
@@ -376,15 +376,15 @@ function toAnthropicMessages(messages) {
376
376
  }
377
377
  if (m.role === 'tool') {
378
378
  const last = result[result.length - 1];
379
- const refs = Array.isArray(m.nativeToolSearch?.toolReferences)
380
- ? m.nativeToolSearch.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
381
- : [];
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.
382
384
  const block = {
383
385
  type: 'tool_result',
384
386
  tool_use_id: m.toolCallId || '',
385
- content: refs.length
386
- ? refs.map((name) => ({ type: 'tool_reference', tool_name: name }))
387
- : normalizeContentForAnthropic(m.content),
387
+ content: normalizeContentForAnthropic(m.content),
388
388
  };
389
389
  if (last?.role === 'user' && Array.isArray(last.content)) {
390
390
  last.content.push(block);
@@ -15,31 +15,61 @@ function pushUnique(list, value) {
15
15
  if (!list.includes(value)) list.push(value);
16
16
  }
17
17
 
18
+ // Short-TTL memo. Under simultaneous multi-agent launch, buildDefaultConfig
19
+ // and registry lazy-init call these probes in bursts; each probe does
20
+ // synchronous existsSync + readFileSync disk work. Cache the boolean per key
21
+ // for a brief window so one launch burst shares a single read instead of N.
22
+ // Kept short so the registry's "re-probe on each getProvider miss" self-heal
23
+ // (a credential file appearing/changing) is delayed by at most PROBE_TTL_MS.
24
+ const PROBE_TTL_MS = 3000;
25
+ const _probeCache = new Map();
26
+ function memoProbe(key, compute) {
27
+ const hit = _probeCache.get(key);
28
+ const now = Date.now();
29
+ if (hit && now - hit.ts < PROBE_TTL_MS) return hit.value;
30
+ const value = compute();
31
+ _probeCache.set(key, { ts: now, value });
32
+ return value;
33
+ }
34
+
18
35
  export function hasAnthropicOAuthCredentials() {
36
+ return memoProbe('anthropic-oauth', () => {
19
37
  const paths = [];
38
+ const candidates = [];
20
39
  pushUnique(paths, process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH);
21
40
  pushUnique(paths, ANTHROPIC_DEFAULT_CREDENTIALS_PATH);
22
41
  for (const path of paths) {
23
42
  const raw = readJsonIfExists(path);
24
43
  const oauth = raw?.claudeAiOauth;
25
- if (oauth?.accessToken && Array.isArray(oauth.scopes) && oauth.scopes.includes('user:inference')) {
26
- return true;
44
+ if (oauth?.accessToken) {
45
+ candidates.push({
46
+ accessToken: oauth.accessToken,
47
+ expiresAt: Number(oauth.expiresAt ?? oauth.expires_at) || 0,
48
+ scopes: Array.isArray(oauth.scopes) ? oauth.scopes : [],
49
+ });
27
50
  }
28
51
  }
29
- return false;
52
+ if (!candidates.length) return false;
53
+ candidates.sort((a, b) => (Number(b.expiresAt) || 0) - (Number(a.expiresAt) || 0));
54
+ const chosen = candidates[0];
55
+ return !!(chosen.accessToken && Array.isArray(chosen.scopes) && chosen.scopes.includes('user:inference'));
56
+ });
30
57
  }
31
58
 
32
59
  export function hasOpenAIOAuthCredentials() {
60
+ return memoProbe('openai-oauth', () => {
33
61
  const paths = [join(resolvePluginData(), 'openai-oauth.json')];
34
62
  for (const path of paths) {
35
63
  const raw = readJsonIfExists(path);
36
- const tokens = raw?.tokens || raw;
37
- if (tokens?.access_token && tokens?.refresh_token) return true;
64
+ if (raw?.access_token && raw?.refresh_token) return true;
38
65
  }
39
66
  return false;
67
+ });
40
68
  }
41
69
 
42
70
  export function hasGrokOAuthCredentials() {
71
+ return memoProbe('grok-oauth', () => {
43
72
  const own = readJsonIfExists(join(resolvePluginData(), 'grok-oauth.json'));
44
73
  return !!(own?.access_token && own?.refresh_token);
74
+ });
45
75
  }
@@ -10,6 +10,7 @@ import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
10
10
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
11
11
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
12
12
  import { randomBytes } from 'crypto';
13
+ import { createActiveToolItemTracker } from './tool-stream-state.mjs';
13
14
 
14
15
  // Synthesize a native-shaped OpenAI tool call from a recovered leaked call.
15
16
  // Matches the `call_...` id scheme the native Responses/Chat paths use so the
@@ -554,14 +555,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
554
555
  name: event.item.name || '',
555
556
  callId: event.item.call_id || '',
556
557
  });
558
+ state.toolTracker?.mark(event.item);
557
559
  state.toolInFlight = true;
558
560
  } else if (event.item?.type === 'custom_tool_call') {
561
+ state.toolTracker?.mark(event.item);
559
562
  state.toolInFlight = true;
560
563
  } else if (event.item?.type === 'tool_search_call') {
561
564
  // Mark tool_search in-flight at item-added time, same as
562
565
  // function_call/custom_tool_call above, so the stall-recovery
563
566
  // pendingToolUse gate never drops a mid-flight tool_search
564
567
  // before response.output_item.done pushes it.
568
+ state.toolTracker?.mark(event.item);
565
569
  state.toolInFlight = true;
566
570
  }
567
571
  try { onStreamDelta?.(); } catch {}
@@ -569,6 +573,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
569
573
  case 'response.function_call_arguments.delta':
570
574
  // A tool call's args are streaming — mark tool work in-flight so a
571
575
  // mid-args stall is NEVER accepted as a text-only partial-final.
576
+ state.toolTracker?.mark(null, event.item_id);
572
577
  state.toolInFlight = true;
573
578
  try { onStreamDelta?.(); } catch {}
574
579
  break;
@@ -576,6 +581,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
576
581
  // Custom-tool input streams before output_item.done records the call
577
582
  // in pendingCalls; flag it so a mid-input stall gates out partial-
578
583
  // final success (otherwise a tool-bearing turn looks text-only).
584
+ state.toolTracker?.mark(null, event.item_id);
579
585
  state.toolInFlight = true;
580
586
  try { onStreamDelta?.(); } catch {}
581
587
  break;
@@ -616,10 +622,21 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
616
622
  state.toolCalls.push(call);
617
623
  emitCompatToolCallOnce(state, call, onToolCall);
618
624
  }
625
+ // Drop the resolved function item from pendingCalls before
626
+ // recomputing toolInFlight — otherwise a completed call keeps
627
+ // pendingCalls.size > 0 and the latch never clears, so a later
628
+ // text-only stall stays wrongly gated as tool-bearing.
629
+ if (itemId) state.pendingCalls.delete(itemId);
630
+ state.toolTracker?.clear(item, itemId);
631
+ state.toolInFlight = state.pendingCalls.size > 0 || (state.toolTracker ? state.toolTracker.items.size > 0 : false);
619
632
  } else if (item.type === 'tool_search_call') {
620
633
  pushToolSearchCall(item);
634
+ state.toolTracker?.clear(item, item.id || '');
635
+ state.toolInFlight = state.pendingCalls.size > 0 || (state.toolTracker ? state.toolTracker.items.size > 0 : false);
621
636
  } else if (item.type === 'custom_tool_call') {
622
637
  pushCustomToolCall(item);
638
+ state.toolTracker?.clear(item, item.id || '');
639
+ state.toolInFlight = state.pendingCalls.size > 0 || (state.toolTracker ? state.toolTracker.items.size > 0 : false);
623
640
  }
624
641
  try { onStreamDelta?.(); } catch {}
625
642
  break;
@@ -746,6 +763,14 @@ export async function consumeCompatResponsesStream(stream, {
746
763
  pendingCalls: new Map(),
747
764
  emittedToolCallKeys: new Set(),
748
765
  emittedToolCall: false,
766
+ // Active tool-item / alias tracking shared with the WS + HTTP-SSE
767
+ // Responses streams (tool-stream-state.mjs): mark on output_item.added /
768
+ // arg-input deltas, clear on output_item.done. Unions id/call_id/item_id
769
+ // aliases so a mark under one key and a clear under another resolve to
770
+ // the same item — closes the custom-tool-input in-flight gap that a bare
771
+ // boolean toolInFlight latch could not (a mid-input stall now gates out
772
+ // text-only partial-final).
773
+ toolTracker: createActiveToolItemTracker(),
749
774
  completed: false,
750
775
  completedResponse: null,
751
776
  sawOutput: false,
@@ -823,6 +848,7 @@ export async function consumeCompatResponsesStream(stream, {
823
848
  || leakedCalls.length > 0
824
849
  || (state.pendingCalls && state.pendingCalls.size > 0)
825
850
  || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
851
+ || (state.toolTracker && state.toolTracker.items.size > 0)
826
852
  || state.toolInFlight === true;
827
853
  err.partialModel = state.model || undefined;
828
854
  } catch { /* best-effort */ }
@@ -849,6 +875,7 @@ export async function consumeCompatResponsesStream(stream, {
849
875
  || leakedCalls.length > 0
850
876
  || (state.pendingCalls && state.pendingCalls.size > 0)
851
877
  || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
878
+ || (state.toolTracker && state.toolTracker.items.size > 0)
852
879
  || state.toolInFlight === true;
853
880
  err.partialModel = state.model || undefined;
854
881
  } catch { /* best-effort */ }
@@ -105,11 +105,26 @@ export function toOpenAITools(tools) {
105
105
  }));
106
106
  }
107
107
 
108
- export function toResponsesTools(tools) {
108
+ function functionToolFromSessionTool(t, name = t?.name) {
109
+ return {
110
+ type: 'function',
111
+ name,
112
+ description: t.description,
113
+ parameters: t.inputSchema || { type: 'object', additionalProperties: true },
114
+ };
115
+ }
116
+
117
+ export function toResponsesTools(tools, options = {}) {
118
+ const provider = String(options?.provider || '').toLowerCase();
119
+ const allowNativeToolSearch = options?.nativeToolSearch === true
120
+ || (options?.nativeToolSearch !== false && provider !== 'xai');
109
121
  return tools.map((t) => {
110
122
  // load_tool advertises as the OpenAI-native `tool_search` wire type
111
- // (legacy 'tool_search' name still accepted for back-compat).
123
+ // (legacy 'tool_search' name still accepted for back-compat). xAI/Grok
124
+ // Responses rejects that OpenAI-only variant ("unknown variant
125
+ // 'tool_search'"), so serialize it as an ordinary function there.
112
126
  if (t?.name === 'load_tool' || t?.name === 'tool_search') {
127
+ if (!allowNativeToolSearch) return functionToolFromSessionTool(t, 'load_tool');
113
128
  return {
114
129
  type: 'tool_search',
115
130
  execution: 'client',
@@ -122,12 +137,7 @@ export function toResponsesTools(tools) {
122
137
  // tools (e.g. apply_patch) as ordinary function tools instead. Grammar
123
138
  // tools may carry no usable inputSchema, so fall back to a permissive
124
139
  // object schema so grok still registers a valid function tool.
125
- return {
126
- type: 'function',
127
- name: t.name,
128
- description: t.description,
129
- parameters: t.inputSchema || { type: 'object', additionalProperties: true },
130
- };
140
+ return functionToolFromSessionTool(t);
131
141
  });
132
142
  }
133
143
 
@@ -271,18 +281,10 @@ export function collectCompatResponseSearchSources(response) {
271
281
 
272
282
  export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCallNameById = null) {
273
283
  if (m.role === 'tool') {
274
- if (Array.isArray(m.nativeToolSearch?.openaiTools)) {
275
- return {
276
- type: 'tool_search_output',
277
- call_id: m.toolCallId || '',
278
- status: 'completed',
279
- execution: 'client',
280
- tools: m.nativeToolSearch.openaiTools,
281
- };
282
- }
283
284
  const { output, mediaContent } = splitToolContentForOpenAIResponses(m.content);
284
285
  // xai path: never emit `custom_tool_call_output` (the `custom` variant
285
- // is rejected by grok). Replay prior tool outputs as the standard
286
+ // is rejected by grok). Replay prior tool outputs including old
287
+ // native tool_search outputs after /model switches — as the standard
286
288
  // `function_call_output` item regardless of original native type.
287
289
  const item = {
288
290
  type: 'function_call_output',
@@ -296,25 +298,16 @@ export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCa
296
298
  const items = [];
297
299
  if (m.content) items.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
298
300
  for (const tc of m.toolCalls) {
299
- if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
300
- items.push({
301
- type: 'tool_search_call',
302
- call_id: tc.id,
303
- execution: 'client',
304
- arguments: tc.arguments || {},
305
- });
306
- } else {
307
- // xai path: prior native `custom_tool_call` history is replayed
308
- // as a standard `function_call` (grok rejects the `custom`
309
- // variant). tc.arguments already holds the recovered object
310
- // form, so the same stringify path as regular calls applies.
311
- items.push({
312
- type: 'function_call',
313
- call_id: tc.id,
314
- name: tc.name,
315
- arguments: JSON.stringify(tc.arguments || {}),
316
- });
317
- }
301
+ // xAI/Grok rejects OpenAI-only Responses variants such as
302
+ // `custom_tool_call` and `tool_search_call`, including when they
303
+ // appear in replayed history after a model switch. Replay all prior
304
+ // client tools as plain function calls.
305
+ items.push({
306
+ type: 'function_call',
307
+ call_id: tc.id,
308
+ name: tc.name === 'tool_search' ? 'load_tool' : tc.name,
309
+ arguments: JSON.stringify(tc.arguments || {}),
310
+ });
318
311
  }
319
312
  return items;
320
313
  }
@@ -351,6 +344,7 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
351
344
  startIndex = Math.max(0, Math.min(seen, messages.length));
352
345
  if (messages[startIndex]?.role === 'assistant') startIndex += 1;
353
346
  }
347
+ const dropToolHistory = !previousResponseId && (resetReason !== null || !state?.previousResponseId);
354
348
  const input = [];
355
349
  const pendingToolMedia = [];
356
350
  const customToolCallNameById = new Map();
@@ -361,6 +355,11 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
361
355
  for (const m of messages.slice(startIndex)) {
362
356
  if (!includeSystem && m.role === 'system') continue;
363
357
  if (m.role !== 'tool') flushToolMedia();
358
+ if (dropToolHistory && m.role === 'tool') continue;
359
+ if (dropToolHistory && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
360
+ if (m.content) input.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
361
+ continue;
362
+ }
364
363
  const converted = toResponsesInputMessage(m, pendingToolMedia, customToolCallNameById);
365
364
  if (Array.isArray(converted)) input.push(...converted);
366
365
  else input.push(converted);
@@ -467,7 +467,7 @@ export class OpenAICompatProvider {
467
467
  };
468
468
  if (previousResponseId) params.previous_response_id = previousResponseId;
469
469
  const nativeTools = nativeResponsesTools(opts);
470
- if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [])];
470
+ if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [], { provider: 'xai' })];
471
471
  // SSE transport: report 'requesting' until the stream opens, then
472
472
  // per-chunk onStreamDelta feeds the agent stall watchdog.
473
473
  try { opts.onStageChange?.('requesting'); } catch { /* heartbeat best-effort */ }
@@ -664,7 +664,7 @@ export class OpenAICompatProvider {
664
664
  // first response already anchors instructions for the continuation.
665
665
  else if (instructions) params.instructions = instructions;
666
666
  const nativeTools = nativeResponsesTools(opts);
667
- if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [])];
667
+ if (tools?.length || nativeTools.length) params.tools = [...nativeTools, ...toResponsesTools(tools || [], { provider: 'xai' })];
668
668
  const reasoningEffort = normalizeXaiReasoningEffort(opts.xaiReasoningEffort
669
669
  ?? opts.effort
670
670
  ?? this.config?.reasoningEffort
@@ -26,6 +26,7 @@ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
26
26
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
27
27
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
28
28
  import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL, _displayCodexModel } from './openai-oauth.mjs';
29
+ import { createActiveToolItemTracker } from './tool-stream-state.mjs';
29
30
 
30
31
  // Public OpenAI Responses API endpoint for the api-key `openai` provider.
31
32
  // The openai-direct WS transport hits the same origin (openai-ws-pool
@@ -297,7 +298,15 @@ export async function sendViaHttpSse({
297
298
  try {
298
299
  _streamAbortReason.partialContent = content;
299
300
  _streamAbortReason.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
300
- _streamAbortReason.pendingToolUse = pendingCalls.size > 0 || emittedToolCallIds.size > 0;
301
+ // Shared active tool-item tracker (tool-stream-state.mjs) closes
302
+ // the custom-tool gap: a stall mid custom_tool_call_input.delta
303
+ // never lands in pendingCalls, so without activeToolItems/_toolInFlight
304
+ // a tool-bearing turn would look text-only and be wrongly accepted
305
+ // as a partial-final.
306
+ _streamAbortReason.pendingToolUse = pendingCalls.size > 0
307
+ || emittedToolCallIds.size > 0
308
+ || activeToolItems.size > 0
309
+ || _toolInFlight === true;
301
310
  _streamAbortReason.partialModel = model || undefined;
302
311
  } catch { /* best-effort enrichment */ }
303
312
  try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
@@ -313,6 +322,15 @@ export async function sendViaHttpSse({
313
322
  let ttftMs = null;
314
323
  const toolCalls = [];
315
324
  const pendingCalls = new Map();
325
+ // Active tool-item / alias tracking shared with the WS + compat Responses
326
+ // streams (tool-stream-state.mjs). Mark on output_item.added / arg-input
327
+ // deltas, clear on output_item.done; _toolInFlight latches tool work the
328
+ // moment a call's input starts streaming (before it lands in pendingCalls).
329
+ const _toolTracker = createActiveToolItemTracker();
330
+ const activeToolItems = _toolTracker.items;
331
+ const markActiveToolItem = _toolTracker.mark;
332
+ const clearActiveToolItem = _toolTracker.clear;
333
+ let _toolInFlight = false;
316
334
  const reasoningItems = [];
317
335
  const citations = [];
318
336
  const citationKeys = new Set();
@@ -486,10 +504,12 @@ export async function sendViaHttpSse({
486
504
  break;
487
505
  case 'response.output_item.added':
488
506
  if (event.item?.type === 'function_call') {
507
+ markActiveToolItem(event.item);
489
508
  pendingCalls.set(event.item.id || '', {
490
509
  name: event.item.name || '',
491
510
  callId: event.item.call_id || '',
492
511
  });
512
+ _toolInFlight = true;
493
513
  } else if (event.item?.type === 'tool_search_call') {
494
514
  // Mark tool_search as in-flight the moment the item is
495
515
  // added, mirroring function_call above, so the semantic
@@ -506,9 +526,21 @@ export async function sendViaHttpSse({
506
526
  kind: 'tool_search',
507
527
  });
508
528
  }
529
+ markActiveToolItem(event.item);
530
+ _toolInFlight = true;
531
+ } else if (event.item?.type === 'custom_tool_call') {
532
+ // Custom tool calls surface no pendingCalls entry, so mark
533
+ // the item active at added-time (mirroring function_call /
534
+ // tool_search_call above). The later custom_tool_call_input.delta
535
+ // still marks too, but a stall between added and the first
536
+ // input delta must already read as pendingToolUse.
537
+ markActiveToolItem(event.item);
538
+ _toolInFlight = true;
509
539
  }
510
540
  break;
511
541
  case 'response.function_call_arguments.delta':
542
+ markActiveToolItem(null, event.item_id);
543
+ _toolInFlight = true;
512
544
  meaningful();
513
545
  break;
514
546
  case 'response.function_call_arguments.done': {
@@ -530,6 +562,8 @@ export async function sendViaHttpSse({
530
562
  break;
531
563
  }
532
564
  case 'response.custom_tool_call_input.delta':
565
+ markActiveToolItem(null, event.item_id);
566
+ _toolInFlight = true;
533
567
  meaningful();
534
568
  break;
535
569
  case 'response.output_item.done': {
@@ -546,11 +580,23 @@ export async function sendViaHttpSse({
546
580
  emitToolCall(tc);
547
581
  }
548
582
  }
583
+ // Drop the resolved function item from pendingCalls before
584
+ // recomputing _toolInFlight (mirrors tool_search_call below
585
+ // and the compat path) — otherwise a completed call keeps
586
+ // pendingCalls.size > 0 and the latch never clears, so a
587
+ // later max-output cutoff is misread as a tool in flight.
588
+ pendingCalls.delete(item.id || '');
589
+ clearActiveToolItem(item, item.id || '');
590
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
549
591
  } else if (item.type === 'tool_search_call') {
550
592
  pendingCalls.delete(item.id || '');
551
593
  pushToolSearchCall(item);
594
+ clearActiveToolItem(item, item.id || '');
595
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
552
596
  } else if (item.type === 'custom_tool_call') {
553
597
  pushCustomToolCall(item);
598
+ clearActiveToolItem(item, item.id || '');
599
+ _toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
554
600
  meaningful();
555
601
  }
556
602
  break;
@@ -636,6 +682,20 @@ export async function sendViaHttpSse({
636
682
  } else if (event.response.status === 'incomplete') {
637
683
  const reason = _incompleteReasonFromEvent(event);
638
684
  if (_isMaxOutputIncompleteReason(reason)) {
685
+ // Max-output cutoff with a function/custom/tool_search
686
+ // still in flight means the tool arguments were
687
+ // truncated — do NOT mark a clean completion (mirrors
688
+ // the compat Responses path), or partial args surface as
689
+ // a successful tool call. Throw a stream-stalled
690
+ // pendingToolUse error so the loop gates/retries.
691
+ if (pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true) {
692
+ const err = _stampToolSafety(new Error('OpenAI OAuth HTTP fallback response.done incomplete (max_output_tokens) with tool call in flight'));
693
+ err.streamStalled = true;
694
+ err.pendingToolUse = true;
695
+ err.partialContent = content;
696
+ err.partialModel = model || undefined;
697
+ throw err;
698
+ }
639
699
  completed = true;
640
700
  stopReason = 'length';
641
701
  break;
@@ -652,6 +712,17 @@ export async function sendViaHttpSse({
652
712
  case 'response.incomplete': {
653
713
  const reason = _incompleteReasonFromEvent(event);
654
714
  if (_isMaxOutputIncompleteReason(reason)) {
715
+ // See response.done incomplete above: a max-output cutoff
716
+ // while a tool is in flight is a truncated tool call, not a
717
+ // clean length completion.
718
+ if (pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true) {
719
+ const err = _stampToolSafety(new Error('OpenAI OAuth HTTP fallback response.incomplete (max_output_tokens) with tool call in flight'));
720
+ err.streamStalled = true;
721
+ err.pendingToolUse = true;
722
+ err.partialContent = content;
723
+ err.partialModel = model || undefined;
724
+ throw err;
725
+ }
655
726
  completed = true;
656
727
  stopReason = 'length';
657
728
  break;
@@ -895,7 +895,26 @@ export async function sendViaWebSocket({
895
895
  // Delta opt-in still chains via entry.lastResponseId above.
896
896
  }
897
897
 
898
- const delta = _computeDelta({ entry, body: requestBody, traceProvider });
898
+ // Warmup writes the same prefix with generate:false, but the first
899
+ // real response must still be a FULL generating frame. Reusing the
900
+ // warmup response_id here causes _buildResponseCreateFrame() to drop
901
+ // `instructions` (previous_response_id frames cannot carry them) and
902
+ // can also reduce the frame input to [] when the warmup input matches.
903
+ // That made first-turn lead/system rules effectively disappear
904
+ // ("who are you?" answered as a generic OpenAI assistant). Keep the
905
+ // warmup state for cache/trace, but compute the main frame as cold.
906
+ const deltaEntry = warmupResult
907
+ ? {
908
+ ...entry,
909
+ lastResponseId: null,
910
+ lastRequestSansInput: null,
911
+ lastRequestInput: null,
912
+ lastResponseItems: null,
913
+ lastInputLen: 0,
914
+ lastInputPrefixHash: null,
915
+ }
916
+ : entry;
917
+ const delta = _computeDelta({ entry: deltaEntry, body: requestBody, traceProvider });
899
918
  ({ mode, frame } = delta);
900
919
  deltaReason = delta.reason || null;
901
920
  strippedResponseItems = delta.strippedResponseItems || 0;
@@ -1063,7 +1082,11 @@ export async function sendViaWebSocket({
1063
1082
  // own, so retaining the anchor cannot corrupt the cache — it only adds
1064
1083
  // a delta fast-path when the items DO match.
1065
1084
  const keepResponseChain = !!result.responseId;
1066
- const keepSocket = true;
1085
+ // Normally the socket is pooled for reuse. But an early tool-call settle
1086
+ // (result.closeSocket) means the stream resolved before
1087
+ // response.completed/done arrived: the server may still emit those as
1088
+ // orphan frames, so the socket must be discarded, not reused.
1089
+ const keepSocket = !result.closeSocket;
1067
1090
 
1068
1091
  // Update cache state for the next iteration in this session. openai-oauth
1069
1092
  // keeps the previous response anchor even when the model emitted tool
@@ -1300,7 +1323,7 @@ export async function sendViaWebSocket({
1300
1323
  } catch {}
1301
1324
 
1302
1325
  releaseWebSocket({ entry, poolKey, keep: keepSocket });
1303
- const { responseId: _ignored, responseItems: _responseItemsIgnored, ...out } = result;
1326
+ const { responseId: _ignored, responseItems: _responseItemsIgnored, closeSocket: _closeSocketIgnored, ...out } = result;
1304
1327
  if (includeResponseId && result.responseId) out.responseId = result.responseId;
1305
1328
  if (warmupResult) {
1306
1329
  try {
@@ -410,16 +410,6 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
410
410
  for (const m of messages) {
411
411
  if (!m || m.role === 'system') continue;
412
412
  if (m.role === 'tool') {
413
- if (Array.isArray(m.nativeToolSearch?.openaiTools)) {
414
- out.push({
415
- type: 'tool_search_output',
416
- call_id: m.toolCallId || '',
417
- status: 'completed',
418
- execution: 'client',
419
- tools: m.nativeToolSearch.openaiTools,
420
- });
421
- continue;
422
- }
423
413
  const { output, mediaContent } = splitToolContentForOpenAIResponses(m.content);
424
414
  if (customToolCallNameById.has(m.toolCallId || '')) {
425
415
  out.push({
@@ -449,14 +439,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
449
439
  // reasoning in `input` triggers "Duplicate item".
450
440
  if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
451
441
  for (const tc of m.toolCalls) {
452
- if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
453
- out.push({
454
- type: 'tool_search_call',
455
- call_id: tc.id,
456
- execution: 'client',
457
- arguments: tc.arguments || {},
458
- });
459
- } else if (isCustomToolCallRecord(tc)) {
442
+ if (isCustomToolCallRecord(tc)) {
460
443
  if (tc.id) customToolCallNameById.set(tc.id, tc.name || '');
461
444
  out.push({
462
445
  type: 'custom_tool_call',
@@ -468,7 +451,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
468
451
  out.push({
469
452
  type: 'function_call',
470
453
  call_id: tc.id,
471
- name: tc.name,
454
+ name: tc.name === 'tool_search' ? 'load_tool' : tc.name,
472
455
  arguments: JSON.stringify(tc.arguments),
473
456
  });
474
457
  }
@@ -487,8 +470,8 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
487
470
  function toOpenAIResponsesTool(t) {
488
471
  if (t?.name === 'load_tool' || t?.name === 'tool_search') {
489
472
  return {
490
- type: 'tool_search',
491
- execution: 'client',
473
+ type: 'function',
474
+ name: 'load_tool',
492
475
  description: t.description,
493
476
  parameters: t.inputSchema,
494
477
  };
@@ -923,13 +906,14 @@ export class OpenAIOAuthProvider {
923
906
  useModel,
924
907
  displayModel: _displayCodexModel,
925
908
  forceFresh,
926
- // Fast-fallback: when HTTP/SSE fallback is enabled, cap the WS
927
- // handshake acquire loop at ONE attempt so a first
928
- // acquire/first-byte failure aborts the remaining backoff retries
929
- // and lets HTTP start immediately (skip-retries, no concurrent race
930
- // no double token spend). WS-only paths (fallback disabled) keep
931
- // the full retry budget.
932
- fastFallback: httpFallbackEnabled,
909
+ // Default refs-style recovery: keep using WS first. A transient
910
+ // first-byte / mid-stream stall closes the bad socket and retries on
911
+ // a fresh WS entry; only after the bounded WS retry budget is
912
+ // exhausted does openai-oauth fall back to HTTP/SSE. This preserves
913
+ // the hot WS/cache path for temporary blips while still preventing
914
+ // TUI-level hangs. Operators can opt into immediate HTTP fallback
915
+ // for diagnostics with MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK=1.
916
+ fastFallback: httpFallbackEnabled && _envFlag('MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK', false),
933
917
  // codex-parity prewarm (generate:false full frame on a fresh
934
918
  // socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
935
919
  // R20(on) A/B shows prewarm removes ALL early-session zero-cache