mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -14,9 +14,10 @@
14
14
  * a stale number.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, writeFileSync } from 'fs';
17
+ import { existsSync, readFileSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { getPluginData } from '../config.mjs';
20
+ import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
20
21
 
21
22
  const CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
22
23
  const CATALOG_CACHE_FILE = 'litellm-catalog.json';
@@ -187,7 +188,7 @@ async function _loadCatalogImpl() {
187
188
  if (!res.ok) throw new Error('HTTP ' + res.status);
188
189
  const data = await res.json();
189
190
  try {
190
- writeFileSync(cachePath(), JSON.stringify({ fetchedAt: Date.now(), data }));
191
+ writeJsonAtomicSync(cachePath(), { fetchedAt: Date.now(), data }, { lock: true, compact: true, fsyncDir: true, timeoutMs: 1000 });
191
192
  } catch { /* cache is best-effort */ }
192
193
  _memCache = data;
193
194
  _memCacheAt = Date.now();
@@ -239,7 +240,7 @@ async function _loadModelsDevImpl() {
239
240
  if (!res.ok) throw new Error('HTTP ' + res.status);
240
241
  const data = await res.json();
241
242
  try {
242
- writeFileSync(mdCachePath(), JSON.stringify({ fetchedAt: Date.now(), data }));
243
+ writeJsonAtomicSync(mdCachePath(), { fetchedAt: Date.now(), data }, { lock: true, compact: true, fsyncDir: true, timeoutMs: 1000 });
243
244
  } catch { /* cache is best-effort */ }
244
245
  _mdCache = data;
245
246
  _mdCacheAt = Date.now();
@@ -430,12 +431,12 @@ export async function enrichModels(models) {
430
431
  outputCostPerM: meta.outputCostPerM,
431
432
  cacheReadCostPerM: meta.cacheReadCostPerM,
432
433
  cacheWriteCostPerM: meta.cacheWriteCostPerM,
433
- supportsVision: meta.supportsVision,
434
- supportsFunctionCalling: meta.supportsFunctionCalling,
434
+ supportsVision: m.supportsVision === true || meta.supportsVision,
435
+ supportsFunctionCalling: m.supportsFunctionCalling === true || meta.supportsFunctionCalling,
435
436
  supportsWebSearch: meta.supportsWebSearch || m.supportsWebSearch === true,
436
- supportsPromptCaching: meta.supportsPromptCaching,
437
- supportsReasoning: meta.supportsReasoning,
438
- reasoningOptions: meta.reasoningOptions || m.reasoningOptions || [],
437
+ supportsPromptCaching: m.supportsPromptCaching === true || meta.supportsPromptCaching,
438
+ supportsReasoning: m.supportsReasoning === true || meta.supportsReasoning,
439
+ reasoningOptions: m.reasoningOptions?.length ? m.reasoningOptions : (meta.reasoningOptions || []),
439
440
  reasoningContentField: meta.reasoningContentField || m.reasoningContentField || null,
440
441
  mode: meta.mode || m.mode || null,
441
442
  };
@@ -472,3 +473,12 @@ export async function warmModelMetadataCatalogs() {
472
473
  const [litellm] = await Promise.all([loadCatalog(), loadModelsDevCatalog()]);
473
474
  return litellm;
474
475
  }
476
+
477
+ /** Fire-and-forget warm of both in-memory catalog caches (disk-first, then remote). */
478
+ export async function warmCatalogsInBackground() {
479
+ try {
480
+ await Promise.all([loadCatalog(), loadModelsDevCatalog()]);
481
+ } catch {
482
+ /* never throw — boot/statusline must not fail on catalog warm */
483
+ }
484
+ }
@@ -1,12 +1,28 @@
1
1
  import {
2
2
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
3
- PROVIDER_SSE_IDLE_TIMEOUT_MS,
4
3
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
4
+ PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
5
+ streamStalledError,
5
6
  createTimeoutSignal,
6
7
  providerTimeoutError,
7
8
  } from '../stall-policy.mjs';
8
9
  import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
9
10
  import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
11
+ import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
12
+ import { randomBytes } from 'crypto';
13
+
14
+ // Synthesize a native-shaped OpenAI tool call from a recovered leaked call.
15
+ // Matches the `call_...` id scheme the native Responses/Chat paths use so the
16
+ // dispatch loop and any downstream tool_result reference line up.
17
+ function synthLeakedOpenAICall(recovered) {
18
+ let args = recovered?.arguments;
19
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
20
+ return {
21
+ id: `call_leaked_${randomBytes(8).toString('hex')}`,
22
+ name: recovered.name,
23
+ arguments: args,
24
+ };
25
+ }
10
26
 
11
27
  function truncatedCompatStreamError(label, detail) {
12
28
  return Object.assign(
@@ -87,17 +103,29 @@ function firstByteCompatStreamError(label) {
87
103
  return err;
88
104
  }
89
105
 
90
- async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel } = {}) {
106
+ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, idleLabel, emittedToolCall } = {}) {
91
107
  let idleTimer = null;
92
108
  let idleReject = null;
93
109
  let idleTimedOut = false;
110
+ // Double-dispatch guard (reviewer High): if a tool call was already emitted
111
+ // this stream, a stall must be unsafe-to-retry so withRetry() won't replay
112
+ // the turn and re-run the side-effecting tool. `emittedToolCall` may be a
113
+ // boolean or a getter evaluated at abort time (state mutates mid-stream).
114
+ const didEmitToolCall = () => {
115
+ try { return typeof emittedToolCall === 'function' ? !!emittedToolCall() : !!emittedToolCall; }
116
+ catch { return false; }
117
+ };
94
118
  const armIdle = () => {
95
119
  if (!idleEnabled || !(idleMs > 0)) return;
96
120
  if (idleTimer) clearTimeout(idleTimer);
97
121
  idleTimer = setTimeout(() => {
98
122
  idleTimedOut = true;
99
- const e = providerTimeoutError(idleLabel || 'compat SSE idle', idleMs);
100
- e.code = 'ETIMEDOUT';
123
+ // SEMANTIC idle abort: this timer is (re)armed only around waiting
124
+ // for the NEXT stream event, so keepalive/comment frames the SDK
125
+ // filters out cannot keep it alive. Throw the named terminal
126
+ // StreamStalledError so the retry-classifier treats it as a stream
127
+ // failure (owner gets notified) rather than a user cancel.
128
+ const e = streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
101
129
  if (idleReject) {
102
130
  const r = idleReject;
103
131
  idleReject = null;
@@ -143,7 +171,7 @@ async function nextAsyncWithWatchdog(iterator, { signal, idleMs, idleEnabled, id
143
171
  return result;
144
172
  } catch (err) {
145
173
  if (idleTimer) clearTimeout(idleTimer);
146
- if (idleTimedOut) throw providerTimeoutError(idleLabel || 'compat SSE idle', idleMs);
174
+ if (idleTimedOut) throw streamStalledError(idleLabel || 'compat SSE', idleMs, { emittedToolCall: didEmitToolCall() });
147
175
  throw err;
148
176
  }
149
177
  }
@@ -213,6 +241,15 @@ function emitCompatToolCallOnce(state, call, onToolCall) {
213
241
  const key = `id:${call.id}`;
214
242
  if (!state.emittedToolCallKeys) state.emittedToolCallKeys = new Set();
215
243
  if (state.emittedToolCallKeys.has(key)) return false;
244
+ // Fix 2: cross-path name+args dedupe. A synthesized text-leaked call and an
245
+ // identical native tool_call must fire onToolCall exactly once. state._toolDedupe
246
+ // is created per stream; when absent (older callers) behavior is unchanged.
247
+ if (state._toolDedupe && !state._toolDedupe.shouldDispatch(call.name, call.arguments)) {
248
+ // Still mark the id as emitted so later id-frames for the same native
249
+ // call don't retry, but do NOT invoke onToolCall (already dispatched).
250
+ state.emittedToolCallKeys.add(key);
251
+ return false;
252
+ }
216
253
  state.emittedToolCallKeys.add(key);
217
254
  state.emittedToolCall = true;
218
255
  const { _pendingItemId, ...cleanCall } = call;
@@ -257,11 +294,14 @@ function isMaxOutputIncompleteReason(reason) {
257
294
  return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
258
295
  }
259
296
 
260
- export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls } = {}) {
297
+ export async function consumeCompatChatCompletionStream(stream, { signal, label, onStreamDelta, onToolCall, onTextDelta, parseToolCalls, knownToolNames } = {}) {
261
298
  const iterator = stream[Symbol.asyncIterator]();
262
299
  const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
263
300
  const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
264
- const idleMs = PROVIDER_SSE_IDLE_TIMEOUT_MS;
301
+ // Per-event (last-event-relative) SEMANTIC idle: nextAsyncWithWatchdog arms
302
+ // the timer only while awaiting the NEXT stream event, so a stream that
303
+ // emits some deltas then goes silent trips it within the window.
304
+ const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
265
305
  let sawFirstEvent = false;
266
306
  let content = '';
267
307
  let reasoningContent = '';
@@ -276,6 +316,46 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
276
316
  let rawUsage = null;
277
317
  const toolAcc = new Map();
278
318
  const toolBucketState = { _orderSeq: 0, _nextAnonId: 0, _lastAnonKey: null };
319
+ // Fix 2: one dedupe per stream, shared by the synthetic leaked-call
320
+ // dispatch and every native emit so an identical (name,args) fires once.
321
+ const _toolDedupe = createToolCallDedupe();
322
+ // Leaked tool-call guard: the model sometimes emits a tool call as plain
323
+ // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
324
+ // `<|channel|>...to=functions.NAME...<|call|>`) inside `delta.content`
325
+ // instead of a native `tool_calls` delta. Route content through the guard
326
+ // so leaked calls are suppressed from visible text, synthesized, and
327
+ // dispatched like native calls. Additive: the native tool_calls path is
328
+ // untouched. Harmony detection is opt-in here (gpt-oss compat backends).
329
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
330
+ const dispatchLeakedCall = (recovered) => {
331
+ const call = synthLeakedOpenAICall(recovered);
332
+ const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
333
+ emitCompatToolCallOnce(emitState, call, onToolCall);
334
+ return call;
335
+ };
336
+ const leakedCalls = [];
337
+ const relayText = (delta) => {
338
+ const { text, calls } = leakGuard.push(delta);
339
+ if (text) {
340
+ content += text;
341
+ if (onTextDelta) {
342
+ emittedText = true;
343
+ try { onTextDelta(text); } catch {}
344
+ }
345
+ }
346
+ for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
347
+ };
348
+ const flushLeak = () => {
349
+ const { text, calls } = leakGuard.flush();
350
+ if (text) {
351
+ content += text;
352
+ if (onTextDelta) {
353
+ emittedText = true;
354
+ try { onTextDelta(text); } catch {}
355
+ }
356
+ }
357
+ for (const c of calls) leakedCalls.push(dispatchLeakedCall(c));
358
+ };
279
359
  try {
280
360
  while (true) {
281
361
  const { value: chunk, done } = await nextAsyncWithWatchdog(iterator, {
@@ -285,6 +365,9 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
285
365
  idleMs,
286
366
  idleEnabled: sawFirstEvent && idleEnabled,
287
367
  idleLabel: `${label} SSE idle`,
368
+ // A stall after a tool call has already been dispatched (native
369
+ // or recovered-leaked) must be unsafe-to-retry (no double-run).
370
+ emittedToolCall: () => leakedCalls.length > 0 || toolAcc.size > 0,
288
371
  });
289
372
  if (done) break;
290
373
  if (!sawFirstEvent) {
@@ -296,12 +379,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
296
379
  if (chunk?.model) model = chunk.model;
297
380
  const choice = chunk?.choices?.[0];
298
381
  if (choice?.delta?.content) {
299
- content += choice.delta.content;
300
- // Live text relay (gateway): explicit assistant text delta.
382
+ // Live text relay (gateway): explicit assistant text delta,
383
+ // routed through the leaked-tool-call guard (which appends to
384
+ // `content`, forwards visible text, and recovers leaked calls).
301
385
  // reasoning_content + tool_calls deltas stay off this path.
302
- if (onTextDelta) {
303
- emittedText = true;
304
- try { onTextDelta(choice.delta.content); } catch {}
386
+ if (leakGuard.enabled) {
387
+ relayText(choice.delta.content);
388
+ } else {
389
+ content += choice.delta.content;
390
+ if (onTextDelta) {
391
+ emittedText = true;
392
+ try { onTextDelta(choice.delta.content); } catch {}
393
+ }
305
394
  }
306
395
  }
307
396
  if (typeof choice?.delta?.reasoning_content === 'string') {
@@ -311,9 +400,23 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
311
400
  if (choice?.finish_reason) stopReason = choice.finish_reason;
312
401
  if (chunk?.usage) rawUsage = chunk.usage;
313
402
  }
403
+ // Flush any partial-sentinel tail held back mid-stream so legitimate
404
+ // trailing text is never lost.
405
+ if (leakGuard.enabled) flushLeak();
314
406
  } catch (err) {
315
407
  // Any mid-stream failure after live text was relayed is non-retryable.
316
408
  if (emittedText) throw markErrorLiveTextEmitted(err);
409
+ // Partial-final recovery parity: on a mid-stream stall, attach the
410
+ // streamed partial state so the loop can accept a wedged FINAL no-tool
411
+ // summary as partial-final success. pendingToolUse gates out any
412
+ // in-flight/emitted tool call.
413
+ if (err?.streamStalled === true) {
414
+ try {
415
+ err.partialContent = content;
416
+ err.pendingToolUse = toolAcc.size > 0 || leakedCalls.length > 0;
417
+ err.partialModel = model || undefined;
418
+ } catch { /* best-effort */ }
419
+ }
317
420
  throw err;
318
421
  } finally {
319
422
  firstByteTimeout.cleanup();
@@ -356,9 +459,18 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
356
459
  throw err;
357
460
  }
358
461
  if (Array.isArray(toolCalls) && toolCalls.length) {
359
- const emitState = { emittedToolCallKeys: new Set() };
462
+ const emitState = { emittedToolCallKeys: new Set(), _toolDedupe };
360
463
  for (const call of toolCalls) emitCompatToolCallOnce(emitState, call, onToolCall);
361
464
  }
465
+ // Fold recovered leaked calls into the returned toolCalls so the dispatch
466
+ // loop treats them exactly like native ones. They were already emitted via
467
+ // onToolCall in relayText/flushLeak, so no re-dispatch here. Dedupe the
468
+ // final array by name+args (Fix 2, array side): a synthetic leaked call and
469
+ // an identical native tool_call must not both remain, else the loop runs
470
+ // the side-effecting tool twice.
471
+ if (leakedCalls.length) {
472
+ toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
473
+ }
362
474
  return {
363
475
  response,
364
476
  model,
@@ -370,7 +482,7 @@ export async function consumeCompatChatCompletionStream(stream, { signal, label,
370
482
  };
371
483
  }
372
484
 
373
- function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta }) {
485
+ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText }) {
374
486
  if (!event || typeof event.type !== 'string') return;
375
487
  const pushToolSearchCall = (item) => {
376
488
  if (!item || item.type !== 'tool_search_call') return;
@@ -399,12 +511,17 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
399
511
  if (event.response?.id) state.responseId = event.response.id;
400
512
  break;
401
513
  case 'response.output_text.delta':
402
- state.content += event.delta || '';
403
514
  state.sawOutput = true;
404
515
  try { onStreamDelta?.(); } catch {}
405
- if (event.delta && onTextDelta) {
406
- state.emittedText = true;
407
- try { onTextDelta(event.delta); } catch {}
516
+ // Route assistant text through the leaked-tool-call guard (appends
517
+ // to state.content, forwards visible text, recovers leaked calls).
518
+ if (relayLeakText) relayLeakText(event.delta || '');
519
+ else {
520
+ state.content += event.delta || '';
521
+ if (event.delta && onTextDelta) {
522
+ state.emittedText = true;
523
+ try { onTextDelta(event.delta); } catch {}
524
+ }
408
525
  }
409
526
  break;
410
527
  case 'response.output_item.added':
@@ -413,13 +530,23 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
413
530
  name: event.item.name || '',
414
531
  callId: event.item.call_id || '',
415
532
  });
533
+ state.toolInFlight = true;
534
+ } else if (event.item?.type === 'custom_tool_call') {
535
+ state.toolInFlight = true;
416
536
  }
417
537
  try { onStreamDelta?.(); } catch {}
418
538
  break;
419
539
  case 'response.function_call_arguments.delta':
540
+ // A tool call's args are streaming — mark tool work in-flight so a
541
+ // mid-args stall is NEVER accepted as a text-only partial-final.
542
+ state.toolInFlight = true;
420
543
  try { onStreamDelta?.(); } catch {}
421
544
  break;
422
545
  case 'response.custom_tool_call_input.delta':
546
+ // Custom-tool input streams before output_item.done records the call
547
+ // in pendingCalls; flag it so a mid-input stall gates out partial-
548
+ // final success (otherwise a tool-bearing turn looks text-only).
549
+ state.toolInFlight = true;
423
550
  try { onStreamDelta?.(); } catch {}
424
551
  break;
425
552
  case 'response.function_call_arguments.done': {
@@ -555,11 +682,13 @@ export async function consumeCompatResponsesStream(stream, {
555
682
  onTextDelta,
556
683
  parseResponsesToolCalls,
557
684
  responseOutputText,
685
+ knownToolNames,
558
686
  } = {}) {
559
687
  const iterator = stream[Symbol.asyncIterator]();
560
688
  const firstByteTimeout = createTimeoutSignal(signal, PROVIDER_FIRST_BYTE_TIMEOUT_MS, `${label} first byte`);
561
689
  const idleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED;
562
- const idleMs = PROVIDER_SSE_IDLE_TIMEOUT_MS;
690
+ // Per-event (last-event-relative) SEMANTIC idle — see the Chat path note.
691
+ const idleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
563
692
  const state = {
564
693
  content: '',
565
694
  model: '',
@@ -572,13 +701,51 @@ export async function consumeCompatResponsesStream(stream, {
572
701
  completed: false,
573
702
  completedResponse: null,
574
703
  sawOutput: false,
704
+ // Fix 2: cross-path name+args dedupe shared by synthetic leaked-call
705
+ // dispatch and every native emit in this Responses stream.
706
+ _toolDedupe: createToolCallDedupe(),
575
707
  // Gateway live-text relay invariant: set once a non-empty text chunk
576
708
  // has been forwarded. A later failure is non-retryable (rendered text
577
709
  // cannot be withdrawn; a retry would concatenate attempts).
578
710
  emittedText: false,
579
711
  };
580
712
  let sawFirstEvent = false;
581
- const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta };
713
+ // Leaked tool-call guard for the Responses text stream. Same recovery as
714
+ // the Chat path: leaked XML/harmony tool syntax in `output_text.delta` is
715
+ // suppressed from visible text, synthesized, and dispatched like native.
716
+ const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
717
+ const leakedCalls = [];
718
+ const dispatchLeakedCall = (recovered) => {
719
+ const call = synthLeakedOpenAICall(recovered);
720
+ emitCompatToolCallOnce(state, call, onToolCall);
721
+ leakedCalls.push(call);
722
+ };
723
+ const relayLeakText = leakGuard.enabled
724
+ ? (delta) => {
725
+ const { text, calls } = leakGuard.push(delta);
726
+ if (text) {
727
+ state.content += text;
728
+ if (onTextDelta) {
729
+ state.emittedText = true;
730
+ try { onTextDelta(text); } catch {}
731
+ }
732
+ }
733
+ for (const c of calls) dispatchLeakedCall(c);
734
+ }
735
+ : null;
736
+ const flushLeak = () => {
737
+ if (!leakGuard.enabled) return;
738
+ const { text, calls } = leakGuard.flush();
739
+ if (text) {
740
+ state.content += text;
741
+ if (onTextDelta) {
742
+ state.emittedText = true;
743
+ try { onTextDelta(text); } catch {}
744
+ }
745
+ }
746
+ for (const c of calls) dispatchLeakedCall(c);
747
+ };
748
+ const deps = { label, parseResponsesToolCalls, responseOutputText, onStreamDelta, onToolCall, onTextDelta, relayLeakText };
582
749
  try {
583
750
  while (true) {
584
751
  const { value: event, done } = await nextAsyncWithWatchdog(iterator, {
@@ -586,6 +753,9 @@ export async function consumeCompatResponsesStream(stream, {
586
753
  idleMs,
587
754
  idleEnabled: sawFirstEvent && idleEnabled,
588
755
  idleLabel: `${label} SSE idle`,
756
+ // Unsafe-to-retry once any tool call (native or recovered-leaked)
757
+ // has been emitted this stream — avoid a double side-effect.
758
+ emittedToolCall: () => state.emittedToolCall || leakedCalls.length > 0,
589
759
  });
590
760
  if (done) break;
591
761
  if (!sawFirstEvent) {
@@ -594,7 +764,21 @@ export async function consumeCompatResponsesStream(stream, {
594
764
  }
595
765
  handleCompatResponsesStreamEvent(event, state, deps);
596
766
  }
767
+ flushLeak();
597
768
  } catch (err) {
769
+ // Partial-final recovery parity: attach streamed partial state so a
770
+ // wedged FINAL no-tool summary can be accepted as partial-final success.
771
+ if (err?.streamStalled === true) {
772
+ try {
773
+ err.partialContent = state.content || '';
774
+ err.pendingToolUse = state.emittedToolCall === true
775
+ || leakedCalls.length > 0
776
+ || (state.pendingCalls && state.pendingCalls.size > 0)
777
+ || (Array.isArray(state.toolCalls) && state.toolCalls.length > 0)
778
+ || state.toolInFlight === true;
779
+ err.partialModel = state.model || undefined;
780
+ } catch { /* best-effort */ }
781
+ }
598
782
  throw markUnsafeRetryIfToolEmitted(err, state);
599
783
  } finally {
600
784
  firstByteTimeout.cleanup();
@@ -618,9 +802,14 @@ export async function consumeCompatResponsesStream(stream, {
618
802
  output_text: state.content,
619
803
  output: [],
620
804
  };
621
- const toolCalls = state.toolCalls.length
805
+ let toolCalls = state.toolCalls.length
622
806
  ? state.toolCalls.map(({ _pendingItemId, ...t }) => t)
623
807
  : parseResponsesToolCalls(response, label);
808
+ // Fold recovered leaked calls in (already emitted via onToolCall above).
809
+ // Dedupe by name+args so an identical native+synthetic pair can't run twice.
810
+ if (leakedCalls.length) {
811
+ toolCalls = dedupeToolCallList([...(Array.isArray(toolCalls) ? toolCalls : []), ...leakedCalls]);
812
+ }
624
813
  return {
625
814
  response,
626
815
  content: state.content || responseOutputText(response),