mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -23,11 +23,8 @@
23
23
  * auth bundle; this module handles connection caching, delta framing, event
24
24
  * parsing, and tracing.
25
25
  */
26
- import WebSocket from 'ws';
27
- import { errText } from '../../../shared/err-text.mjs';
28
- import { createHash, randomBytes } from 'crypto';
26
+ import { createHash } from 'crypto';
29
27
  import {
30
- extractCachedTokens,
31
28
  traceAgentFetch,
32
29
  traceAgentSse,
33
30
  traceAgentUsage,
@@ -39,53 +36,47 @@ import {
39
36
  createStreamSafetyStamps,
40
37
  jitterDelayMs,
41
38
  MIDSTREAM_RETRY_POLICY,
42
- populateHttpStatusFromMessage,
43
39
  sleepWithAbort,
44
40
  } from './retry-classifier.mjs';
45
- import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
46
- import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
47
41
  import {
48
42
  PROVIDER_RETRY_MAX_ATTEMPTS,
49
- PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
50
- PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
51
- PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
52
- PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
53
- PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
54
- streamStalledError,
55
- resolveTimeoutMs,
56
43
  } from '../stall-policy.mjs';
57
- import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
44
+ import {
45
+ WS_IDLE_MS,
46
+ acquireWebSocket,
47
+ releaseWebSocket,
48
+ _sendFrame,
49
+ _closeAllPooledSockets,
50
+ drainOpenaiWsPool,
51
+ } from './openai-ws-pool.mjs';
52
+ import {
53
+ WS_PRE_RESPONSE_CREATED_MS,
54
+ WS_INTER_CHUNK_MS,
55
+ _sansInput,
56
+ _stableStringify,
57
+ _cloneJson,
58
+ _estimateFrameTokens,
59
+ _combineUsageWithWarmup,
60
+ _computeDelta,
61
+ _logicalResponseItemMatch,
62
+ parseToolSearchArgs,
63
+ _streamResponse,
64
+ } from './openai-ws-stream.mjs';
65
+
66
+ // Legacy import paths for scripts/tool-smoke.mjs, mixdog-session-runtime.mjs
67
+ // (drainOpenaiWsPool), scripts/provider-toolcall-test.mjs (parseToolSearchArgs,
68
+ // _logicalResponseItemMatch, _streamResponse) and other external callers.
69
+ export {
70
+ _closeAllPooledSockets,
71
+ drainOpenaiWsPool,
72
+ _logicalResponseItemMatch,
73
+ parseToolSearchArgs,
74
+ _streamResponse,
75
+ };
58
76
 
59
77
  globalThis.__mixdogOpenaiWsRuntimeLoaded = true;
60
78
 
61
- const CODEX_WS_URL = 'wss://chatgpt.com/backend-api/codex/responses';
62
- const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs';
63
- const OPENAI_WS_URL = 'wss://api.openai.com/v1/responses';
64
- const XAI_WS_URL = 'wss://api.x.ai/v1/responses';
65
- const WS_IDLE_MS = resolveTimeoutMs(
66
- 'MIXDOG_PROVIDER_WS_IDLE_MS',
67
- 20 * 60_000,
68
- { minMs: 60_000, maxMs: 60 * 60_000 },
69
- );
70
- const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
71
- const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
72
- // Pre-`response.created` deadline. Once the socket is open and the
73
- // response.create frame is sent, a healthy server emits response.created
74
- // within seconds. If it stalls past this short bound the socket has wedged
75
- // post-upgrade with zero server events — treat it as a fast, retryable
76
- // first-byte timeout. This is the ONLY pre-stream watchdog; once any server
77
- // event arrives the single inter-chunk idle timer below takes over.
78
- // Only this short window is shortened; the post-`response.created`
79
- // inter-chunk / reasoning span keeps the longer deadlines below.
80
- const WS_PRE_RESPONSE_CREATED_MS = (() => {
81
- const raw = process.env.MIXDOG_PROVIDER_WS_PRE_RESPONSE_CREATED_TIMEOUT_MS;
82
- const n = Number(raw);
83
- if (Number.isFinite(n) && n > 0) return Math.min(Math.max(n, 1_000), 120_000);
84
- return 10_000;
85
- })();
86
- // Single inter-chunk idle timer. Resets on EVERY received frame — any frame,
87
- // including metadata/keepalive, proves the socket is live.
88
- const WS_INTER_CHUNK_MS = PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS;
79
+ // --- WS_PRE_RESPONSE_CREATED_MS / WS_INTER_CHUNK_MS: extracted to openai-ws-stream.mjs ---
89
80
  // Mid-stream retry budgets + backoff now live in the shared MIDSTREAM_RETRY_POLICY
90
81
  // table (retry-classifier.mjs). These aliases keep the local call sites readable
91
82
  // and ensure the numbers exist in exactly ONE place.
@@ -113,2019 +104,11 @@ const WS_MIDSTREAM_POLICY = {
113
104
  const HANDSHAKE_MAX_ATTEMPTS = PROVIDER_RETRY_MAX_ATTEMPTS;
114
105
  const HANDSHAKE_BACKOFF_BASE_MS = 500;
115
106
  const HANDSHAKE_BACKOFF_CAP_MS = 5000;
116
- // WS socket pool buckets are keyed by `poolKey` (the per-call sessionId)
117
- // to isolate parallel agent invocations — each gets its own socket so
118
- // a second caller cannot grab a sibling's mid-turn entry (openai-oauth would
119
- // otherwise reject the new response.create with "No tool output found
120
- // for function call ..."). The handshake `session_id` header/URL
121
- // uses `cacheKey` — a prefix-scoped cache key derived from the configured
122
- // provider namespace plus model/system/tools hash. Same-prefix sessions share
123
- // server-side prompt cache, while unrelated main/worker prefixes no longer
124
- // evict each other inside one static provider lane. The backend dedupes cache by
125
- // handshake session_id, not by body.prompt_cache_key alone (measured
126
- // 2026-04-19 after the v0.6.151 regression).
127
- const MAX_POOLED_SOCKETS_PER_KEY = 8;
128
-
129
- // poolKey -> Entry[]
130
- // Entry: { socket, busy, idleTimer, lastResponseId, lastRequestSansInput,
131
- // lastRequestInput, lastResponseItems, lastInputLen, turnState,
132
- // closing, ephemeral }
133
- const _wsPool = new Map();
134
-
135
- // Final prompt_cache_key/session_id lane guard for OpenAI OAuth transports.
136
- // The provider code may shard one logical prefix into N cache keys for 10+
137
- // total parallelism; inside each final key we still serialize requests because
138
- // Live probes show same-key concurrent WebSockets can randomly miss the
139
- // server prompt cache even after warm-up.
140
- const _openAiPromptCacheLanes = new Map();
141
- const _openAiPromptCacheLaneRates = new Map();
142
- const OPENAI_PROMPT_CACHE_LANE_RATE_WINDOW_MS = 60_000;
143
- const OPENAI_PROMPT_CACHE_LANE_RATE_MAX_KEYS = 4096;
144
-
145
- function _positiveInt(value, fallback = 0) {
146
- const n = Number(value);
147
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
148
- }
149
-
150
- function _nonNegativeInt(value, fallback = 0) {
151
- const n = Number(value);
152
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
153
- }
154
-
155
- function _cacheLaneHash(value) {
156
- return createHash('sha256').update(String(value || '')).digest('hex').slice(0, 12);
157
- }
158
-
159
- function _openAiPromptCacheLaneMaxInFlight(sendOpts = {}) {
160
- return _positiveInt(
161
- sendOpts?.openaiCacheLaneMaxInFlight
162
- ?? sendOpts?.promptCacheLaneMaxInFlight
163
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_MAX_INFLIGHT
164
- ?? process.env.MIXDOG_OPENAI_CACHE_MAX_INFLIGHT,
165
- 1,
166
- );
167
- }
168
-
169
- function _openAiPromptCacheLaneQueueTimeoutMs(sendOpts = {}) {
170
- return _positiveInt(
171
- sendOpts?.openaiCacheLaneQueueTimeoutMs
172
- ?? sendOpts?.promptCacheLaneQueueTimeoutMs
173
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_QUEUE_TIMEOUT_MS
174
- ?? process.env.MIXDOG_OPENAI_CACHE_QUEUE_TIMEOUT_MS,
175
- 0,
176
- );
177
- }
178
-
179
- function _openAiPromptCacheLaneRateLimitPerMin(sendOpts = {}) {
180
- return _nonNegativeInt(
181
- sendOpts?.openaiCacheLaneRateLimitPerMin
182
- ?? sendOpts?.promptCacheLaneRateLimitPerMin
183
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_RPM
184
- ?? process.env.MIXDOG_OPENAI_CACHE_KEY_RPM,
185
- 12,
186
- );
187
- }
188
-
189
- function _openAiPromptCacheLaneDeltaRateLimitPerMin(sendOpts = {}) {
190
- return _nonNegativeInt(
191
- sendOpts?.openaiCacheLaneDeltaRateLimitPerMin
192
- ?? sendOpts?.promptCacheLaneDeltaRateLimitPerMin
193
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_DELTA_RPM
194
- ?? process.env.MIXDOG_OPENAI_CACHE_DELTA_RPM,
195
- 60,
196
- );
197
- }
198
-
199
- function _openAiPromptCacheLaneDeltaMaxItems(sendOpts = {}) {
200
- return _nonNegativeInt(
201
- sendOpts?.openaiCacheLaneDeltaMaxItems
202
- ?? sendOpts?.promptCacheLaneDeltaMaxItems
203
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_DELTA_MAX_ITEMS
204
- ?? process.env.MIXDOG_OPENAI_CACHE_DELTA_MAX_ITEMS,
205
- 8,
206
- );
207
- }
208
-
209
- function _openAiPromptCacheLaneDeltaMaxTokens(sendOpts = {}) {
210
- return _nonNegativeInt(
211
- sendOpts?.openaiCacheLaneDeltaMaxTokens
212
- ?? sendOpts?.promptCacheLaneDeltaMaxTokens
213
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_DELTA_MAX_TOKENS
214
- ?? process.env.MIXDOG_OPENAI_CACHE_DELTA_MAX_TOKENS,
215
- 20_000,
216
- );
217
- }
218
-
219
- function _openAiPromptCacheLaneSlowTraceMs(sendOpts = {}) {
220
- return _positiveInt(
221
- sendOpts?.openaiCacheLaneSlowTraceMs
222
- ?? sendOpts?.promptCacheLaneSlowTraceMs
223
- ?? process.env.MIXDOG_OPENAI_CACHE_LANE_SLOW_MS,
224
- 3000,
225
- );
226
- }
227
-
228
- function _isOpenAiPromptCacheLaneAuth(auth) {
229
- return auth?.type !== 'xai';
230
- }
231
-
232
- function _getOpenAiPromptCacheLaneState(key, maxInFlight) {
233
- let state = _openAiPromptCacheLanes.get(key);
234
- if (!state) {
235
- state = { key, active: 0, queue: [], maxInFlight, nextId: 0 };
236
- _openAiPromptCacheLanes.set(key, state);
237
- }
238
- state.maxInFlight = maxInFlight;
239
- return state;
240
- }
241
-
242
- function _cleanupOpenAiPromptCacheLane(state) {
243
- if (state.active === 0 && state.queue.length === 0) {
244
- _openAiPromptCacheLanes.delete(state.key);
245
- }
246
- }
247
-
248
- function _getOpenAiPromptCacheLaneRateState(key) {
249
- let state = _openAiPromptCacheLaneRates.get(key);
250
- if (!state) {
251
- state = { key, starts: [], lastUsedAt: Date.now() };
252
- _openAiPromptCacheLaneRates.set(key, state);
253
- }
254
- state.lastUsedAt = Date.now();
255
- if (_openAiPromptCacheLaneRates.size > OPENAI_PROMPT_CACHE_LANE_RATE_MAX_KEYS) {
256
- let oldestKey = null;
257
- let oldestAt = Infinity;
258
- for (const [k, v] of _openAiPromptCacheLaneRates) {
259
- if ((v?.lastUsedAt || 0) < oldestAt) {
260
- oldestAt = v.lastUsedAt || 0;
261
- oldestKey = k;
262
- }
263
- }
264
- if (oldestKey) _openAiPromptCacheLaneRates.delete(oldestKey);
265
- }
266
- return state;
267
- }
268
-
269
- function _pruneOpenAiPromptCacheLaneRateState(state, now = Date.now()) {
270
- const cutoff = now - OPENAI_PROMPT_CACHE_LANE_RATE_WINDOW_MS;
271
- while (state.starts.length > 0 && state.starts[0] <= cutoff) state.starts.shift();
272
- state.lastUsedAt = now;
273
- }
274
-
275
- function _sleepWithSignal(ms, signal) {
276
- if (ms <= 0) return Promise.resolve();
277
- return new Promise((resolve, reject) => {
278
- let timer = null;
279
- let abortListener = null;
280
- const cleanup = () => {
281
- if (timer) clearTimeout(timer);
282
- if (signal && abortListener) signal.removeEventListener('abort', abortListener);
283
- };
284
- abortListener = () => {
285
- cleanup();
286
- const reason = signal?.reason;
287
- reject(reason instanceof Error ? reason : new Error('OpenAI prompt cache lane rate wait aborted'));
288
- };
289
- if (signal?.aborted) {
290
- abortListener();
291
- return;
292
- }
293
- if (signal) signal.addEventListener('abort', abortListener, { once: true });
294
- timer = setTimeout(() => {
295
- cleanup();
296
- resolve();
297
- }, ms);
298
- timer.unref?.();
299
- });
300
- }
301
-
302
- async function _reserveOpenAiPromptCacheLaneRate({ key, limitPerMin, signal, beforeWait }) {
303
- if (!limitPerMin || limitPerMin <= 0) {
304
- return { rateLimitPerMin: 0, rateWaitMs: 0, rateWindowCount: 0 };
305
- }
306
- const state = _getOpenAiPromptCacheLaneRateState(key);
307
- const startedAt = Date.now();
308
- let beforeWaitCalled = false;
309
- while (true) {
310
- const now = Date.now();
311
- _pruneOpenAiPromptCacheLaneRateState(state, now);
312
- if (state.starts.length < limitPerMin) {
313
- state.starts.push(now);
314
- return {
315
- rateLimitPerMin: limitPerMin,
316
- rateWaitMs: now - startedAt,
317
- rateWindowCount: state.starts.length,
318
- };
319
- }
320
- const waitMs = Math.max(25, state.starts[0] + OPENAI_PROMPT_CACHE_LANE_RATE_WINDOW_MS - now);
321
- if (!beforeWaitCalled && typeof beforeWait === 'function') {
322
- beforeWaitCalled = true;
323
- await beforeWait({
324
- waitMs,
325
- rateLimitPerMin: limitPerMin,
326
- rateWindowCount: state.starts.length,
327
- });
328
- }
329
- await _sleepWithSignal(waitMs, signal);
330
- }
331
- }
332
-
333
- export function _resolveOpenAiPromptCacheRatePolicy(sendOpts = {}, info = {}) {
334
- const mode = String(info?.mode || '').toLowerCase();
335
- const frameInputItems = Number(info?.frameInputItems);
336
- const deltaTokens = Number(info?.deltaTokens);
337
- const hasPreviousResponseId = info?.hasPreviousResponseId === true;
338
- const fullLimitPerMin = _openAiPromptCacheLaneRateLimitPerMin(sendOpts);
339
- const deltaLimitPerMin = _openAiPromptCacheLaneDeltaRateLimitPerMin(sendOpts);
340
- const deltaMaxItems = _openAiPromptCacheLaneDeltaMaxItems(sendOpts);
341
- const deltaMaxTokens = _openAiPromptCacheLaneDeltaMaxTokens(sendOpts);
342
- const itemCount = Number.isFinite(frameInputItems) ? frameInputItems : null;
343
- const tokenCount = Number.isFinite(deltaTokens) ? deltaTokens : null;
344
- const smallDeltaItems = itemCount == null || deltaMaxItems <= 0 || itemCount <= deltaMaxItems;
345
- const smallDeltaTokens = tokenCount == null || deltaMaxTokens <= 0 || tokenCount <= deltaMaxTokens;
346
-
347
- if (mode === 'delta' && hasPreviousResponseId && smallDeltaItems && smallDeltaTokens) {
348
- return {
349
- policy: deltaLimitPerMin > 0 ? 'delta_relaxed' : 'delta_unlimited',
350
- limitPerMin: deltaLimitPerMin,
351
- fullLimitPerMin,
352
- deltaLimitPerMin,
353
- deltaMaxItems,
354
- deltaMaxTokens,
355
- frameInputItems: itemCount,
356
- deltaTokens: tokenCount,
357
- };
358
- }
359
-
360
- return {
361
- policy: mode === 'delta' ? 'delta_guarded' : 'full_guard',
362
- limitPerMin: fullLimitPerMin,
363
- fullLimitPerMin,
364
- deltaLimitPerMin,
365
- deltaMaxItems,
366
- deltaMaxTokens,
367
- frameInputItems: itemCount,
368
- deltaTokens: tokenCount,
369
- };
370
- }
371
-
372
- function _removeQueuedOpenAiPromptCacheLaneRequest(state, request) {
373
- const index = state.queue.indexOf(request);
374
- if (index >= 0) state.queue.splice(index, 1);
375
- _cleanupOpenAiPromptCacheLane(state);
376
- }
377
-
378
- function _releaseOpenAiPromptCacheLane(state) {
379
- state.active = Math.max(0, state.active - 1);
380
- while (state.queue.length > 0 && state.active < state.maxInFlight) {
381
- const next = state.queue.shift();
382
- next.cleanup?.();
383
- state.active += 1;
384
- next.resolve(_makeOpenAiPromptCacheLaneHandle(state, next.requestId, next.enqueuedAt, true));
385
- }
386
- _cleanupOpenAiPromptCacheLane(state);
387
- }
388
-
389
- function _makeOpenAiPromptCacheLaneHandle(state, requestId, enqueuedAt, queued) {
390
- let released = false;
391
- return {
392
- requestId,
393
- queued,
394
- waitedMs: Date.now() - enqueuedAt,
395
- activeCount: state.active,
396
- queueDepth: state.queue.length,
397
- release() {
398
- if (released) return;
399
- released = true;
400
- _releaseOpenAiPromptCacheLane(state);
401
- },
402
- };
403
- }
404
-
405
- function _acquireOpenAiPromptCacheLane({ key, maxInFlight, signal, timeoutMs }) {
406
- const state = _getOpenAiPromptCacheLaneState(key, maxInFlight);
407
- const requestId = ++state.nextId;
408
- const enqueuedAt = Date.now();
409
- if (state.active < state.maxInFlight) {
410
- state.active += 1;
411
- return Promise.resolve(_makeOpenAiPromptCacheLaneHandle(state, requestId, enqueuedAt, false));
412
- }
413
- return new Promise((resolve, reject) => {
414
- const request = { requestId, enqueuedAt, resolve, reject, cleanup: null, timer: null, abortListener: null };
415
- const cleanup = () => {
416
- if (request.timer) clearTimeout(request.timer);
417
- if (signal && request.abortListener) signal.removeEventListener('abort', request.abortListener);
418
- };
419
- request.cleanup = cleanup;
420
- request.abortListener = () => {
421
- cleanup();
422
- _removeQueuedOpenAiPromptCacheLaneRequest(state, request);
423
- const reason = signal?.reason;
424
- reject(reason instanceof Error ? reason : new Error('OpenAI prompt cache lane wait aborted'));
425
- };
426
- if (signal?.aborted) {
427
- request.abortListener();
428
- return;
429
- }
430
- if (signal) signal.addEventListener('abort', request.abortListener, { once: true });
431
- if (timeoutMs > 0) {
432
- request.timer = setTimeout(() => {
433
- cleanup();
434
- _removeQueuedOpenAiPromptCacheLaneRequest(state, request);
435
- reject(new Error(`OpenAI prompt cache lane wait timed out after ${timeoutMs}ms`));
436
- }, timeoutMs);
437
- request.timer.unref?.();
438
- }
439
- state.queue.push(request);
440
- });
441
- }
442
-
443
- async function _withOpenAiPromptCacheLane({ auth, cacheKey, sendOpts, poolKey, iteration, traceProvider, useModel, externalSignal }, fn) {
444
- if (!_isOpenAiPromptCacheLaneAuth(auth) || !cacheKey) {
445
- return await fn({ enabled: false, maxInFlight: 0 });
446
- }
447
- const maxInFlight = _openAiPromptCacheLaneMaxInFlight(sendOpts);
448
- if (maxInFlight <= 0) {
449
- return await fn({ enabled: false, maxInFlight: 0 });
450
- }
451
- const laneKey = `openai-prompt:${traceProvider || 'openai'}:${useModel || 'default'}:${cacheKey}`;
452
- const laneKeyHash = _cacheLaneHash(laneKey);
453
- const state = _getOpenAiPromptCacheLaneState(laneKey, maxInFlight);
454
- const queued = state.active >= state.maxInFlight;
455
- if (queued) {
456
- appendAgentTrace({
457
- sessionId: poolKey,
458
- iteration,
459
- kind: 'cache_lane',
460
- provider: traceProvider,
461
- model: useModel,
462
- event: 'queued',
463
- lane_key_hash: laneKeyHash,
464
- max_in_flight: maxInFlight,
465
- active: state.active,
466
- queue_depth: state.queue.length,
467
- });
468
- }
469
- const timeoutMs = _openAiPromptCacheLaneQueueTimeoutMs(sendOpts);
470
- let handle = await _acquireOpenAiPromptCacheLane({ key: laneKey, maxInFlight, signal: externalSignal, timeoutMs });
471
- let handleActive = true;
472
- const laneMeta = {
473
- enabled: true,
474
- laneKeyHash,
475
- maxInFlight,
476
- ratePolicy: 'pending',
477
- rateLimitPerMin: 0,
478
- rateWaitMs: 0,
479
- rateWindowCount: 0,
480
- rateReleasedForWait: false,
481
- rateReacquireWaitMs: 0,
482
- queued: queued || handle.queued === true,
483
- waitMs: handle.waitedMs,
484
- activeAfterAcquire: handle.activeCount,
485
- queueDepthAfterAcquire: handle.queueDepth,
486
- async reserveRate(info = {}) {
487
- if (laneMeta.ratePolicy !== 'pending') return laneMeta;
488
- const policy = _resolveOpenAiPromptCacheRatePolicy(sendOpts, info);
489
- let releasedForRateWait = false;
490
- const rateMeta = await _reserveOpenAiPromptCacheLaneRate({
491
- key: laneKey,
492
- limitPerMin: policy.limitPerMin,
493
- signal: externalSignal,
494
- beforeWait: () => {
495
- if (!handleActive) return;
496
- handle.release();
497
- handleActive = false;
498
- releasedForRateWait = true;
499
- },
500
- });
501
- let reacquireWaitMs = 0;
502
- if (releasedForRateWait) {
503
- const reacquired = await _acquireOpenAiPromptCacheLane({
504
- key: laneKey,
505
- maxInFlight,
506
- signal: externalSignal,
507
- timeoutMs,
508
- });
509
- handle = reacquired;
510
- handleActive = true;
511
- reacquireWaitMs = reacquired.waitedMs;
512
- laneMeta.queued = laneMeta.queued || reacquired.queued === true;
513
- laneMeta.waitMs = (Number(laneMeta.waitMs) || 0) + reacquireWaitMs;
514
- laneMeta.activeAfterAcquire = reacquired.activeCount;
515
- laneMeta.queueDepthAfterAcquire = reacquired.queueDepth;
516
- }
517
- Object.assign(laneMeta, {
518
- ratePolicy: policy.policy,
519
- rateLimitPerMin: rateMeta.rateLimitPerMin,
520
- rateWaitMs: rateMeta.rateWaitMs,
521
- rateWindowCount: rateMeta.rateWindowCount,
522
- rateReleasedForWait: releasedForRateWait,
523
- rateReacquireWaitMs: reacquireWaitMs,
524
- rateFullLimitPerMin: policy.fullLimitPerMin,
525
- rateDeltaLimitPerMin: policy.deltaLimitPerMin,
526
- rateDeltaMaxItems: policy.deltaMaxItems,
527
- rateDeltaMaxTokens: policy.deltaMaxTokens,
528
- ratePolicyFrameInputItems: policy.frameInputItems,
529
- ratePolicyDeltaTokens: policy.deltaTokens,
530
- });
531
- if (rateMeta.rateWaitMs > 0) {
532
- appendAgentTrace({
533
- sessionId: poolKey,
534
- iteration,
535
- kind: 'cache_lane',
536
- provider: traceProvider,
537
- model: useModel,
538
- event: 'rate_wait',
539
- lane_key_hash: laneKeyHash,
540
- rate_policy: laneMeta.ratePolicy,
541
- rate_limit_per_min: rateMeta.rateLimitPerMin,
542
- rate_wait_ms: rateMeta.rateWaitMs,
543
- rate_window_count: rateMeta.rateWindowCount,
544
- released_for_rate_wait: releasedForRateWait,
545
- reacquire_wait_ms: reacquireWaitMs,
546
- frame_input_items: policy.frameInputItems,
547
- delta_tokens: policy.deltaTokens,
548
- });
549
- }
550
- return laneMeta;
551
- },
552
- };
553
- try {
554
- return await fn(laneMeta);
555
- } finally {
556
- const slowTraceMs = _openAiPromptCacheLaneSlowTraceMs(sendOpts);
557
- const slowWaitMs = Math.max(Number(laneMeta.rateWaitMs) || 0, Number(laneMeta.waitMs) || 0);
558
- if (slowTraceMs > 0 && slowWaitMs >= slowTraceMs) {
559
- appendAgentTrace({
560
- sessionId: poolKey,
561
- iteration,
562
- kind: 'cache_lane_slow',
563
- provider: traceProvider,
564
- model: useModel,
565
- event: laneMeta.rateWaitMs > 0 && laneMeta.waitMs > 0
566
- ? 'rate_and_queue_wait'
567
- : laneMeta.rateWaitMs > 0
568
- ? 'rate_wait'
569
- : 'queue_wait',
570
- lane_key_hash: laneKeyHash,
571
- payload: {
572
- event: laneMeta.rateWaitMs > 0 && laneMeta.waitMs > 0
573
- ? 'rate_and_queue_wait'
574
- : laneMeta.rateWaitMs > 0
575
- ? 'rate_wait'
576
- : 'queue_wait',
577
- provider: traceProvider,
578
- model: useModel,
579
- lane_key_hash: laneKeyHash,
580
- threshold_ms: slowTraceMs,
581
- max_wait_ms: slowWaitMs,
582
- rate_policy: laneMeta.ratePolicy,
583
- rate_limit_per_min: laneMeta.rateLimitPerMin,
584
- rate_wait_ms: laneMeta.rateWaitMs,
585
- rate_window_count: laneMeta.rateWindowCount,
586
- released_for_rate_wait: laneMeta.rateReleasedForWait,
587
- reacquire_wait_ms: laneMeta.rateReacquireWaitMs,
588
- rate_full_limit_per_min: laneMeta.rateFullLimitPerMin,
589
- rate_delta_limit_per_min: laneMeta.rateDeltaLimitPerMin,
590
- rate_delta_max_items: laneMeta.rateDeltaMaxItems,
591
- rate_delta_max_tokens: laneMeta.rateDeltaMaxTokens,
592
- rate_policy_frame_input_items: laneMeta.ratePolicyFrameInputItems,
593
- rate_policy_delta_tokens: laneMeta.ratePolicyDeltaTokens,
594
- max_in_flight: laneMeta.maxInFlight,
595
- queued: laneMeta.queued,
596
- wait_ms: laneMeta.waitMs,
597
- active_after_acquire: laneMeta.activeAfterAcquire,
598
- queue_depth_after_acquire: laneMeta.queueDepthAfterAcquire,
599
- },
600
- });
601
- }
602
- if (handleActive) handle.release();
603
- }
604
- }
605
-
606
- function _getPoolArr(poolKey) {
607
- if (!poolKey) return null;
608
- let arr = _wsPool.get(poolKey);
609
- if (!arr) {
610
- arr = [];
611
- _wsPool.set(poolKey, arr);
612
- }
613
- return arr;
614
- }
615
-
616
- function _removeFromPool(poolKey, entry) {
617
- if (!poolKey) return;
618
- const arr = _wsPool.get(poolKey);
619
- if (!arr) return;
620
- const idx = arr.indexOf(entry);
621
- if (idx >= 0) arr.splice(idx, 1);
622
- if (arr.length === 0) _wsPool.delete(poolKey);
623
- }
624
-
625
- function _scheduleIdleClose(poolKey, entry) {
626
- if (!entry) return;
627
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
628
- entry.idleTimer = setTimeout(() => {
629
- if (entry.busy) return;
630
- try { entry.socket.close(1000, 'idle_timeout'); } catch {}
631
- _removeFromPool(poolKey, entry);
632
- }, WS_IDLE_MS);
633
- try { entry.idleTimer.unref?.(); } catch {}
634
- }
635
-
636
- function _clearIdle(entry) {
637
- if (entry?.idleTimer) {
638
- clearTimeout(entry.idleTimer);
639
- entry.idleTimer = null;
640
- }
641
- }
642
-
643
- function _isOpen(entry) {
644
- return entry?.socket?.readyState === WebSocket.OPEN;
645
- }
646
-
647
- // Awaited frame send. Asserts the socket is OPEN and resolves only after
648
- // the underlying transport reports the buffered write succeeded (or fails)
649
- // via the WebSocket send callback. Raw `socket.send(JSON.stringify(...))`
650
- // is fire-and-forget — a wedged or half-closed socket silently queues the
651
- // payload and the caller assumes it landed, then later times out waiting
652
- // for a server event that will never arrive. Tag any failure with
653
- // `wsSendFailed=true` so _classifyMidstreamError routes the next attempt
654
- // through a fresh socket.
655
- function _sendFrame(entry, frame) {
656
- return new Promise((resolve, reject) => {
657
- const socket = entry?.socket;
658
- if (!socket || socket.readyState !== WebSocket.OPEN) {
659
- const err = new Error(`WS send: socket not OPEN (readyState=${socket?.readyState ?? 'n/a'})`);
660
- err.wsSendFailed = true;
661
- reject(err);
662
- return;
663
- }
664
- let payload;
665
- try { payload = JSON.stringify(frame); }
666
- catch (e) {
667
- const err = e instanceof Error ? e : new Error(String(e));
668
- err.wsSendFailed = true;
669
- reject(err);
670
- return;
671
- }
672
- try {
673
- // Do NOT await the send callback: on a wedged-but-OPEN socket the
674
- // ws write callback may never fire, which would hang this Promise
675
- // before _streamResponse arms its first-byte watchdog. Fire and
676
- // resolve immediately; transport failures surface via the socket
677
- // 'error'/'close' handlers and the first-byte watchdog.
678
- socket.send(payload, () => {});
679
- resolve();
680
- } catch (e) {
681
- const err = e instanceof Error ? e : new Error(String(e));
682
- err.wsSendFailed = true;
683
- reject(err);
684
- }
685
- });
686
- }
687
-
688
- function _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey: _cacheKey }) {
689
- // xAI WS: do NOT pin x-grok-conv-id. Measured parallel runs show that
690
- // forcing a routing shard via that header alternates cold caches across
691
- // parallel workers; the automatic prompt-prefix cache holds up better
692
- // when each handshake is unpinned. Reference: vercel/ai xai provider.
693
- const headers = auth.type === 'xai'
694
- ? {
695
- 'Authorization': `Bearer ${auth.apiKey}`,
696
- }
697
- : auth.type === 'openai-direct'
698
- ? {
699
- 'Authorization': `Bearer ${auth.apiKey}`,
700
- 'OpenAI-Beta': 'responses_websockets=2026-02-06',
701
- }
702
- : {
703
- 'Authorization': `Bearer ${auth.access_token}`,
704
- 'chatgpt-account-id': auth.account_id || '',
705
- 'originator': CODEX_OAUTH_ORIGINATOR,
706
- 'OpenAI-Beta': 'responses_websockets=2026-02-06',
707
- };
708
- if (sessionToken) {
709
- const sid = String(sessionToken);
710
- headers['session_id'] = sid;
711
- }
712
- // x-client-request-id must be a per-request value so server-side request
713
- // traces stay distinguishable across retries / reconnects sharing the same
714
- // session_id. Reusing sessionToken (= cacheKey) collapsed every request
715
- // for the same conversation onto one trace bucket.
716
- headers['x-client-request-id'] = randomBytes(16).toString('hex');
717
- if (turnState) headers['x-codex-turn-state'] = turnState;
718
- return headers;
719
- }
720
-
721
- // handshake session_id is the conversation slot openai-oauth uses for in-memory
722
- // prefix state. All orchestrator-internal dispatches for this provider share
723
- // the same cacheKey (built in manager.mjs via providerCacheKey()), so they
724
- // share the server-side prefix-cache shard across roles/sources.
725
- function _mintSessionToken(cacheKey, auth) {
726
- // xAI's public WebSocket endpoint uses the open connection plus
727
- // response ids for continuation; unlike openai-oauth, it does not need the
728
- // OAuth-specific session_id handshake shard.
729
- if (auth?.type === 'xai') return null;
730
- return cacheKey || 'mixdog-default';
731
- }
732
-
733
- function _openSocket({ auth, sessionToken, turnState, externalSignal, cacheKey }) {
734
- const headers = _buildHandshakeHeaders({ auth, sessionToken, turnState, cacheKey });
735
- const baseUrl = auth.type === 'xai'
736
- ? XAI_WS_URL
737
- : auth.type === 'openai-direct'
738
- ? OPENAI_WS_URL
739
- : CODEX_WS_URL;
740
- const _wsOpenStart = Date.now();
741
- if (process.env.MIXDOG_DEBUG_AGENT) {
742
- process.stderr.write(`[agent-trace] ws-open-start url=${baseUrl} tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} ts=${_wsOpenStart}\n`);
743
- }
744
- const url = baseUrl + (sessionToken ? `?session_id=${encodeURIComponent(String(sessionToken))}` : '');
745
- return new Promise((resolve, reject) => {
746
- let settled = false;
747
- let abortListener = null;
748
- let acquireTimer = null;
749
- const settle = (ok, val) => {
750
- if (settled) return;
751
- settled = true;
752
- if (acquireTimer) {
753
- clearTimeout(acquireTimer);
754
- acquireTimer = null;
755
- }
756
- if (abortListener && externalSignal) {
757
- try { externalSignal.removeEventListener('abort', abortListener); } catch {}
758
- }
759
- (ok ? resolve : reject)(val);
760
- };
761
- const socket = new WebSocket(url, { headers, handshakeTimeout: WS_HANDSHAKE_TIMEOUT_MS });
762
- acquireTimer = setTimeout(() => {
763
- if (settled) return;
764
- if (process.env.MIXDOG_DEBUG_AGENT) {
765
- process.stderr.write(`[agent-trace] ws-open-fail kind=acquire_timeout timeoutMs=${WS_ACQUIRE_TIMEOUT_MS} elapsed=${Date.now() - _wsOpenStart}ms\n`);
766
- }
767
- try { socket.terminate(); } catch {}
768
- settle(false, Object.assign(
769
- new Error(`${_wsErrLabel(auth?.type === 'xai' ? 'xai' : auth?.type === 'openai-direct' ? 'openai-direct' : 'openai-oauth')} acquire timed out before open (${WS_ACQUIRE_TIMEOUT_MS}ms)`),
770
- { code: 'EWSACQUIRETIMEOUT', acquireTimeoutMs: WS_ACQUIRE_TIMEOUT_MS },
771
- ));
772
- }, WS_ACQUIRE_TIMEOUT_MS);
773
- try { acquireTimer.unref?.(); } catch {}
774
- const capturedHeaders = { turnState: null };
775
- socket.once('upgrade', (res) => {
776
- try {
777
- const ts = res?.headers?.['x-codex-turn-state'];
778
- if (typeof ts === 'string' && ts.length) capturedHeaders.turnState = ts;
779
- } catch {}
780
- });
781
- socket.once('open', () => {
782
- if (process.env.MIXDOG_DEBUG_AGENT) {
783
- process.stderr.write(`[agent-trace] ws-open-ok elapsed=${Date.now() - _wsOpenStart}ms\n`);
784
- }
785
- settle(true, { socket, turnState: capturedHeaders.turnState });
786
- });
787
- socket.once('error', (err) => {
788
- if (process.env.MIXDOG_DEBUG_AGENT) {
789
- process.stderr.write(`[agent-trace] ws-open-fail kind=error msg=${String(err?.message || err).slice(0, 120)} elapsed=${Date.now() - _wsOpenStart}ms\n`);
790
- }
791
- try { socket.terminate(); } catch {}
792
- settle(false, err instanceof Error ? err : Object.assign(new Error(errText(err) || 'openai-oauth WS error'), { wsErrorEvent: true, original: err }));
793
- });
794
- socket.once('close', (code, reason) => {
795
- // Half-open handshake: the peer closed before 'open'/'error' fired
796
- // (TCP RST / TLS edge). Without this the connect Promise never
797
- // settles and only the 600s outer watchdog can break the stall
798
- // (observed stage=requesting 601s hang). Open-path closes are
799
- // no-ops here because settle() has already flipped `settled`.
800
- if (settled) return;
801
- try { socket.terminate(); } catch {}
802
- settle(false, Object.assign(
803
- new Error(`${_wsErrLabel(auth?.type === 'xai' ? 'xai' : auth?.type === 'openai-direct' ? 'openai-direct' : 'openai-oauth')} handshake closed before open (code=${code})`),
804
- { wsCloseCode: code, wsCloseReason: (reason && reason.toString) ? reason.toString('utf-8') : '' }));
805
- });
806
- socket.once('unexpected-response', (_req, res) => {
807
- if (settled) return;
808
- const status = res?.statusCode || 0;
809
- let body = '';
810
- res.on('data', c => { if (body.length < 2048) body += c.toString('utf-8'); });
811
- res.on('end', () => {
812
- if (process.env.MIXDOG_DEBUG_AGENT) {
813
- process.stderr.write(`[agent-trace] ws-open-fail kind=http status=${status} body=${body.slice(0, 120)} elapsed=${Date.now() - _wsOpenStart}ms\n`);
814
- }
815
- try { socket.terminate(); } catch {}
816
- settle(false, Object.assign(new Error(`${_wsErrLabel(auth?.type === 'xai' ? 'xai' : auth?.type === 'openai-direct' ? 'openai-direct' : 'openai-oauth')} handshake ${status}: ${body.slice(0, 200)}`), { httpStatus: status, httpBody: body }));
817
- });
818
- });
819
- if (externalSignal) {
820
- const onAbort = () => {
821
- try { socket.terminate(); } catch {}
822
- const reason = externalSignal.reason;
823
- settle(false, reason instanceof Error ? reason : new Error(`${_wsErrLabel(auth?.type === 'xai' ? 'xai' : auth?.type === 'openai-direct' ? 'openai-direct' : 'openai-oauth')} handshake aborted`));
824
- };
825
- if (externalSignal.aborted) { onAbort(); return; }
826
- abortListener = onAbort;
827
- externalSignal.addEventListener('abort', onAbort, { once: true });
828
- }
829
- });
830
- }
831
-
832
- async function acquireWebSocket({ auth, poolKey, cacheKey, forceFresh, externalSignal }) {
833
- const _acqStart = Date.now();
834
- if (process.env.MIXDOG_DEBUG_AGENT) {
835
- process.stderr.write(`[agent-trace] acquire-start poolKey=${poolKey} cacheKey=${cacheKey} forceFresh=${forceFresh} externalAborted=${!!externalSignal?.aborted} ts=${_acqStart}\n`);
836
- }
837
- if (externalSignal?.aborted) {
838
- const reason = externalSignal.reason;
839
- throw reason instanceof Error ? reason : new Error('OpenAI OAuth WS acquire aborted');
840
- }
841
- if (poolKey && !forceFresh) {
842
- const arr = _wsPool.get(poolKey) || [];
843
- // Prune dead entries first.
844
- for (let i = arr.length - 1; i >= 0; i--) {
845
- if (!_isOpen(arr[i]) || arr[i].closing) {
846
- _clearIdle(arr[i]);
847
- arr.splice(i, 1);
848
- }
849
- }
850
- if (arr.length === 0) _wsPool.delete(poolKey);
851
- // Reuse any idle open entry (cache-warm path).
852
- const idle = arr.find(e => !e.busy);
853
- if (idle) {
854
- _clearIdle(idle);
855
- idle.busy = true;
856
- // Defensive: pre-existing pooled entries created before the
857
- // prefix-hash field was introduced may not have it set. Normalize
858
- // to null so the first delta check reads a deterministic value
859
- // (and falls back to full-create instead of silently passing).
860
- if (idle.lastInputPrefixHash === undefined) idle.lastInputPrefixHash = null;
861
- if (idle.lastRequestInput === undefined) idle.lastRequestInput = null;
862
- if (idle.lastResponseItems === undefined) idle.lastResponseItems = null;
863
- if (process.env.MIXDOG_DEBUG_AGENT) {
864
- process.stderr.write(`[agent-trace] acquire-reuse poolKey=${poolKey} openSockets=${arr.length} elapsed=${Date.now() - _acqStart}ms\n`);
865
- }
866
- return { entry: idle, reused: true };
867
- }
868
- // All entries busy and bucket at cap: fall through to ephemeral socket.
869
- if (arr.length >= MAX_POOLED_SOCKETS_PER_KEY) {
870
- if (process.env.MIXDOG_DEBUG_AGENT) {
871
- process.stderr.write(`[agent-trace] acquire-ephemeral cacheKey=${cacheKey} reason=cap elapsed=${Date.now() - _acqStart}ms\n`);
872
- }
873
- const ephSessionToken = _mintSessionToken(cacheKey, auth);
874
- const { socket, turnState } = await _openSocket({ auth, sessionToken: ephSessionToken, turnState: null, externalSignal, cacheKey });
875
- // Drain-complete fence: same invariant as the normal acquire path —
876
- // if drain fired during the await, do NOT push an ephemeral entry
877
- // back into the pool.
878
- if (_drainComplete) {
879
- try { socket.close(1000, 'drain-complete'); } catch {}
880
- throw new Error('WS pool drained — process exiting');
881
- }
882
- const entry = {
883
- socket,
884
- busy: true,
885
- idleTimer: null,
886
- lastResponseId: null,
887
- lastRequestSansInput: null,
888
- lastRequestInput: null,
889
- lastResponseItems: null,
890
- lastInputLen: 0,
891
- lastInputPrefixHash: null,
892
- turnState: turnState || null,
893
- closing: false,
894
- ephemeral: true,
895
- sessionToken: ephSessionToken,
896
- };
897
- socket.on('close', () => { entry.closing = true; });
898
- return { entry, reused: false };
899
- }
900
- }
901
- // Parallel sockets must not inherit sibling turnState or the openai-oauth server
902
- // treats the new request as a continuation of another in-flight turn and
903
- // returns "No tool output found for function call …". turnState only
904
- // propagates within a single entry across its own iterations.
905
- const sessionToken = _mintSessionToken(cacheKey, auth);
906
- if (process.env.MIXDOG_DEBUG_AGENT) {
907
- process.stderr.write(`[agent-trace] acquire-new tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _acqStart}ms\n`);
908
- }
909
- const { socket, turnState } = await _openSocket({ auth, sessionToken, turnState: null, externalSignal, cacheKey });
910
- const entry = {
911
- socket,
912
- busy: true,
913
- idleTimer: null,
914
- lastResponseId: null,
915
- lastRequestSansInput: null,
916
- lastRequestInput: null,
917
- lastResponseItems: null,
918
- lastInputLen: 0,
919
- lastInputPrefixHash: null,
920
- turnState: turnState || null,
921
- closing: false,
922
- ephemeral: false,
923
- sessionToken,
924
- };
925
- if (poolKey && !forceFresh) _getPoolArr(poolKey).push(entry);
926
- socket.on('close', () => {
927
- entry.closing = true;
928
- _removeFromPool(poolKey, entry);
929
- });
930
- return { entry, reused: false };
931
- }
932
-
933
- function releaseWebSocket({ entry, poolKey, keep }) {
934
- if (!entry) return;
935
- entry.busy = false;
936
- if (!keep || !_isOpen(entry) || !poolKey || entry.ephemeral) {
937
- try { entry.socket.close(1000, keep ? 'no_session' : 'release_no_keep'); } catch {}
938
- _removeFromPool(poolKey, entry);
939
- return;
940
- }
941
- _scheduleIdleClose(poolKey, entry);
942
- }
943
-
944
- // If the cached request (sans input) matches the current one and the current
945
- // input starts with the cached input, return only the tail. Otherwise return
946
- // the full input (fresh turn).
947
- function _sansInput(body) {
948
- const { input: _ignored, previous_response_id: _prevIgnored, ...rest } = body;
949
- return rest;
950
- }
951
-
952
- function _stableStringify(obj) {
953
- // Shallow stable-ish: JSON.stringify with sorted top-level keys. Nested
954
- // arrays (tools, include) are order-sensitive and reflect intent, so we
955
- // do not sort them.
956
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return JSON.stringify(obj);
957
- const keys = Object.keys(obj).sort();
958
- const parts = [];
959
- for (const k of keys) parts.push(JSON.stringify(k) + ':' + _stableStringify(obj[k]));
960
- return '{' + parts.join(',') + '}';
961
- }
962
-
963
- function _cloneJson(value) {
964
- if (value == null) return value;
965
- try { return JSON.parse(JSON.stringify(value)); } catch { return value; }
966
- }
967
-
968
- function _responseItemKey(item, fallbackIndex = 0) {
969
- if (!item || typeof item !== 'object') return `primitive:${fallbackIndex}`;
970
- if (item.id) return `${item.type || 'item'}:id:${item.id}`;
971
- if (item.call_id) return `${item.type || 'item'}:call:${item.call_id}`;
972
- try { return `${item.type || 'item'}:json:${_stableStringify(item)}`; } catch {}
973
- return `${item.type || 'item'}:${fallbackIndex}`;
974
- }
975
-
976
- function _normalizeArguments(value) {
977
- if (value == null) return '';
978
- if (typeof value === 'string') {
979
- const trimmed = value.trim();
980
- try { return _stableStringify(JSON.parse(trimmed || '{}')); } catch { return trimmed; }
981
- }
982
- return _stableStringify(value);
983
- }
984
-
985
- function _normalizeContentPart(part) {
986
- if (!part || typeof part !== 'object') return part;
987
- const type = part.type === 'input_text' ? 'output_text' : part.type;
988
- if (type === 'output_text') return { type, text: part.text || '' };
989
- return part;
990
- }
991
-
992
- function _contentPartsEqual(a, b) {
993
- const aa = Array.isArray(a) ? a.map(_normalizeContentPart) : [];
994
- const bb = Array.isArray(b) ? b.map(_normalizeContentPart) : [];
995
- return _stableStringify(aa) === _stableStringify(bb);
996
- }
997
-
998
- export function _logicalResponseItemMatch(inputItem, responseItem) {
999
- if (!inputItem || !responseItem) return false;
1000
- const inputType = inputItem.type || (inputItem.role === 'assistant' ? 'message' : '');
1001
- const responseType = responseItem.type || '';
1002
- if (responseType === 'function_call') {
1003
- if (inputType !== 'function_call') return false;
1004
- const inputCallId = String(inputItem.call_id || '');
1005
- const responseCallId = String(responseItem.call_id || '');
1006
- const inputName = String(inputItem.name || '');
1007
- const responseName = String(responseItem.name || '');
1008
- if (inputCallId && responseCallId) {
1009
- // call_id is the server-side anchor. The replayed history may carry
1010
- // locally compacted arguments, but previous_response_id already
1011
- // points at the canonical output item.
1012
- return inputCallId === responseCallId && inputName === responseName;
1013
- }
1014
- return inputName === responseName
1015
- && _normalizeArguments(inputItem.arguments) === _normalizeArguments(responseItem.arguments);
1016
- }
1017
- if (responseType === 'tool_search_call') {
1018
- if (inputType !== 'tool_search_call') return false;
1019
- const inputCallId = String(inputItem.call_id || '');
1020
- const responseCallId = String(responseItem.call_id || '');
1021
- if (inputCallId && responseCallId) return inputCallId === responseCallId;
1022
- return _normalizeArguments(inputItem.arguments) === _normalizeArguments(responseItem.arguments);
1023
- }
1024
- if (responseType === 'custom_tool_call') {
1025
- if (inputType !== 'custom_tool_call') return false;
1026
- const inputCallId = String(inputItem.call_id || '');
1027
- const responseCallId = String(responseItem.call_id || '');
1028
- const inputName = String(inputItem.name || '');
1029
- const responseName = String(responseItem.name || '');
1030
- if (inputCallId && responseCallId) return inputCallId === responseCallId && inputName === responseName;
1031
- return inputName === responseName && String(inputItem.input || '') === String(responseItem.input || '');
1032
- }
1033
- if (responseType === 'message') {
1034
- const inputRole = inputItem.role || (inputType === 'message' ? 'assistant' : '');
1035
- const responseRole = responseItem.role || 'assistant';
1036
- return inputType === 'message'
1037
- && inputRole === responseRole
1038
- && _contentPartsEqual(inputItem.content, responseItem.content);
1039
- }
1040
- if (responseType === 'reasoning') {
1041
- return inputType === 'reasoning'
1042
- && (!!responseItem.id ? inputItem.id === responseItem.id : true)
1043
- && (!!responseItem.encrypted_content
1044
- ? inputItem.encrypted_content === responseItem.encrypted_content
1045
- : true);
1046
- }
1047
- if (responseType === 'web_search_call') {
1048
- return inputType === 'web_search_call'
1049
- && (!!responseItem.id ? inputItem.id === responseItem.id : true)
1050
- && _stableStringify(inputItem.action || null) === _stableStringify(responseItem.action || null);
1051
- }
1052
- if (inputType !== responseType) return false;
1053
- const stripVolatile = (item) => {
1054
- if (!item || typeof item !== 'object') return item;
1055
- const { id: _id, status: _status, ...rest } = item;
1056
- return rest;
1057
- };
1058
- return _stableStringify(stripVolatile(inputItem)) === _stableStringify(stripVolatile(responseItem));
1059
- }
1060
107
 
