mixdog 0.9.2 → 0.9.4

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