1061
- function _requestInputItemsMatch(a, b) {
1062
- return _stableStringify(a) === _stableStringify(b);
1063
- }
1064
-
1065
- function _stripRequestPrefix(curInput, prevInput) {
1066
- const current = Array.isArray(curInput) ? curInput : [];
1067
- const previous = Array.isArray(prevInput) ? prevInput : [];
1068
- if (current.length < previous.length) return null;
1069
- for (let i = 0; i < previous.length; i += 1) {
1070
- if (!_requestInputItemsMatch(current[i], previous[i])) return null;
1071
- }
1072
- return current.slice(previous.length);
1073
- }
1074
-
1075
- function _isReplayLikeHead(item, responseItem) {
1076
- if (!item || !responseItem) return false;
1077
- const inputType = item.type || (item.role === 'assistant' ? 'message' : '');
1078
- const responseType = responseItem.type || '';
1079
- if (responseType === 'message') return inputType === 'message';
1080
- if (responseType === 'function_call') return inputType === 'function_call';
1081
- if (responseType === 'tool_search_call') return inputType === 'tool_search_call';
1082
- return inputType === responseType;
1083
- }
1084
-
1085
- function _stripResponseItemsFromHead(items, responseItems) {
1086
- const tail = Array.isArray(items) ? items : [];
1087
- const outputs = Array.isArray(responseItems) ? responseItems : [];
1088
- let cursor = 0;
1089
- let stripped = 0;
1090
- let skipped = 0;
1091
- for (const output of outputs) {
1092
- if (cursor >= tail.length) break;
1093
- if (_logicalResponseItemMatch(tail[cursor], output)) {
1094
- cursor += 1;
1095
- stripped += 1;
1096
- continue;
1097
- }
1098
- if (_isReplayLikeHead(tail[cursor], output)) {
1099
- return {
1100
- ok: false,
1101
- reason: `response_output_mismatch:${output?.type || 'unknown'}`,
1102
- tail,
1103
- stripped,
1104
- skipped,
1105
- };
1106
- }
1107
- skipped += 1;
1108
- }
1109
- return { ok: true, reason: null, tail: tail.slice(cursor), stripped, skipped };
1110
- }
1111
-
1112
- function _computeDelta({ entry, body }) {
1113
- if (!entry || !entry.lastRequestSansInput || !entry.lastResponseId) {
1114
- return { mode: 'full', reason: 'no_anchor', frame: { type: 'response.create', ...body } };
1115
- }
1116
- if (!Array.isArray(entry.lastRequestInput)) {
1117
- return { mode: 'full', reason: 'no_input_snapshot', frame: { type: 'response.create', ...body } };
1118
- }
1119
- const curSans = _stableStringify(_sansInput(body));
1120
- if (curSans !== entry.lastRequestSansInput) {
1121
- return { mode: 'full', reason: 'request_properties_changed', frame: { type: 'response.create', ...body } };
1122
- }
1123
- const curInput = Array.isArray(body.input) ? body.input : [];
1124
- const afterPreviousInput = _stripRequestPrefix(curInput, entry.lastRequestInput);
1125
- if (!afterPreviousInput) {
1126
- return { mode: 'full', reason: 'input_prefix_mismatch', frame: { type: 'response.create', ...body } };
1127
- }
1128
- const stripped = _stripResponseItemsFromHead(afterPreviousInput, entry.lastResponseItems);
1129
- if (!stripped.ok) {
1130
- return { mode: 'full', reason: stripped.reason, frame: { type: 'response.create', ...body } };
1131
- }
1132
- return {
1133
- mode: 'delta',
1134
- reason: null,
1135
- strippedResponseItems: stripped.stripped,
1136
- skippedResponseItems: stripped.skipped,
1137
- frame: {
1138
- ...body,
1139
- type: 'response.create',
1140
- previous_response_id: entry.lastResponseId,
1141
- input: stripped.tail,
1142
- },
1143
- };
1144
- }
1145
-
1146
- function _estimateFrameTokens(frame) {
1147
- try {
1148
- const s = JSON.stringify(frame);
1149
- return Math.ceil(s.length / 4);
1150
- } catch { return 0; }
1151
- }
1152
-
1153
- function _usageNum(value) {
1154
- const n = Number(value || 0);
1155
- return Number.isFinite(n) ? n : 0;
1156
- }
1157
-
1158
- function _combineUsageWithWarmup(actual, warmup) {
1159
- if (!warmup) return actual;
1160
- if (!actual) return warmup;
1161
- const actualRaw = actual.raw || {};
1162
- const warmupRaw = warmup.raw || {};
1163
- const actualTicks = _usageNum(actualRaw.cost_in_usd_ticks);
1164
- const warmupTicks = _usageNum(warmupRaw.cost_in_usd_ticks);
1165
- return {
1166
- ...actual,
1167
- inputTokens: _usageNum(actual.inputTokens) + _usageNum(warmup.inputTokens),
1168
- outputTokens: _usageNum(actual.outputTokens) + _usageNum(warmup.outputTokens),
1169
- cachedTokens: _usageNum(actual.cachedTokens) + _usageNum(warmup.cachedTokens),
1170
- promptTokens: _usageNum(actual.promptTokens) + _usageNum(warmup.promptTokens),
1171
- warmupInputTokens: _usageNum(warmup.inputTokens),
1172
- warmupCachedTokens: _usageNum(warmup.cachedTokens),
1173
- warmupOutputTokens: _usageNum(warmup.outputTokens),
1174
- raw: {
1175
- ...actualRaw,
1176
- warmup_usage: warmupRaw,
1177
- ...(actualTicks || warmupTicks ? { cost_in_usd_ticks: actualTicks + warmupTicks } : {}),
1178
- },
1179
- };
1180
- }
1181
-
1182
- function _parseEvent(raw) {
1183
- try { return JSON.parse(raw); } catch { return null; }
1184
- }
1185
-
1186
- function _incompleteReasonFromEvent(event) {
1187
- const reasonObj = event?.response?.incomplete_details
1188
- || event?.incomplete_details
1189
- || event?.response?.status_details
1190
- || null;
1191
- return String(reasonObj?.reason || event?.response?.status || 'incomplete');
1192
- }
1193
-
1194
- function _isMaxOutputIncompleteReason(reason) {
1195
- return /^(?:max_output_tokens|max_tokens|length|output_token_limit)$/i.test(String(reason || '').trim());
1196
- }
1197
-
1198
- function _httpStatusFromWsClose(code, reason) {
1199
- const n = Number(code || 0);
1200
- const r = String(reason || '').toLowerCase();
1201
- if (n === 4401
1202
- || /\b(?:unauthorized|unauthorised|authentication|auth(?:enticated?)?|not authenticated|token expired|access token)\b/.test(r)) {
1203
- return 401;
1204
- }
1205
- if (n === 4403 || /\b(?:forbidden|policy|permission denied)\b/.test(r)) return 403;
1206
- if (n === 4429 || /\b(?:rate limit|quota)\b/.test(r)) return 429;
1207
- return 0;
1208
- }
1209
-
1210
- function _wsErrLabel(p) {
1211
- if (p === 'xai') return 'xAI WS';
1212
- if (p === 'openai-direct' || p === 'openai') return 'OpenAI WS';
1213
- return 'OpenAI OAuth WS';
1214
- }
1215
- // tool_search_call.arguments parse. Module-scope (exported) for direct test
1216
- // coverage. Same policy as the function_call_arguments.done path and
1217
- // openai-oauth _parseJsonObject —
1218
- // object passes through; null/non-string/empty/whitespace → {} (no args); a
1219
- // non-empty string that fails JSON.parse is deterministic bad JSON, surfaced
1220
- // as an invalid-args MARKER (not silently swallowed to {}) so the dispatch
1221
- // loop returns an is_error tool_result and the model self-corrects in the same
1222
- // turn.
1223
- export function parseToolSearchArgs(value) {
1224
- if (value && typeof value === 'object') {
1225
- // Reject arrays — the tool_search schema is an object
1226
- // ({query,select,limit}); an array must never pass through as args.
1227
- return Array.isArray(value) ? {} : value;
1228
- }
1229
- if (typeof value !== 'string' || !value.trim()) return {};
1230
- try {
1231
- const parsed = JSON.parse(value);
1232
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
1233
- } catch (err) {
1234
- return makeInvalidToolArgsMarker(value, err instanceof Error ? err.message : String(err));
1235
- }
1236
- }
1237
- export async function _streamResponse({
1238
- entry,
1239
- externalSignal,
1240
- onStreamDelta,
1241
- onToolCall,
1242
- onTextDelta,
1243
- state,
1244
- logSuppressedReasoningDeltas = true,
1245
- traceProvider = 'openai-oauth',
1246
- _timeouts = null,
1247
- knownToolNames = null,
1248
- }) {
1249
- const errLabel = _wsErrLabel(traceProvider);
1250
- const socket = entry.socket;
1251
- const preResponseCreatedMs = _positiveInt(_timeouts?.preResponseCreatedMs, WS_PRE_RESPONSE_CREATED_MS);
1252
- const interChunkMs = _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS);
1253
- const _streamingStart = Date.now();
1254
- let _firstDeltaEmitted = false;
1255
- let content = '';
1256
- let model = '';
1257
- let responseId = '';
1258
- let responseServiceTier = '';
1259
- const toolCalls = [];
1260
- const webSearchCalls = [];
1261
- const webSearchCallKeys = new Set();
1262
- const responseItemsAdded = [];
1263
- const responseItemKeys = new Set();
1264
- const citations = [];
1265
- const citationKeys = new Set();
1266
- const pendingCalls = new Map();
1267
- // Tool-work-in-flight flag: set the moment a function/custom tool call's
1268
- // input starts streaming (before it lands in pendingCalls/toolCalls).
1269
- // Gates partial-final SUCCESS so a stall mid tool-input never looks text-only.
1270
- let _toolInFlight = false;
1271
- // Fix 2: cross-path name+args dedupe. A text-leaked synthetic and an
1272
- // identical native function_call must fire onToolCall exactly once. Every
1273
- // dispatch site routes through emitToolCallDedupe.
1274
- const _toolDedupe = createToolCallDedupe();
1275
- const emitToolCallDedupe = (call) => {
1276
- if (!_toolDedupe.shouldDispatch(call?.name, call?.arguments)) return;
1277
- midState.emittedToolCall = true;
1278
- try { onToolCall?.(call); } catch {}
1279
- };
1280
- // Reasoning items collected from response.output_item.done (or salvaged
1281
- // from response.completed.response.output). The request still includes
1282
- // `reasoning.encrypted_content` so the server keeps emitting the blobs,
1283
- // but explicit input-side replay is INTENTIONALLY OMITTED in
1284
- // convertMessagesToResponsesInput (openai-oauth.mjs:233-238) — openai-oauth
1285
- // rejects the same `rs_*` id twice in one handshake session_id with a
1286
- // "Duplicate item" error. Server-side conversation state already carries
1287
- // the prefix forward across the WS_IDLE_MS window. The collected
1288
- // reasoningItems below are surfaced for trace/debugging only; they do
1289
- // not feed back into the next request body.
1290
- const reasoningItems = [];
1291
- let reasoningTextDeltaCount = 0;
1292
- let reasoningSummaryTextDeltaCount = 0;
1293
- let reasoningOtherDeltaCount = 0;
1294
- let reasoningDeltaLogEmitted = false;
1295
- const pushReasoningItem = (item) => {
1296
- if (!item || item.type !== 'reasoning') return;
1297
- if (!item.encrypted_content) return;
1298
- reasoningItems.push({
1299
- id: item.id || '',
1300
- encrypted_content: item.encrypted_content,
1301
- summary: Array.isArray(item.summary) ? item.summary : [],
1302
- });
1303
- };
1304
- const pushResponseItem = (item) => {
1305
- if (!item || typeof item !== 'object') return;
1306
- const key = _responseItemKey(item, responseItemsAdded.length);
1307
- if (responseItemKeys.has(key)) {
1308
- const existing = responseItemsAdded.find((candidate, index) => _responseItemKey(candidate, index) === key);
1309
- if (existing?.type === 'function_call' && item.type === 'function_call') {
1310
- if (!existing.call_id && item.call_id) existing.call_id = item.call_id;
1311
- if (!existing.name && item.name) existing.name = item.name;
1312
- if ((existing.arguments == null || existing.arguments === '') && item.arguments != null) existing.arguments = item.arguments;
1313
- }
1314
- return;
1315
- }
1316
- responseItemKeys.add(key);
1317
- responseItemsAdded.push(_cloneJson(item));
1318
- };
1319
- const enrichFunctionCallResponseItem = ({ itemId = '', callId = '', name = '', argumentsText = '' } = {}) => {
1320
- for (const item of responseItemsAdded) {
1321
- if (item?.type !== 'function_call') continue;
1322
- if (itemId && item.id && item.id !== itemId) continue;
1323
- if (callId && item.call_id && item.call_id !== callId) continue;
1324
- if (!item.call_id && callId) item.call_id = callId;
1325
- if (!item.name && name) item.name = name;
1326
- if ((item.arguments == null || item.arguments === '') && argumentsText) item.arguments = argumentsText;
1327
- }
1328
- };
1329
- const pushCitation = (raw, fallbackTitle = '') => {
1330
- const url = raw?.url || raw?.uri || raw?.href || '';
1331
- if (!url || citationKeys.has(url)) return;
1332
- citationKeys.add(url);
1333
- citations.push({
1334
- title: raw?.title || fallbackTitle || '',
1335
- url,
1336
- snippet: raw?.snippet || raw?.text || raw?.description || '',
1337
- source: 'openai-oauth',
1338
- });
1339
- };
1340
- const pushOutputTextAnnotations = (contentPart) => {
1341
- const annotations = Array.isArray(contentPart?.annotations) ? contentPart.annotations : [];
1342
- for (const annotation of annotations) pushCitation(annotation);
1343
- };
1344
- const pushWebSearchCall = (item) => {
1345
- if (!item || item.type !== 'web_search_call') return;
1346
- let key = item.id || '';
1347
- if (!key) {
1348
- try { key = JSON.stringify(item.action || item); } catch { key = `${webSearchCalls.length}`; }
1349
- }
1350
- if (webSearchCallKeys.has(key)) return;
1351
- webSearchCallKeys.add(key);
1352
- webSearchCalls.push({
1353
- id: item.id || '',
1354
- status: item.status || '',
1355
- action: item.action || null,
1356
- });
1357
- const action = item.action || {};
1358
- if (action.url) pushCitation({ url: action.url, title: action.query || '' });
1359
- if (Array.isArray(action.urls)) {
1360
- for (const url of action.urls) pushCitation({ url, title: action.query || '' });
1361
- }
1362
- };
1363
- const pushCustomToolCall = (item) => {
1364
- const call = customToolCallFromResponseItem(item);
1365
- if (!call || toolCalls.some((existing) => existing.id === call.id)) return;
1366
- toolCalls.push(call);
1367
- emitToolCallDedupe(call);
1368
- };
1369
- const pushToolSearchCall = (item) => {
1370
- if (!item || item.type !== 'tool_search_call') return;
1371
- const callId = item.call_id || item.id || '';
1372
- if (!callId || toolCalls.some((call) => call.id === callId)) return;
1373
- const call = {
1374
- id: callId,
1375
- name: 'tool_search',
1376
- arguments: parseToolSearchArgs(item.arguments),
1377
- nativeType: 'tool_search_call',
1378
- };
1379
- toolCalls.push(call);
1380
- emitToolCallDedupe(call);
1381
- };
1382
- // Leaked tool-call guard. The model sometimes emits a tool call as plain
1383
- // text (XML `<invoke>`/`<function_calls>` or gpt-oss harmony
1384
- // `<|channel|>...to=functions.NAME...<|call|>`) inside
1385
- // `response.output_text.delta` instead of a native function_call. Route
1386
- // text through the guard so leaked calls are suppressed from the visible
1387
- // stream, synthesized (native `call_...` id shape), and dispatched like
1388
- // native ones. Additive: the native function_call path is untouched.
1389
- const leakGuard = createLeakGuard({ knownToolNames, harmony: true });
1390
- const dispatchLeakedCall = (recovered) => {
1391
- let args = recovered?.arguments;
1392
- if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
1393
- const call = {
1394
- id: `call_leaked_${randomBytes(8).toString('hex')}`,
1395
- name: recovered.name,
1396
- arguments: args,
1397
- };
1398
- if (!_toolDedupe.shouldDispatch(call.name, call.arguments)) return;
1399
- toolCalls.push(call);
1400
- midState.emittedToolCall = true;
1401
- try { onToolCall?.(call); } catch {}
1402
- };
1403
- const relayLeakText = (delta) => {
1404
- if (!leakGuard.enabled) {
1405
- content += delta || '';
1406
- if (delta && onTextDelta) {
1407
- if (state) state.emittedText = true;
1408
- try { onTextDelta(delta); } catch {}
1409
- }
1410
- return;
1411
- }
1412
- const { text, calls } = leakGuard.push(delta);
1413
- if (text) {
1414
- content += text;
1415
- if (onTextDelta) {
1416
- if (state) state.emittedText = true;
1417
- try { onTextDelta(text); } catch {}
1418
- }
1419
- }
1420
- for (const c of calls) dispatchLeakedCall(c);
1421
- };
1422
- const flushLeak = () => {
1423
- if (!leakGuard.enabled) return;
1424
- const { text, calls } = leakGuard.flush();
1425
- if (text) {
1426
- content += text;
1427
- if (onTextDelta) {
1428
- if (state) state.emittedText = true;
1429
- try { onTextDelta(text); } catch {}
1430
- }
1431
- }
1432
- for (const c of calls) dispatchLeakedCall(c);
1433
- };
1434
- const logReasoningDeltaSuppression = () => {
1435
- if (!logSuppressedReasoningDeltas) return;
1436
- const total = reasoningTextDeltaCount + reasoningSummaryTextDeltaCount + reasoningOtherDeltaCount;
1437
- if (reasoningDeltaLogEmitted || total === 0) return;
1438
- reasoningDeltaLogEmitted = true;
1439
- process.stderr.write(`[openai-oauth-ws] suppressed reasoning text deltas from user content count=${total} text=${reasoningTextDeltaCount} summary=${reasoningSummaryTextDeltaCount} other=${reasoningOtherDeltaCount}\n`);
1440
- };
1441
- let usage;
1442
- let stopReason = null;
1443
- let incompleteReason = null;
1444
- let done = false;
1445
- let terminalError = null;
1446
- // Mid-stream retry classifier needs to distinguish "stream died before we
1447
- // even saw response.created" from "stream died after we had a partial
1448
- // response but before completion". Mutate the shared state object so the
1449
- // caller can inspect flags on the error path without us having to attach
1450
- // them manually at every reject site.
1451
- const midState = state || {};
1452
- midState.sawResponseCreated = midState.sawResponseCreated || false;
1453
- midState.sawCompleted = midState.sawCompleted || false;
1454
- midState.wsCloseCode = null;
1455
- midState.responseFailedPayload = null;
1456
- let idleTimer = null;
1457
- let keepaliveTimer = null;
1458
- let abortHandler = null;
1459
- let messageHandler = null;
1460
- let closeHandler = null;
1461
- let errorHandler = null;
1462
- // SEMANTIC idle timer: distinct from the inter-chunk
1463
- // timer, which resets on EVERY frame (rate_limits/metadata/keepalive keep
1464
- // the socket "alive"). This timer resets ONLY on meaningful output deltas
1465
- // (text/reasoning/tool args — the same events that call onStreamDelta) so a
1466
- // stream that emits some deltas then goes silent (server keepalive frames
1467
- // only) trips a short, named terminal StreamStalledError instead of coasting
1468
- // to the 300s inter-chunk cap / 30-min agent watchdog.
1469
- let semanticIdleTimer = null;
1470
- const semanticIdleMs = PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS;
1471
- const semanticIdleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED && semanticIdleMs > 0;
1472
-
1473
- return new Promise((resolve, reject) => {
1474
- // Pre-stream watchdog: the timer fires if the server never sends a
1475
- // first event (response.created) within preResponseCreatedMs
1476
- // after our last frame. The socket is open and the response.create
1477
- // frame was sent, but no server event has come back — a wedged
1478
- // post-upgrade socket. Healthy servers ack within seconds, so this
1479
- // window is intentionally short (WS_PRE_RESPONSE_CREATED_MS, ~10s).
1480
- // Once ANY server event arrives, resetIdle() cancels this watchdog and
1481
- // the single inter-chunk idle timer takes over — silent gaps
1482
- // mid-reasoning (openai-oauth spending 50s+ producing reasoning
1483
- // tokens) are normal and should not abort the turn.
1484
- const armPreStreamWatchdog = () => {
1485
- if (idleTimer) clearTimeout(idleTimer);
1486
- idleTimer = setTimeout(() => {
1487
- if (process.env.MIXDOG_DEBUG_AGENT) {
1488
- process.stderr.write(`[agent-trace] ws-timeout kind=first-byte afterMs=${preResponseCreatedMs}\n`);
1489
- }
1490
- traceWsTimeout('first_byte_timeout', preResponseCreatedMs);
1491
- const err = new Error(`WS stream: no first server event within ${preResponseCreatedMs}ms`);
1492
- // Tag the close code so _classifyMidstreamError sees a 4000
1493
- // (our local pre-stream watchdog code) and routes through
1494
- // the post-upgrade-no-first-event retryable bucket.
1495
- err.wsCloseCode = 4000;
1496
- // Tag the error object itself (not just midState): the warmup
1497
- // path streams under a separate warmupState and rethrows on
1498
- // timeout BEFORE it can copy flags to the outer midState, so the
1499
- // outer catch's _classifyMidstreamError would otherwise see
1500
- // sawResponseCreated=false + close 4000 and hit the pre-created
1501
- // deny gate. err.firstByteTimeout makes both paths retryable.
1502
- err.firstByteTimeout = true;
1503
- midState.firstByteTimeout = true;
1504
- terminalError = err;
1505
- try { socket.close(4000, 'first_byte_timeout'); } catch {}
1506
- // socket.close() may not settle a half-open WS (closeHandler never
1507
- // fires) — reject directly so the turn retries instead of hanging
1508
- // until the 600s watchdog. finish() is idempotent (Promise settles
1509
- // once; cleanup is null-safe).
1510
- finish();
1511
- }, preResponseCreatedMs);
1512
- };
1513
- let interChunkTimer = null;
1514
- const traceWsTimeout = (event, timeoutMs) => {
1515
- try {
1516
- const iteration = Number(midState.iteration);
1517
- const attemptIndex = Number(midState.attemptIndex);
1518
- const payload = {
1519
- provider: midState.traceProvider || traceProvider,
1520
- transport: 'websocket',
1521
- event,
1522
- timeout_ms: timeoutMs,
1523
- elapsed_ms: Date.now() - _streamingStart,
1524
- model: midState.model || model || null,
1525
- attempt_index: Number.isFinite(attemptIndex) ? attemptIndex : null,
1526
- warmup: midState.warmup === true,
1527
- saw_response_created: midState.sawResponseCreated === true,
1528
- };
1529
- appendAgentTrace({
1530
- sessionId: midState.sessionId || null,
1531
- iteration: Number.isFinite(iteration) ? iteration : null,
1532
- kind: 'ws_timeout',
1533
- ...payload,
1534
- payload,
1535
- });
1536
- } catch {}
1537
- };
1538
- const clearPreStreamWatchdog = () => {
1539
- if (idleTimer) {
1540
- clearTimeout(idleTimer);
1541
- idleTimer = null;
1542
- }
1543
- };
1544
- const resetInterChunk = () => {
1545
- if (interChunkTimer) clearTimeout(interChunkTimer);
1546
- interChunkTimer = setTimeout(() => {
1547
- if (process.env.MIXDOG_DEBUG_AGENT) {
1548
- process.stderr.write(`[agent-trace] ws-timeout kind=inter-chunk afterMs=${interChunkMs}\n`);
1549
- }
1550
- traceWsTimeout('inter_chunk_timeout', interChunkMs);
1551
- terminalError = new Error(`WS stream: inter-chunk inactivity for ${interChunkMs}ms`);
1552
- try { socket.close(4000, 'inter_chunk_timeout'); } catch {}
1553
- // Same half-open guard as the pre-stream watchdog: reject directly
1554
- // so a stuck socket.close() cannot leave the Promise pending.
1555
- finish();
1556
- }, interChunkMs);
1557
- };
1558
- // pi per-event idle: (re)armed only on meaningful output deltas via
1559
- // bumpSemanticIdle(). Keepalive/metadata frames DON'T touch it, so a
1560
- // deltas-then-silent wedge trips this short semantic window.
1561
- const resetSemanticIdle = () => {
1562
- if (!semanticIdleEnabled) return;
1563
- if (semanticIdleTimer) clearTimeout(semanticIdleTimer);
1564
- semanticIdleTimer = setTimeout(() => {
1565
- traceWsTimeout('semantic_idle_timeout', semanticIdleMs);
1566
- terminalError = streamStalledError('Responses WS', semanticIdleMs, { emittedToolCall: !!midState?.emittedToolCall });
1567
- // Partial-final recovery: attach streamed partial state so
1568
- // a wedged FINAL no-tool summary can be accepted as partial-final
1569
- // success by the loop. pendingToolUse gates out mid-flight tools.
1570
- try {
1571
- terminalError.partialContent = content;
1572
- terminalError.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
1573
- terminalError.pendingToolUse = pendingCalls.size > 0
1574
- || !!midState?.emittedToolCall
1575
- || toolCalls.length > 0
1576
- || _toolInFlight === true;
1577
- terminalError.partialModel = model || undefined;
1578
- } catch { /* best-effort enrichment */ }
1579
- try { terminalError.wsCloseCode = 4000; } catch {}
1580
- try { socket.close(4000, 'semantic_idle_timeout'); } catch {}
1581
- finish();
1582
- }, semanticIdleMs);
1583
- try { semanticIdleTimer.unref?.(); } catch {}
1584
- };
1585
- // Single idle reset — called on EVERY parsed server event (matches
1586
- // codex, which resets one idle timer on every received WS frame). Any
1587
- // frame proves the socket is live; there is no separate "meaningful
1588
- // output" gate. Also clears the pre-stream watchdog defensively in case
1589
- // the first event is not response.created.
1590
- const resetIdle = () => {
1591
- clearPreStreamWatchdog();
1592
- resetInterChunk();
1593
- };
1594
- // Meaningful-output progress bump: called by the same delta cases that
1595
- // call onStreamDelta (text/reasoning/tool args). Arms the semantic idle.
1596
- const bumpSemanticIdle = () => { resetSemanticIdle(); };
1597
- const cleanup = () => {
1598
- if (idleTimer) clearTimeout(idleTimer);
1599
- if (interChunkTimer) { clearTimeout(interChunkTimer); interChunkTimer = null; }
1600
- if (semanticIdleTimer) { clearTimeout(semanticIdleTimer); semanticIdleTimer = null; }
1601
- if (keepaliveTimer) { clearInterval(keepaliveTimer); keepaliveTimer = null; }
1602
- if (messageHandler) socket.off('message', messageHandler);
1603
- if (closeHandler) socket.off('close', closeHandler);
1604
- if (errorHandler) socket.off('error', errorHandler);
1605
- if (abortHandler && externalSignal) externalSignal.removeEventListener('abort', abortHandler);
1606
- };
1607
- const finish = () => {
1608
- logReasoningDeltaSuppression();
1609
- // Flush any partial-sentinel tail held back mid-stream so
1610
- // legitimate trailing text is never lost (streamed-text path).
1611
- flushLeak();
1612
- cleanup();
1613
- if (terminalError) { reject(terminalError); return; }
1614
- resolve({
1615
- content,
1616
- model,
1617
- reasoningItems: reasoningItems.length ? reasoningItems : undefined,
1618
- responseItems: responseItemsAdded.length ? responseItemsAdded : undefined,
1619
- // Dedupe by name+args (Fix 2, array side) so an identical
1620
- // synthetic-leaked + native pair can't run the tool twice.
1621
- toolCalls: toolCalls.length ? dedupeToolCallList(toolCalls) : undefined,
1622
- citations: citations.length ? citations : undefined,
1623
- webSearchCalls: webSearchCalls.length ? webSearchCalls : undefined,
1624
- usage,
1625
- stopReason: stopReason || undefined,
1626
- // P1 audit fix: mirror the HTTP/SSE fallback's truncated flag
1627
- // for the WS path (sendViaWebSocket spreads this result
1628
- // through to the provider caller unchanged).
1629
- ...(stopReason === 'length' && content.length > 0 ? { truncated: true } : {}),
1630
- incompleteReason: incompleteReason || undefined,
1631
- responseId: responseId || undefined,
1632
- serviceTier: responseServiceTier || undefined,
1633
- });
1634
- };
1635
-
1636
- messageHandler = (data) => {
1637
- resetIdle();
1638
- // resetIdle() above resets the SINGLE inter-chunk idle timer on
1639
- // EVERY received frame — response.created, metadata,
1640
- // rate_limits, and all deltas keep the socket alive. Separately, do
1641
- // NOT call onStreamDelta for every frame — metadata/keepalive frames
1642
- // must not reset the agent stall watchdog's lastStreamDeltaAt. Only
1643
- // meaningful output (text delta / tool call) updates that timestamp.
1644
- const text = typeof data === 'string' ? data : data.toString('utf-8');
1645
- const event = _parseEvent(text);
1646
- if (!event) return;
1647
- if (event.error) {
1648
- const err = new Error(event.error.message || 'Responses WS error');
1649
- try {
1650
- err.payload = event.error;
1651
- populateHttpStatusFromMessage(err);
1652
- } catch {}
1653
- terminalError = err;
1654
- finish();
1655
- return;
1656
- }
1657
- if (typeof event.type !== 'string') return;
1658
- switch (event.type) {
1659
- case 'response.created':
1660
- midState.sawResponseCreated = true;
1661
- if (event.response?.model) model = event.response.model;
1662
- if (event.response?.id) responseId = event.response.id;
1663
- // Server ack (first event). resetIdle() at the top of
1664
- // messageHandler already cleared the pre-stream watchdog and
1665
- // armed the single idle timer — no extra bookkeeping here.
1666
- break;
1667
- case 'response.output_text.delta':
1668
- try {
1669
- if (!_firstDeltaEmitted) {
1670
- _firstDeltaEmitted = true;
1671
- if (process.env.MIXDOG_DEBUG_AGENT) {
1672
- process.stderr.write(`[agent-trace] ws-first-delta sinceStreaming=${Date.now() - _streamingStart}ms\n`);
1673
- }
1674
- }
1675
- onStreamDelta?.();
1676
- } catch {}
1677
- bumpSemanticIdle();
1678
- // Live text relay (gateway): forward the raw text chunk so
1679
- // the client renders first tokens before the final replay.
1680
- // Tool-call/argument deltas intentionally stay off this path.
1681
- // Invariant: once a non-empty chunk has been relayed live it
1682
- // cannot be withdrawn, so flag the attempt so a later
1683
- // mid-stream/truncated failure is NOT retried (retry would
1684
- // concatenate a second attempt onto rendered text).
1685
- // Routed through the leaked-tool-call guard: appends to
1686
- // `content`, forwards visible text via onTextDelta, and
1687
- // recovers/dispatches any leaked known-tool call.
1688
- relayLeakText(event.delta || '');
1689
- break;
1690
- case 'response.reasoning_text.delta':
1691
- case 'response.reasoning_summary_text.delta':
1692
- if (event.type === 'response.reasoning_text.delta') reasoningTextDeltaCount += 1;
1693
- else reasoningSummaryTextDeltaCount += 1;
1694
- // Reasoning text is live model progress — refresh
1695
- // lastStreamDeltaAt so stream-watchdog does not flag a
1696
- // long reasoning span as a stall. The local WS idle timer
1697
- // was already reset by resetIdle() at the top of
1698
- // messageHandler. Reasoning is still suppressed from user
1699
- // content (no `content +=` here).
1700
- try { onStreamDelta?.(); } catch {}
1701
- bumpSemanticIdle();
1702
- break;
1703
- case 'response.output_item.added':
1704
- if (event.item?.type === 'function_call') {
1705
- pendingCalls.set(event.item.id || '', {
1706
- name: event.item.name || '',
1707
- callId: event.item.call_id || '',
1708
- });
1709
- _toolInFlight = true;
1710
- } else if (event.item?.type === 'custom_tool_call') {
1711
- _toolInFlight = true;
1712
- } else if (event.item?.type === 'tool_search_call') {
1713
- // Mark tool_search in-flight at item-added time, same
1714
- // as function_call/custom_tool_call above, so the
1715
- // semantic-idle stall gate's pendingToolUse never
1716
- // drops a mid-flight tool_search before
1717
- // response.output_item.done.
1718
- _toolInFlight = true;
1719
- }
1720
- break;
1721
- case 'response.function_call_arguments.delta':
1722
- _toolInFlight = true;
1723
- try { onStreamDelta?.(); } catch {}
1724
- bumpSemanticIdle();
1725
- break;
1726
- case 'response.custom_tool_call_input.delta':
1727
- _toolInFlight = true;
1728
- try { onStreamDelta?.(); } catch {}
1729
- bumpSemanticIdle();
1730
- break;
1731
- case 'response.function_call_arguments.done': {
1732
- const itemId = event.item_id || '';
1733
- const pending = pendingCalls.get(itemId);
1734
- // function_call_arguments.done is a completion signal:
1735
- // empty/whitespace → no args ({}); a non-empty string that
1736
- // fails JSON.parse is deterministic bad JSON. Surface an
1737
- // invalid-args MARKER (not silent {}) so the dispatch loop
1738
- // returns an is_error tool_result and the model re-issues
1739
- // valid JSON in the same turn.
1740
- let args = {};
1741
- {
1742
- const _argText = typeof event.arguments === 'string' ? event.arguments : '';
1743
- if (_argText.trim() !== '') {
1744
- try {
1745
- args = JSON.parse(_argText);
1746
- } catch (err) {
1747
- args = makeInvalidToolArgsMarker(_argText, err instanceof Error ? err.message : String(err));
1748
- }
1749
- }
1750
- }
1751
- enrichFunctionCallResponseItem({
1752
- itemId,
1753
- callId: pending?.callId || event.call_id || '',
1754
- name: pending?.name || event.name || '',
1755
- argumentsText: event.arguments || JSON.stringify(args),
1756
- });
1757
- if (pending?.callId && pending?.name) {
1758
- const call = { id: pending.callId, name: pending.name, arguments: args };
1759
- toolCalls.push(call);
1760
- emitToolCallDedupe(call);
1761
- } else {
1762
- // Synthesizing a `tc_${Date.now()}` callId here would
1763
- // make the next turn fail to match the model's
1764
- // function_call_output reference. Defer instead and
1765
- // salvage call_id/name from the final
1766
- // response.completed.output bundle below. If salvage
1767
- // also fails we fail the stream explicitly — masking
1768
- // the gap with a synthetic id just shifts the failure
1769
- // one turn later under a confusing "No tool output
1770
- // found for function call" error.
1771
- toolCalls.push({
1772
- id: pending?.callId || '',
1773
- name: pending?.name || '',
1774
- arguments: args,
1775
- _pendingItemId: itemId,
1776
- _deferred: true,
1777
- });
1778
- }
1779
- try { onStreamDelta?.(); } catch {}
1780
- bumpSemanticIdle();
1781
- break;
1782
- }
1783
- case 'response.output_item.done':
1784
- pushResponseItem(event.item);
1785
- // function_call / output_text already captured via their
1786
- // dedicated streaming events. The one shape we still need
1787
- // here is `reasoning` — carries encrypted_content that
1788
- // must be replayed on the next input to keep the openai-oauth
1789
- // server-side prompt cache prefix warm.
1790
- if (event.item?.type === 'reasoning') pushReasoningItem(event.item);
1791
- if (event.item?.type === 'web_search_call') pushWebSearchCall(event.item);
1792
- if (event.item?.type === 'tool_search_call') {
1793
- pushToolSearchCall(event.item);
1794
- }
1795
- if (event.item?.type === 'custom_tool_call') {
1796
- pushCustomToolCall(event.item);
1797
- }
1798
- break;
1799
- case 'response.completed': {
1800
- const completedServiceTier = event.response?.service_tier || event.response?.serviceTier || '';
1801
- if (completedServiceTier) responseServiceTier = String(completedServiceTier);
1802
- if (event.response?.usage) {
1803
- const u = event.response.usage;
1804
- const rawUsage = responseServiceTier
1805
- ? { ...u, service_tier: responseServiceTier }
1806
- : u;
1807
- usage = {
1808
- inputTokens: u.input_tokens || 0,
1809
- outputTokens: u.output_tokens || 0,
1810
- cachedTokens: extractCachedTokens(u),
1811
- // openai-oauth reports input_tokens as the total
1812
- // prompt volume (cached portion is a subset, not
1813
- // additive). Alias into the cross-provider
1814
- // `promptTokens` field so downstream loggers have
1815
- // uniform semantics.
1816
- promptTokens: u.input_tokens || 0,
1817
- raw: rawUsage,
1818
- };
1819
- }
1820
- if (!model && event.response?.model) model = event.response.model;
1821
- if (!responseId && event.response?.id) responseId = event.response.id;
1822
- if (event.response?.output) {
1823
- for (const item of event.response.output) {
1824
- pushResponseItem(item);
1825
- if (!content && item.type === 'message') {
1826
- for (const c of item.content || []) {
1827
- if (c.type === 'output_text') {
1828
- // Completed-output fallback (no streamed
1829
- // text). Route through the leak guard so
1830
- // a tool call leaked only in the final
1831
- // bundle is recovered, not surfaced as
1832
- // visible content. final=true → full flush.
1833
- if (leakGuard.enabled) {
1834
- const { text, calls } = leakGuard.push(c.text || '', true);
1835
- content += text;
1836
- for (const lc of calls) dispatchLeakedCall(lc);
1837
- } else {
1838
- content += c.text || '';
1839
- }
1840
- pushOutputTextAnnotations(c);
1841
- }
1842
- }
1843
- }
1844
- if (item.type === 'message') {
1845
- for (const c of item.content || []) {
1846
- if (c.type === 'output_text') pushOutputTextAnnotations(c);
1847
- }
1848
- }
1849
- if (item.type === 'web_search_call') pushWebSearchCall(item);
1850
- if (item.type === 'tool_search_call') pushToolSearchCall(item);
1851
- if (item.type === 'custom_tool_call') pushCustomToolCall(item);
1852
- // Salvage path: some streams emit reasoning only
1853
- // inside the final response.completed.output
1854
- // bundle (no per-item .done event). Dedup by id.
1855
- if (item.type === 'reasoning'
1856
- && !reasoningItems.some(r => r.id === item.id)) {
1857
- pushReasoningItem(item);
1858
- }
1859
- // Salvage path for function_call: when
1860
- // arguments.done fired before (or without) a
1861
- // matching output_item.added, the deferred tool
1862
- // call placeholder has empty id/name. The
1863
- // completed.output bundle carries the canonical
1864
- // call_id/name; fill them in and emit onToolCall.
1865
- if (item.type === 'function_call') {
1866
- const tc = toolCalls.find(
1867
- (t) => t._deferred && t._pendingItemId === (item.id || ''),
1868
- );
1869
- if (tc) {
1870
- if (!tc.id && item.call_id) tc.id = item.call_id;
1871
- if (!tc.name && item.name) tc.name = item.name;
1872
- if (tc.id && tc.name) {
1873
- delete tc._deferred;
1874
- delete tc._pendingItemId;
1875
- emitToolCallDedupe(tc);
1876
- }
1877
- }
1878
- }
1879
- }
1880
- }
1881
- // Salvage validation. Any deferred call still missing
1882
- // id/name would propagate to the next turn as a
1883
- // function_call_output the server can't anchor. Fail the
1884
- // stream now so the caller sees a deterministic error
1885
- // instead of a cryptic mismatch one turn later.
1886
- const unresolved = toolCalls.find((t) => t._deferred);
1887
- if (unresolved) {
1888
- terminalError = new Error(
1889
- `${errLabel} function_call salvage failed: missing call_id/name for item_id=${unresolved._pendingItemId || '?'}`,
1890
- );
1891
- finish();
1892
- break;
1893
- }
1894
- midState.sawCompleted = true;
1895
- done = true;
1896
- finish();
1897
- break;
1898
- }
1899
- case 'response.done': {
1900
- // response.done is the terminal frame for some openai-oauth
1901
- // streams that never emit a separate response.completed.
1902
- // Route through the same completed/failed/incomplete
1903
- // normalization based on event.response.status so a
1904
- // server-side abort (incomplete / failed) does not slip
1905
- // through as success. status === 'completed' falls
1906
- // through to the success path with sawCompleted set;
1907
- // anything else is converted into a terminal error.
1908
- const status = event.response?.status || '';
1909
- if (status === 'failed') {
1910
- midState.responseFailedPayload = event;
1911
- const msg = event.response?.error?.message
1912
- || event.error?.message
1913
- || event.message
1914
- || 'response.done failed';
1915
- terminalError = Object.assign(
1916
- new Error(`${errLabel} response.done failed: ${msg}`),
1917
- { responseFailed: event },
1918
- );
1919
- populateHttpStatusFromMessage(terminalError, msg);
1920
- done = true;
1921
- finish();
1922
- break;
1923
- }
1924
- if (status === 'incomplete') {
1925
- const reasonStr = _incompleteReasonFromEvent(event);
1926
- if (_isMaxOutputIncompleteReason(reasonStr)) {
1927
- incompleteReason = reasonStr;
1928
- stopReason = 'length';
1929
- midState.sawCompleted = true;
1930
- done = true;
1931
- finish();
1932
- break;
1933
- }
1934
- terminalError = Object.assign(
1935
- new Error(`${errLabel} response.done incomplete: ${reasonStr}`),
1936
- { responseIncomplete: event, incompleteReason: reasonStr },
1937
- );
1938
- done = true;
1939
- finish();
1940
- break;
1941
- }
1942
- if (status && status !== 'completed') {
1943
- terminalError = Object.assign(
1944
- new Error(`${errLabel} response.done unexpected status: ${status}`),
1945
- { responseDoneStatus: status },
1946
- );
1947
- done = true;
1948
- finish();
1949
- break;
1950
- }
1951
- midState.sawCompleted = true;
1952
- done = true;
1953
- finish();
1954
- break;
1955
- }
1956
- case 'response.incomplete': {
1957
- // Most incomplete reasons are real failures. max_output_tokens
1958
- // maps cleanly to Anthropic's stop_reason=max_tokens; treating
1959
- // it as an error makes Claude retry the same over-budget turn.
1960
- const reasonStr = _incompleteReasonFromEvent(event);
1961
- if (_isMaxOutputIncompleteReason(reasonStr)) {
1962
- incompleteReason = reasonStr;
1963
- stopReason = 'length';
1964
- midState.sawCompleted = true;
1965
- done = true;
1966
- finish();
1967
- break;
1968
- }
1969
- terminalError = Object.assign(
1970
- new Error(`${errLabel} response.incomplete: ${reasonStr}`),
1971
- { responseIncomplete: event, incompleteReason: reasonStr },
1972
- );
1973
- finish();
1974
- break;
1975
- }
1976
- case 'response.failed': {
1977
- // Stash the payload so the mid-stream classifier can sniff
1978
- // network_error / stream_disconnected without re-parsing.
1979
- midState.responseFailedPayload = event;
1980
- const msg = event.response?.error?.message
1981
- || event.error?.message
1982
- || event.message
1983
- || 'response.failed';
1984
- terminalError = Object.assign(new Error(`${errLabel} response.failed: ${msg}`), {
1985
- responseFailed: event,
1986
- });
1987
- // Sniff the server message for transient/auth/permanent
1988
- // hints so the handshake / mid-stream retry classifiers
1989
- // can route by httpStatus. Without this, server-side
1990
- // events like "Our servers are currently overloaded"
1991
- // surfaced as unclassified errors and skipped the
1992
- // 5xx retry bucket entirely.
1993
- populateHttpStatusFromMessage(terminalError, msg);
1994
- finish();
1995
- break;
1996
- }
1997
- case 'error': {
1998
- const errMsg = String(event.message || event.error?.message || 'unknown');
1999
- terminalError = new Error(`${errLabel} error: ${errMsg}`);
2000
- populateHttpStatusFromMessage(terminalError, errMsg);
2001
- finish();
2002
- break;
2003
- }
2004
- default:
2005
- // Catch any other reasoning-delta variants (e.g.
2006
- // `response.reasoning.<sub>.delta`) so they are counted
2007
- // and suppressed, never reaching the user content buffer.
2008
- if (typeof event.type === 'string'
2009
- && event.type.startsWith('response.reasoning')
2010
- && event.type.endsWith('.delta')) {
2011
- reasoningOtherDeltaCount += 1;
2012
- // These ARE live model progress (reviewer Medium): a
2013
- // provider that emits only these reasoning variants for a
2014
- // long span would otherwise trip the SEMANTIC idle abort.
2015
- // Refresh both the watchdog and the semantic idle timer,
2016
- // matching the named reasoning_text.delta case above.
2017
- try { onStreamDelta?.(); } catch {}
2018
- bumpSemanticIdle();
2019
- }
2020
- // Trace-only events (response.in_progress, etc.)
2021
- break;
2022
- }
2023
- };
2024
- closeHandler = (code, reason) => {
2025
- if (done) return;
2026
- midState.wsCloseCode = code;
2027
- if (!terminalError) {
2028
- const r = reason?.toString?.('utf-8') || '';
2029
- const httpStatus = _httpStatusFromWsClose(code, r);
2030
- terminalError = Object.assign(
2031
- new Error(`OpenAI OAuth WS closed before response.completed (code=${code}${r ? `, reason=${r}` : ''})`),
2032
- { wsCloseCode: code, wsCloseReason: r, ...(httpStatus ? { httpStatus } : {}) },
2033
- );
2034
- } else if (terminalError && !terminalError.wsCloseCode) {
2035
- try { terminalError.wsCloseCode = code; } catch {}
2036
- try { terminalError.httpStatus = terminalError.httpStatus || _httpStatusFromWsClose(code, reason?.toString?.('utf-8') || ''); } catch {}
2037
- }
2038
- finish();
2039
- };
2040
- errorHandler = (err) => {
2041
- if (done) return;
2042
- const wrapped = err instanceof Error ? err : new Error(String(err));
2043
- if (terminalError) {
2044
- // Preserve the first terminalError; chain the later socket
2045
- // error in via `cause` (or `suppressed` if cause already set)
2046
- // so diagnostics keep the original failure visible.
2047
- try {
2048
- if (!terminalError.cause) terminalError.cause = wrapped;
2049
- else {
2050
- const list = Array.isArray(terminalError.suppressed)
2051
- ? terminalError.suppressed
2052
- : [];
2053
- list.push(wrapped);
2054
- terminalError.suppressed = list;
2055
- }
2056
- } catch {}
2057
- } else {
2058
- terminalError = wrapped;
2059
- }
2060
- try { socket.close(4001, 'stream_error'); } catch {}
2061
- finish();
2062
- };
2063
- if (externalSignal) {
2064
- abortHandler = () => {
2065
- if (done) return;
2066
- const reason = externalSignal.reason;
2067
- terminalError = reason instanceof Error ? reason : new Error('OpenAI OAuth WS aborted by session close');
2068
- // Tag: was this a user/caller abort, or a watchdog abort?
2069
- // Mid-stream retry must skip user aborts but may retry watchdog
2070
- // aborts. The caller-owned AbortController surfaces through
2071
- // externalSignal; the agent stall watchdog signals via a reason
2072
- // object whose name === 'AgentStallAbortError'. stream-watchdog
2073
- // uses StreamStalledAbortError. Anything else → treat as user.
2074
- const reasonName = reason?.name || '';
2075
- if (reasonName === 'AgentStallAbortError'
2076
- || reasonName === 'StreamStalledAbortError') {
2077
- midState.watchdogAbort = reasonName;
2078
- } else {
2079
- midState.userAbort = true;
2080
- }
2081
- try { socket.close(4002, 'aborted'); } catch {}
2082
- finish();
2083
- };
2084
- if (externalSignal.aborted) { abortHandler(); return; }
2085
- externalSignal.addEventListener('abort', abortHandler, { once: true });
2086
- }
2087
- socket.on('message', messageHandler);
2088
- socket.on('close', closeHandler);
2089
- socket.on('error', errorHandler);
2090
- armPreStreamWatchdog();
2091
- // Periodic client-side WS ping while the stream is active. The server's
2092
- // server closes with 1011 "keepalive ping timeout" when it thinks the
2093
- // peer is silent during long reasoning windows where no data frames
2094
- // flow. Sending a ping every 10s from our side keeps the socket warm.
2095
- // The interval is unref'd so it never holds the event loop open, and
2096
- // cleanup() clears it on every terminal path (completed / close /
2097
- // error / abort / mid-stream retry teardown).
2098
- keepaliveTimer = setInterval(() => {
2099
- try {
2100
- if (socket.readyState !== WebSocket.OPEN) return;
2101
- socket.ping();
2102
- } catch {}
2103
- }, 10_000);
2104
- try { keepaliveTimer.unref?.(); } catch {}
2105
- });
2106
- }
108
+ // --- WS pool/handshake/acquire/release: extracted to openai-ws-pool.mjs ---
2107
109
 
2108
- /**
2109
- * Classify a handshake error for retry eligibility.
2110
- *
2111
- * Default-deny: anything we don't recognize as transient returns null (treat
2112
- * as permanent). Permanent buckets (401/403/404/429) also return null — the
2113
- * server has made a deterministic decision that a retry can't change.
2114
- *
2115
- * Returns one of:
2116
- * 'timeout' — `ws` handshakeTimeout fired
2117
- * 'reset' — ECONNRESET / socket hang up
2118
- * 'dns' — EAI_AGAIN / ENOTFOUND / EAI_NODATA
2119
- * 'refused' — ECONNREFUSED
2120
- * 'network' — ENETUNREACH / EHOSTUNREACH / EPIPE
2121
- * 'acquire_timeout' — hard client-side open/acquire deadline fired
2122
- * 'http_5xx' (with specific status e.g. 'http_503') — server overload
2123
- * null — not retryable
2124
- */
2125
- // Thin re-export wrapper: handshake classification now lives in the shared
2126
- // retry-classifier (classifyHandshakeError). Kept here as a named export so
2127
- // internal call sites (_acquireWithRetry) and any external importer keep
2128
- // resolving the same symbol.
110
+ // --- Delta/matching helpers + parseToolSearchArgs: extracted to openai-ws-stream.mjs ---
111
+ // --- Stream consumer (_streamResponse): extracted to openai-ws-stream.mjs ---
2129
112
  export function _classifyHandshakeError(err) {
2130
113
  return classifyHandshakeError(err);
2131
114
  }
@@ -2204,6 +187,182 @@ function _sleepWithAbort(ms, externalSignal, sleepFn = _defaultSleep) {
2204
187
  return sleepWithAbort(ms, externalSignal, sleepFn, 'OpenAI OAuth WS retry backoff aborted');
2205
188
  }
2206
189
 
190
+ function _num(value, fallback = 0) {
191
+ const n = Number(value);
192
+ return Number.isFinite(n) ? n : fallback;
193
+ }
194
+
195
+ function _envPositiveInt(name, fallback) {
196
+ const n = Number(process.env[name]);
197
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
198
+ }
199
+
200
+ function _envRatio(name, fallback) {
201
+ const n = Number(process.env[name]);
202
+ return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
203
+ }
204
+
205
+ function _cleanMetaString(value) {
206
+ return typeof value === 'string' ? value.trim() : '';
207
+ }
208
+
209
+ function _hashText(value, chars = 24) {
210
+ return createHash('sha256').update(String(value || '')).digest('hex').slice(0, chars);
211
+ }
212
+
213
+ function _sessionStartedAtUnixMs(sessionId) {
214
+ const parts = String(sessionId || '').split('_');
215
+ for (const part of parts) {
216
+ if (/^\d{12,}$/.test(part)) {
217
+ const n = Number(part);
218
+ if (Number.isFinite(n) && n > 0) return Math.floor(n);
219
+ }
220
+ }
221
+ return Date.now();
222
+ }
223
+
224
+ function _codexRequestKind(sendOpts, sessionId) {
225
+ const explicit = _cleanMetaString(sendOpts?.requestKind || sendOpts?.codexRequestKind);
226
+ if (explicit) return explicit;
227
+ return String(sessionId || '').includes(':compact') ? 'compaction' : 'turn';
228
+ }
229
+
230
+ function _codexInstallationId(sendOpts) {
231
+ return _cleanMetaString(sendOpts?.installationId || sendOpts?.codexInstallationId || process.env.MIXDOG_CODEX_INSTALLATION_ID)
232
+ || `mixdog-${_hashText(`${process.env.USERPROFILE || process.env.HOME || ''}:${process.cwd()}`, 32)}`;
233
+ }
234
+
235
+ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts } = {}) {
236
+ const sessionId = _cleanMetaString(sendOpts?.codexSessionId || sendOpts?.session?.codexSessionId || poolKey || cacheKey)
237
+ || 'mixdog-session';
238
+ const threadId = _cleanMetaString(sendOpts?.threadId || sendOpts?.codexThreadId || sendOpts?.session?.threadId || cacheKey || sessionId)
239
+ || sessionId;
240
+ const turnId = _cleanMetaString(sendOpts?.turnId || sendOpts?.codexTurnId || sendOpts?.session?.turnId || sessionId)
241
+ || sessionId;
242
+ const windowId = _cleanMetaString(sendOpts?.windowId || sendOpts?.codexWindowId || sendOpts?.session?.windowId)
243
+ || `${threadId}:1`;
244
+ const installationId = _codexInstallationId(sendOpts);
245
+ const startedAt = Number.isFinite(Number(sendOpts?.turnStartedAtUnixMs))
246
+ ? Math.floor(Number(sendOpts.turnStartedAtUnixMs))
247
+ : _sessionStartedAtUnixMs(sessionId);
248
+ const requestKind = _codexRequestKind(sendOpts, sessionId);
249
+ const turnMetadata = {
250
+ installation_id: installationId,
251
+ session_id: sessionId,
252
+ thread_id: threadId,
253
+ turn_id: turnId,
254
+ window_id: windowId,
255
+ request_kind: requestKind,
256
+ turn_started_at_unix_ms: startedAt,
257
+ };
258
+ const metadata = {
259
+ 'x-codex-installation-id': installationId,
260
+ session_id: sessionId,
261
+ thread_id: threadId,
262
+ turn_id: turnId,
263
+ 'x-codex-window-id': windowId,
264
+ 'x-codex-turn-metadata': JSON.stringify(turnMetadata),
265
+ };
266
+ // NOTE: no entry-level caching — pooled sockets outlive turns, and codex
267
+ // rebuilds metadata per request (responses_metadata.rs client_metadata()).
268
+ // Caching the first turn's turn_id on the socket would replay stale turn
269
+ // identity for every later turn on that connection.
270
+ return metadata;
271
+ }
272
+
273
+ function _metadataTrace(metadata) {
274
+ if (!metadata || typeof metadata !== 'object') {
275
+ return { count: 0, hash: null, hasTurnMetadata: false, hasThreadId: false };
276
+ }
277
+ const keys = Object.keys(metadata).sort();
278
+ return {
279
+ count: keys.length,
280
+ hash: _hashText(keys.map((key) => `${key}=${metadata[key]}`).join('\n'), 12),
281
+ hasTurnMetadata: typeof metadata['x-codex-turn-metadata'] === 'string' && metadata['x-codex-turn-metadata'].length > 0,
282
+ hasThreadId: typeof metadata.thread_id === 'string' && metadata.thread_id.length > 0,
283
+ };
284
+ }
285
+
286
+ function _codexWsCompatibilityHeaders(context = {}) {
287
+ const metadata = _codexMetadataBase(null, context);
288
+ const headers = {};
289
+ if (metadata['x-codex-window-id']) headers['x-codex-window-id'] = metadata['x-codex-window-id'];
290
+ if (metadata['x-codex-turn-metadata']) headers['x-codex-turn-metadata'] = metadata['x-codex-turn-metadata'];
291
+ // codex sends x-codex-installation-id on EVERY request incl. the WS
292
+ // handshake (client.rs:582-584). We previously carried it only in the
293
+ // body client_metadata; the server may gate x-codex-turn-state issuance
294
+ // on handshake-level client identity, so mirror codex here.
295
+ if (metadata['x-codex-installation-id']) headers['x-codex-installation-id'] = metadata['x-codex-installation-id'];
296
+ return headers;
297
+ }
298
+
299
+ function _withCodexWsClientMetadata(frame, entry, enabled, context = {}) {
300
+ if (!enabled || !frame || typeof frame !== 'object') return frame;
301
+ const base = _codexMetadataBase(entry, context);
302
+ const metadata = {
303
+ ...base,
304
+ ...(frame.client_metadata && typeof frame.client_metadata === 'object' ? frame.client_metadata : {}),
305
+ 'x-codex-ws-stream-request-start-ms': String(Date.now()),
306
+ };
307
+ if (entry && typeof entry === 'object') {
308
+ // codex scopes x-codex-turn-state to ONE turn (client.rs:263-279 —
309
+ // "must not send it between different turns"). Pooled sockets span
310
+ // turns, so key the stored token by the turn that captured it and
311
+ // drop it when the turn changes.
312
+ if (entry.turnState && entry.turnStateTurnId && entry.turnStateTurnId !== base.turn_id) {
313
+ entry.turnState = null;
314
+ entry.turnStateTurnId = null;
315
+ }
316
+ entry.currentTurnId = base.turn_id;
317
+ }
318
+ if (entry?.turnState) {
319
+ metadata['x-codex-turn-state'] = String(entry.turnState);
320
+ }
321
+ return {
322
+ ...frame,
323
+ client_metadata: metadata,
324
+ };
325
+ }
326
+
327
+ function _cacheObservation({ entry, result }) {
328
+ const inputTokens = _num(result?.usage?.inputTokens, 0);
329
+ const promptTokens = _num(result?.usage?.promptTokens, 0) || inputTokens;
330
+ const cachedTokens = _num(result?.usage?.cachedTokens, 0);
331
+ const previousMaxCached = _num(entry?.promptCacheMaxCachedTokens, 0);
332
+ const warmThreshold = _envPositiveInt('MIXDOG_OAI_CACHE_MISS_WARM_TOKENS', 2048);
333
+ const promptThreshold = _envPositiveInt('MIXDOG_OAI_CACHE_MISS_PROMPT_TOKENS', 4096);
334
+ const dropRatio = _envRatio('MIXDOG_OAI_CACHE_MISS_DROP_RATIO', 0.6);
335
+ const dropThreshold = Math.floor(previousMaxCached * dropRatio);
336
+ const wasWarm = previousMaxCached >= warmThreshold;
337
+ const cacheRatio = promptTokens > 0 ? cachedTokens / promptTokens : null;
338
+ const zeroMiss = wasWarm && promptTokens >= promptThreshold && cachedTokens === 0;
339
+ const partialDrop = wasWarm
340
+ && promptTokens >= promptThreshold
341
+ && cachedTokens > 0
342
+ && previousMaxCached > 0
343
+ && cachedTokens < dropThreshold;
344
+ const actualMiss = zeroMiss || partialDrop;
345
+ return {
346
+ inputTokens,
347
+ promptTokens,
348
+ cachedTokens,
349
+ uncachedTokens: Math.max(0, promptTokens - cachedTokens),
350
+ previousMaxCached,
351
+ wasWarm,
352
+ warmThreshold,
353
+ promptThreshold,
354
+ dropRatio,
355
+ dropThreshold,
356
+ cacheRatio,
357
+ actualMiss,
358
+ missReason: zeroMiss
359
+ ? 'warm_session_zero_cached_tokens'
360
+ : partialDrop
361
+ ? 'warm_session_cached_tokens_dropped'
362
+ : null,
363
+ };
364
+ }
365
+
2207
366
  /**
2208
367
  * Run `_acquire({auth, poolKey, cacheKey})` with bounded exponential-backoff
2209
368
  * retry on transient handshake failures. The injection seams (`_acquire`,
@@ -2218,6 +377,7 @@ export async function _acquireWithRetry({
2218
377
  auth,
2219
378
  poolKey,
2220
379
  cacheKey,
380
+ codexHeaders,
2221
381
  forceFresh,
2222
382
  onRetry,
2223
383
  externalSignal,
@@ -2237,7 +397,7 @@ export async function _acquireWithRetry({
2237
397
  process.stderr.write(`[agent-trace] ws-handshake-attempt n=${attempt}\n`);
2238
398
  }
2239
399
  }
2240
- return await _acquire({ auth, poolKey, cacheKey, forceFresh, externalSignal });
400
+ return await _acquire({ auth, poolKey, cacheKey, codexHeaders, forceFresh, externalSignal });
2241
401
  } catch (err) {
2242
402
  lastErr = err;
2243
403
  const classifier = _classifyHandshakeError(err);
@@ -2388,17 +548,12 @@ export async function sendViaWebSocket({
2388
548
  // this carry-forward would not help and is therefore gated to xAI.
2389
549
  let carryForwardCache = null;
2390
550
  const emittedProgress = [];
551
+ const useCodexWsClientMetadata = traceProvider === 'openai-oauth';
552
+ const codexMetadataContext = { poolKey, cacheKey, sendOpts };
553
+ const codexHandshakeHeaders = useCodexWsClientMetadata
554
+ ? _codexWsCompatibilityHeaders(codexMetadataContext)
555
+ : null;
2391
556
 
2392
- return await _withOpenAiPromptCacheLane({
2393
- auth,
2394
- cacheKey,
2395
- sendOpts,
2396
- poolKey,
2397
- iteration,
2398
- traceProvider,
2399
- useModel,
2400
- externalSignal,
2401
- }, async (promptCacheLane) => {
2402
557
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
2403
558
  const handshakeStart = Date.now();
2404
559
  let acquired;
@@ -2410,6 +565,7 @@ export async function sendViaWebSocket({
2410
565
  auth,
2411
566
  poolKey,
2412
567
  cacheKey,
568
+ codexHeaders: codexHandshakeHeaders,
2413
569
  // Retry attempt must not reuse a pooled socket — the prior
2414
570
  // one is either torn down or in an unknown state.
2415
571
  forceFresh: forceFresh || attemptIndex > 0,
@@ -2502,18 +658,23 @@ export async function sendViaWebSocket({
2502
658
  let deltaReason = null;
2503
659
  let strippedResponseItems = 0;
2504
660
  let skippedResponseItems = 0;
661
+ let wireFrameHadTurnState = false;
662
+ let wireFrameMetadataTrace = _metadataTrace(null);
663
+ let framePrefixHash = null;
664
+ let framePrefixHeadHash = null;
665
+ let framePrefixPrevMatch = null;
2505
666
  let result;
2506
667
  const streamTimeouts = null;
2507
668
  try {
2508
- if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0) {
2509
- await promptCacheLane?.reserveRate?.({
2510
- mode: 'full',
2511
- frameInputItems: Array.isArray(warmupBody.input) ? warmupBody.input.length : null,
2512
- deltaTokens: _estimateFrameTokens({ type: 'response.create', ...warmupBody }),
2513
- hasPreviousResponseId: false,
2514
- });
669
+ // codex prewarm gate (client.rs:1686-1688): only when the session
670
+ // has no prior request state. A reused pooled socket with a live
671
+ // chain must go straight to the real request.
672
+ if (warmupBody && typeof warmupBody === 'object' && attemptIndex === 0 && !entry.lastResponseId) {
2515
673
  const warmupFrame = { type: 'response.create', ...warmupBody };
2516
- await _sendFrameFn(entry, warmupFrame);
674
+ const wireWarmupFrame = _withCodexWsClientMetadata(warmupFrame, entry, useCodexWsClientMetadata, codexMetadataContext);
675
+ wireFrameHadTurnState = !!wireWarmupFrame?.client_metadata?.['x-codex-turn-state'];
676
+ wireFrameMetadataTrace = _metadataTrace(wireWarmupFrame?.client_metadata);
677
+ await _sendFrameFn(entry, wireWarmupFrame);
2517
678
  const warmupStart = Date.now();
2518
679
  const warmupState = {
2519
680
  attemptIndex,
@@ -2573,13 +734,15 @@ export async function sendViaWebSocket({
2573
734
  payload: warmupPayload,
2574
735
  });
2575
736
  } catch {}
2576
- requestBody = { ...body, previous_response_id: warmupResult.responseId };
2577
- delete requestBody.instructions;
2578
- delete requestBody.generate;
2579
- entry.lastRequestSansInput = _stableStringify(_sansInput({
2580
- ...requestBody,
2581
- input: warmupInputArr,
2582
- }));
737
+ // Do NOT rewrite the main request after warmup (R23 finding).
738
+ // The old prev_id+no-instructions rewrite made it=1's frame a
739
+ // different shape from it=2+ full frames, so the first real
740
+ // full-frame cache write only landed at it=2 and it=3 raced
741
+ // its propagation (cached=0 early misses). Keeping it=1 as a
742
+ // normal full frame makes it byte-identical to the warmup's
743
+ // prefix (instant full hit on the cache warmup just wrote)
744
+ // and keeps every subsequent frame one consistent shape.
745
+ // Delta opt-in still chains via entry.lastResponseId above.
2583
746
  }
2584
747
 
2585
748
  const delta = _computeDelta({ entry, body: requestBody });
@@ -2587,13 +750,34 @@ export async function sendViaWebSocket({
2587
750
  deltaReason = delta.reason || null;
2588
751
  strippedResponseItems = delta.strippedResponseItems || 0;
2589
752
  skippedResponseItems = delta.skippedResponseItems || 0;
2590
- deltaTokens = _estimateFrameTokens(frame);
2591
- await promptCacheLane?.reserveRate?.({
2592
- mode,
2593
- frameInputItems: Array.isArray(frame.input) ? frame.input.length : null,
2594
- deltaTokens,
2595
- hasPreviousResponseId: typeof frame.previous_response_id === 'string' && frame.previous_response_id.length > 0,
2596
- });
753
+ const wireFrame = _withCodexWsClientMetadata(frame, entry, useCodexWsClientMetadata, codexMetadataContext);
754
+ wireFrameHadTurnState = !!wireFrame?.client_metadata?.['x-codex-turn-state'];
755
+ wireFrameMetadataTrace = _metadataTrace(wireFrame?.client_metadata);
756
+ deltaTokens = _estimateFrameTokens(wireFrame);
757
+ // Prefix-consistency probe (item-level). Serialized-JSON byte
758
+ // prefixes can never match across appends (the shorter frame ends
759
+ // in "]}" where the longer has ","), so compare what the server's
760
+ // prefix cache actually sees: the non-input request header and the
761
+ // per-item content of the input array. prev_match=true means the
762
+ // current call's header is identical and its first N input items
763
+ // equal the previous call's N items (append-only history).
764
+ try {
765
+ const { client_metadata: _cm, input: frameInput, ...frameHeader } = frame;
766
+ const headerHash = _hashText(JSON.stringify(frameHeader), 16);
767
+ const itemHashes = (Array.isArray(frameInput) ? frameInput : [])
768
+ .map((item) => _hashText(JSON.stringify(item), 12));
769
+ framePrefixHash = headerHash;
770
+ framePrefixHeadHash = _hashText(itemHashes.join(','), 16);
771
+ const prevHeader = entry.lastFrameHeaderHash;
772
+ const prevItems = entry.lastFrameItemHashes;
773
+ if (prevHeader && Array.isArray(prevItems)) {
774
+ framePrefixPrevMatch = headerHash === prevHeader
775
+ && itemHashes.length >= prevItems.length
776
+ && prevItems.every((h, i) => itemHashes[i] === h);
777
+ }
778
+ entry.lastFrameHeaderHash = headerHash;
779
+ entry.lastFrameItemHashes = itemHashes;
780
+ } catch {}
2597
781
 
2598
782
  // Re-check abort after acquire/warmup — narrow window where
2599
783
  // externalSignal could fire between successful acquire and
@@ -2606,7 +790,7 @@ export async function sendViaWebSocket({
2606
790
  const reason = externalSignal.reason;
2607
791
  throw reason instanceof Error ? reason : new Error('Aborted');
2608
792
  }
2609
- await _sendFrameFn(entry, frame);
793
+ await _sendFrameFn(entry, wireFrame);
2610
794
 
2611
795
  if (process.env.MIXDOG_DEBUG_AGENT) {
2612
796
  process.stderr.write(`[agent-trace] ws-streaming-start sinceAcquire=${Date.now() - handshakeStart}ms\n`);
@@ -2748,6 +932,11 @@ export async function sendViaWebSocket({
2748
932
  entry.lastInputPrefixHash = null;
2749
933
  }
2750
934
 
935
+ // Cache observation must see the MAIN request's usage only. Folding
936
+ // warmup usage in first (R18) made prompt_tokens spike on it=1 and
937
+ // then "shrink" on it=2, faking prefix-rewrite/cache-drop signals in
938
+ // every warmup session (debugger 2026-07-03).
939
+ const cacheObservation = _cacheObservation({ entry, result });
2751
940
  if (warmupResult?.usage) {
2752
941
  result.usage = _combineUsageWithWarmup(result.usage, warmupResult.usage);
2753
942
  }
@@ -2768,11 +957,51 @@ export async function sendViaWebSocket({
2768
957
  provider: traceProvider,
2769
958
  serviceTier: responseServiceTier,
2770
959
  });
960
+ const requestHasPreviousResponseId = typeof frame.previous_response_id === 'string' && frame.previous_response_id.length > 0;
961
+ const transportCacheKeyHash = cacheKey
962
+ ? createHash('sha256').update(String(cacheKey)).digest('hex').slice(0, 12)
963
+ : null;
964
+ if (cacheObservation.actualMiss) {
965
+ try {
966
+ appendAgentTrace({
967
+ sessionId: poolKey,
968
+ iteration,
969
+ kind: 'cache_miss',
970
+ provider: traceProvider,
971
+ model: liveModel,
972
+ transport: 'websocket',
973
+ payload: {
974
+ provider: traceProvider,
975
+ model: liveModel,
976
+ transport: 'websocket',
977
+ ws_mode: mode,
978
+ reason: cacheObservation.missReason || 'warm_session_cache_miss',
979
+ cached_tokens: cacheObservation.cachedTokens,
980
+ prompt_tokens: cacheObservation.promptTokens,
981
+ input_tokens: cacheObservation.inputTokens,
982
+ uncached_tokens: cacheObservation.uncachedTokens,
983
+ cache_ratio: cacheObservation.cacheRatio,
984
+ previous_max_cached_tokens: cacheObservation.previousMaxCached,
985
+ cache_key_hash: transportCacheKeyHash,
986
+ warm_threshold_tokens: cacheObservation.warmThreshold,
987
+ prompt_threshold_tokens: cacheObservation.promptThreshold,
988
+ drop_ratio: cacheObservation.dropRatio,
989
+ drop_threshold_tokens: cacheObservation.dropThreshold,
990
+ request_has_previous_response_id: requestHasPreviousResponseId,
991
+ chain_delta_reason: mode === 'delta' ? null : deltaReason,
992
+ body_input_items: Array.isArray(requestBody.input) ? requestBody.input.length : null,
993
+ frame_input_items: Array.isArray(frame.input) ? frame.input.length : null,
994
+ response_id: result.responseId || null,
995
+ },
996
+ });
997
+ } catch {}
998
+ }
999
+ entry.promptCacheMaxCachedTokens = Math.max(
1000
+ _num(entry.promptCacheMaxCachedTokens, 0),
1001
+ cacheObservation.cachedTokens,
1002
+ );
2771
1003
  // Extra WS-specific observability: transport + per-iteration delta bytes.
2772
1004
  try {
2773
- const transportCacheKeyHash = cacheKey
2774
- ? createHash('sha256').update(String(cacheKey)).digest('hex').slice(0, 12)
2775
- : null;
2776
1005
  const transportPayload = {
2777
1006
  provider: traceProvider,
2778
1007
  transport: 'websocket',
@@ -2789,22 +1018,26 @@ export async function sendViaWebSocket({
2789
1018
  midstream_retries: attemptIndex,
2790
1019
  response_id: result.responseId || null,
2791
1020
  cache_key_hash: transportCacheKeyHash,
2792
- cache_lane_enabled: promptCacheLane?.enabled === true,
2793
- cache_lane_key_hash: promptCacheLane?.laneKeyHash || null,
2794
- cache_lane_rate_policy: promptCacheLane?.ratePolicy || null,
2795
- cache_lane_max_in_flight: Number.isFinite(Number(promptCacheLane?.maxInFlight)) ? Number(promptCacheLane.maxInFlight) : null,
2796
- cache_lane_rate_limit_per_min: Number.isFinite(Number(promptCacheLane?.rateLimitPerMin)) ? Number(promptCacheLane.rateLimitPerMin) : null,
2797
- cache_lane_rate_full_limit_per_min: Number.isFinite(Number(promptCacheLane?.rateFullLimitPerMin)) ? Number(promptCacheLane.rateFullLimitPerMin) : null,
2798
- cache_lane_rate_delta_limit_per_min: Number.isFinite(Number(promptCacheLane?.rateDeltaLimitPerMin)) ? Number(promptCacheLane.rateDeltaLimitPerMin) : null,
2799
- cache_lane_rate_wait_ms: Number.isFinite(Number(promptCacheLane?.rateWaitMs)) ? Number(promptCacheLane.rateWaitMs) : null,
2800
- cache_lane_rate_window_count: Number.isFinite(Number(promptCacheLane?.rateWindowCount)) ? Number(promptCacheLane.rateWindowCount) : null,
2801
- cache_lane_rate_released_for_wait: promptCacheLane?.rateReleasedForWait === true,
2802
- cache_lane_rate_reacquire_wait_ms: Number.isFinite(Number(promptCacheLane?.rateReacquireWaitMs)) ? Number(promptCacheLane.rateReacquireWaitMs) : null,
2803
- cache_lane_wait_ms: Number.isFinite(Number(promptCacheLane?.waitMs)) ? Number(promptCacheLane.waitMs) : null,
2804
- cache_lane_queued: promptCacheLane?.queued === true,
2805
- cache_lane_active: Number.isFinite(Number(promptCacheLane?.activeAfterAcquire)) ? Number(promptCacheLane.activeAfterAcquire) : null,
2806
- cache_lane_queue_depth: Number.isFinite(Number(promptCacheLane?.queueDepthAfterAcquire)) ? Number(promptCacheLane.queueDepthAfterAcquire) : null,
2807
- request_has_previous_response_id: typeof frame.previous_response_id === 'string' && frame.previous_response_id.length > 0,
1021
+ request_has_previous_response_id: requestHasPreviousResponseId,
1022
+ cached_tokens: cacheObservation.cachedTokens,
1023
+ prompt_tokens: cacheObservation.promptTokens,
1024
+ input_tokens: cacheObservation.inputTokens,
1025
+ uncached_tokens: cacheObservation.uncachedTokens,
1026
+ cache_ratio: cacheObservation.cacheRatio,
1027
+ actual_cache_miss: cacheObservation.actualMiss,
1028
+ actual_cache_miss_reason: cacheObservation.missReason,
1029
+ previous_max_cached_tokens: cacheObservation.previousMaxCached,
1030
+ cache_drop_threshold_tokens: cacheObservation.dropThreshold,
1031
+ frame_prefix_hash: framePrefixHash,
1032
+ frame_prefix_head_hash: framePrefixHeadHash,
1033
+ frame_prefix_prev_match: framePrefixPrevMatch,
1034
+ ws_client_metadata: useCodexWsClientMetadata,
1035
+ ws_client_metadata_key_count: wireFrameMetadataTrace.count,
1036
+ ws_client_metadata_hash: wireFrameMetadataTrace.hash,
1037
+ ws_client_metadata_has_turn_metadata: wireFrameMetadataTrace.hasTurnMetadata,
1038
+ ws_client_metadata_has_thread_id: wireFrameMetadataTrace.hasThreadId,
1039
+ ws_client_metadata_has_turn_state: wireFrameHadTurnState,
1040
+ ws_entry_turn_state_available: useCodexWsClientMetadata && !!entry.turnState,
2808
1041
  chain_delta_reason: mode === 'delta' ? null : deltaReason,
2809
1042
  chain_stripped_response_items: strippedResponseItems,
2810
1043
  chain_skipped_response_items: skippedResponseItems,
@@ -2825,7 +1058,10 @@ export async function sendViaWebSocket({
2825
1058
  ...transportPayload,
2826
1059
  payload: transportPayload,
2827
1060
  });
2828
- if (mode !== 'delta' || deltaReason) {
1061
+ const chainFallback = mode !== 'delta'
1062
+ && deltaReason
1063
+ && !['no_anchor', 'full_forced'].includes(deltaReason);
1064
+ if (chainFallback || (mode === 'delta' && deltaReason)) {
2829
1065
  appendAgentTrace({
2830
1066
  sessionId: poolKey,
2831
1067
  iteration,
@@ -2839,7 +1075,11 @@ export async function sendViaWebSocket({
2839
1075
  ws_mode: mode,
2840
1076
  reason: mode === 'delta' ? deltaReason : (deltaReason || 'full_frame'),
2841
1077
  cache_key_hash: transportCacheKeyHash,
2842
- cache_lane_key_hash: promptCacheLane?.laneKeyHash || null,
1078
+ cached_tokens: cacheObservation.cachedTokens,
1079
+ prompt_tokens: cacheObservation.promptTokens,
1080
+ uncached_tokens: cacheObservation.uncachedTokens,
1081
+ cache_ratio: cacheObservation.cacheRatio,
1082
+ actual_cache_miss: cacheObservation.actualMiss,
2843
1083
  request_has_previous_response_id: transportPayload.request_has_previous_response_id,
2844
1084
  chain_stripped_response_items: strippedResponseItems,
2845
1085
  chain_skipped_response_items: skippedResponseItems,
@@ -2876,26 +1116,4 @@ export async function sendViaWebSocket({
2876
1116
  }
2877
1117
  // Unreachable — the loop either returns or throws above.
2878
1118
  throw _stampTool(_stampLiveText(firstAttemptError || new Error('sendViaWebSocket: unreachable')));
2879
- });
2880
- }
2881
-
2882
- // Drain-complete fence — set true once _closeAllPooledSockets runs so any
2883
- // in-flight acquire that resumes after drain throws instead of pushing a
2884
- // fresh socket into the cleared pool. Single-set, process-lifetime invariant.
2885
- let _drainComplete = false;
2886
-
2887
- // Drain hook — self-registered exit drain.
2888
- // Force-closes pooled sockets and fences subsequent acquires.
2889
- // `drainOpenaiWsPool` alias matches the registry's `drain*` naming convention;
2890
- // `_closeAllPooledSockets` kept for backward compat with existing call sites.
2891
- export function _closeAllPooledSockets(reason = 'shutdown') {
2892
- _drainComplete = true;
2893
- for (const arr of _wsPool.values()) {
2894
- for (const entry of arr) {
2895
- try { entry.socket.close(1000, reason); } catch {}
2896
- }
2897
- }
2898
- _wsPool.clear();
2899
1119
  }
2900
- export const drainOpenaiWsPool = _closeAllPooledSockets;
2901
- process.on('exit', drainOpenaiWsPool);