jeopi-ai 16.2.13

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 (598) hide show
  1. package/CHANGELOG.md +4347 -0
  2. package/README.md +1193 -0
  3. package/dist/types/api-registry.d.ts +30 -0
  4. package/dist/types/auth-broker/client.d.ts +73 -0
  5. package/dist/types/auth-broker/discover.d.ts +35 -0
  6. package/dist/types/auth-broker/index.d.ts +7 -0
  7. package/dist/types/auth-broker/refresher.d.ts +25 -0
  8. package/dist/types/auth-broker/remote-store.d.ts +102 -0
  9. package/dist/types/auth-broker/server.d.ts +43 -0
  10. package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
  11. package/dist/types/auth-broker/types.d.ts +107 -0
  12. package/dist/types/auth-broker/wire-schemas.d.ts +411 -0
  13. package/dist/types/auth-gateway/http.d.ts +39 -0
  14. package/dist/types/auth-gateway/index.d.ts +3 -0
  15. package/dist/types/auth-gateway/server.d.ts +36 -0
  16. package/dist/types/auth-gateway/types.d.ts +123 -0
  17. package/dist/types/auth-retry.d.ts +124 -0
  18. package/dist/types/auth-storage.d.ts +1026 -0
  19. package/dist/types/dialect/anthropic.d.ts +15 -0
  20. package/dist/types/dialect/catalog.d.ts +3 -0
  21. package/dist/types/dialect/coercion.d.ts +23 -0
  22. package/dist/types/dialect/deepseek.d.ts +14 -0
  23. package/dist/types/dialect/demotion.d.ts +23 -0
  24. package/dist/types/dialect/examples.d.ts +2 -0
  25. package/dist/types/dialect/factory.d.ts +3 -0
  26. package/dist/types/dialect/fenced-thinking.d.ts +53 -0
  27. package/dist/types/dialect/gemini.d.ts +17 -0
  28. package/dist/types/dialect/gemma.d.ts +15 -0
  29. package/dist/types/dialect/glm.d.ts +9 -0
  30. package/dist/types/dialect/harmony.d.ts +8 -0
  31. package/dist/types/dialect/hermes.d.ts +9 -0
  32. package/dist/types/dialect/history.d.ts +3 -0
  33. package/dist/types/dialect/index.d.ts +11 -0
  34. package/dist/types/dialect/inventory.d.ts +12 -0
  35. package/dist/types/dialect/kimi.d.ts +14 -0
  36. package/dist/types/dialect/minimax.d.ts +3 -0
  37. package/dist/types/dialect/owned-stream.d.ts +4 -0
  38. package/dist/types/dialect/qwen3.d.ts +9 -0
  39. package/dist/types/dialect/rendering.d.ts +45 -0
  40. package/dist/types/dialect/thinking.d.ts +6 -0
  41. package/dist/types/dialect/types.d.ts +69 -0
  42. package/dist/types/dialect/xml.d.ts +9 -0
  43. package/dist/types/error/abort.d.ts +14 -0
  44. package/dist/types/error/auth-classify.d.ts +16 -0
  45. package/dist/types/error/auth.d.ts +27 -0
  46. package/dist/types/error/aws.d.ts +23 -0
  47. package/dist/types/error/classes.d.ts +102 -0
  48. package/dist/types/error/finalize.d.ts +39 -0
  49. package/dist/types/error/flags.d.ts +79 -0
  50. package/dist/types/error/format.d.ts +20 -0
  51. package/dist/types/error/gateway.d.ts +20 -0
  52. package/dist/types/error/index.d.ts +13 -0
  53. package/dist/types/error/oauth.d.ts +43 -0
  54. package/dist/types/error/provider.d.ts +42 -0
  55. package/dist/types/error/rate-limit.d.ts +59 -0
  56. package/dist/types/error/retryable.d.ts +27 -0
  57. package/dist/types/error/validation.d.ts +32 -0
  58. package/dist/types/index.d.ts +49 -0
  59. package/dist/types/provider-details.d.ts +24 -0
  60. package/dist/types/providers/__tests__/google-auth.test.d.ts +1 -0
  61. package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
  62. package/dist/types/providers/__tests__/openai-codex-error.test.d.ts +1 -0
  63. package/dist/types/providers/amazon-bedrock.d.ts +39 -0
  64. package/dist/types/providers/anthropic-client.d.ts +94 -0
  65. package/dist/types/providers/anthropic-messages-server-schema.d.ts +497 -0
  66. package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
  67. package/dist/types/providers/anthropic-wire.d.ts +318 -0
  68. package/dist/types/providers/anthropic.d.ts +248 -0
  69. package/dist/types/providers/aws-credentials.d.ts +53 -0
  70. package/dist/types/providers/aws-eventstream.d.ts +39 -0
  71. package/dist/types/providers/aws-sigv4.d.ts +55 -0
  72. package/dist/types/providers/azure-openai-responses.d.ts +16 -0
  73. package/dist/types/providers/cursor.d.ts +91 -0
  74. package/dist/types/providers/devin.d.ts +12 -0
  75. package/dist/types/providers/error-message.d.ts +27 -0
  76. package/dist/types/providers/github-copilot-headers.d.ts +40 -0
  77. package/dist/types/providers/gitlab-duo-workflow.d.ts +254 -0
  78. package/dist/types/providers/gitlab-duo.d.ts +27 -0
  79. package/dist/types/providers/google-auth.d.ts +32 -0
  80. package/dist/types/providers/google-gemini-cli.d.ts +118 -0
  81. package/dist/types/providers/google-interactions.d.ts +65 -0
  82. package/dist/types/providers/google-shared.d.ts +203 -0
  83. package/dist/types/providers/google-types.d.ts +155 -0
  84. package/dist/types/providers/google-vertex.d.ts +7 -0
  85. package/dist/types/providers/google.d.ts +4 -0
  86. package/dist/types/providers/grammar.d.ts +1 -0
  87. package/dist/types/providers/kimi.d.ts +27 -0
  88. package/dist/types/providers/mock.d.ts +178 -0
  89. package/dist/types/providers/ollama.d.ts +7 -0
  90. package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
  91. package/dist/types/providers/openai-chat-server-schema.d.ts +695 -0
  92. package/dist/types/providers/openai-chat-server.d.ts +16 -0
  93. package/dist/types/providers/openai-chat-wire.d.ts +644 -0
  94. package/dist/types/providers/openai-codex/request-transformer.d.ts +54 -0
  95. package/dist/types/providers/openai-codex/response-handler.d.ts +26 -0
  96. package/dist/types/providers/openai-codex-responses.d.ts +108 -0
  97. package/dist/types/providers/openai-completions.d.ts +45 -0
  98. package/dist/types/providers/openai-reasoning-fallback.d.ts +25 -0
  99. package/dist/types/providers/openai-responses-server-schema.d.ts +349 -0
  100. package/dist/types/providers/openai-responses-server.d.ts +17 -0
  101. package/dist/types/providers/openai-responses-wire.d.ts +6065 -0
  102. package/dist/types/providers/openai-responses.d.ts +126 -0
  103. package/dist/types/providers/openai-shared.d.ts +506 -0
  104. package/dist/types/providers/pi-native-client.d.ts +13 -0
  105. package/dist/types/providers/pi-native-server.d.ts +69 -0
  106. package/dist/types/providers/register-builtins.d.ts +32 -0
  107. package/dist/types/providers/synthetic.d.ts +26 -0
  108. package/dist/types/providers/transform-messages.d.ts +11 -0
  109. package/dist/types/providers/vision-guard.d.ts +20 -0
  110. package/dist/types/registry/aimlapi.d.ts +4 -0
  111. package/dist/types/registry/alibaba-coding-plan.d.ts +8 -0
  112. package/dist/types/registry/amazon-bedrock.d.ts +5 -0
  113. package/dist/types/registry/anthropic.d.ts +10 -0
  114. package/dist/types/registry/api-key-login.d.ts +42 -0
  115. package/dist/types/registry/api-key-validation.d.ts +43 -0
  116. package/dist/types/registry/azure.d.ts +4 -0
  117. package/dist/types/registry/cerebras.d.ts +7 -0
  118. package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
  119. package/dist/types/registry/coreweave.d.ts +7 -0
  120. package/dist/types/registry/cursor.d.ts +7 -0
  121. package/dist/types/registry/deepseek.d.ts +8 -0
  122. package/dist/types/registry/derived.d.ts +5 -0
  123. package/dist/types/registry/devin.d.ts +8 -0
  124. package/dist/types/registry/firepass.d.ts +16 -0
  125. package/dist/types/registry/fireworks.d.ts +7 -0
  126. package/dist/types/registry/github-copilot.d.ts +7 -0
  127. package/dist/types/registry/gitlab-duo-workflow.d.ts +10 -0
  128. package/dist/types/registry/gitlab-duo.d.ts +9 -0
  129. package/dist/types/registry/google-antigravity.d.ts +9 -0
  130. package/dist/types/registry/google-gemini-cli.d.ts +9 -0
  131. package/dist/types/registry/google-vertex.d.ts +5 -0
  132. package/dist/types/registry/google.d.ts +4 -0
  133. package/dist/types/registry/groq.d.ts +4 -0
  134. package/dist/types/registry/huggingface.d.ts +7 -0
  135. package/dist/types/registry/index.d.ts +4 -0
  136. package/dist/types/registry/kagi.d.ts +14 -0
  137. package/dist/types/registry/kilo.d.ts +7 -0
  138. package/dist/types/registry/kimi-code.d.ts +7 -0
  139. package/dist/types/registry/litellm.d.ts +13 -0
  140. package/dist/types/registry/llama-cpp.d.ts +8 -0
  141. package/dist/types/registry/lm-studio.d.ts +8 -0
  142. package/dist/types/registry/minimax-code-cn.d.ts +6 -0
  143. package/dist/types/registry/minimax-code.d.ts +6 -0
  144. package/dist/types/registry/minimax.d.ts +4 -0
  145. package/dist/types/registry/mistral.d.ts +4 -0
  146. package/dist/types/registry/moonshot.d.ts +7 -0
  147. package/dist/types/registry/nanogpt.d.ts +7 -0
  148. package/dist/types/registry/nvidia.d.ts +7 -0
  149. package/dist/types/registry/oauth/__tests__/xai-oauth.test.d.ts +1 -0
  150. package/dist/types/registry/oauth/anthropic.d.ts +23 -0
  151. package/dist/types/registry/oauth/callback-server.d.ts +72 -0
  152. package/dist/types/registry/oauth/cursor.d.ts +15 -0
  153. package/dist/types/registry/oauth/devin.d.ts +5 -0
  154. package/dist/types/registry/oauth/github-copilot.d.ts +30 -0
  155. package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +6 -0
  156. package/dist/types/registry/oauth/gitlab-duo.d.ts +3 -0
  157. package/dist/types/registry/oauth/google-antigravity.d.ts +11 -0
  158. package/dist/types/registry/oauth/google-gemini-cli.d.ts +10 -0
  159. package/dist/types/registry/oauth/google-oauth-shared.d.ts +22 -0
  160. package/dist/types/registry/oauth/index.d.ts +64 -0
  161. package/dist/types/registry/oauth/kimi.d.ts +21 -0
  162. package/dist/types/registry/oauth/minimax-code.d.ts +27 -0
  163. package/dist/types/registry/oauth/openai-codex.d.ts +33 -0
  164. package/dist/types/registry/oauth/opencode.d.ts +18 -0
  165. package/dist/types/registry/oauth/perplexity.d.ts +9 -0
  166. package/dist/types/registry/oauth/pkce.d.ts +8 -0
  167. package/dist/types/registry/oauth/types.d.ts +56 -0
  168. package/dist/types/registry/oauth/wafer.d.ts +1 -0
  169. package/dist/types/registry/oauth/xai-oauth.d.ts +52 -0
  170. package/dist/types/registry/oauth/xiaomi.d.ts +25 -0
  171. package/dist/types/registry/ollama-cloud.d.ts +7 -0
  172. package/dist/types/registry/ollama.d.ts +12 -0
  173. package/dist/types/registry/openai-codex-device.d.ts +8 -0
  174. package/dist/types/registry/openai-codex.d.ts +9 -0
  175. package/dist/types/registry/openai.d.ts +4 -0
  176. package/dist/types/registry/opencode-go.d.ts +6 -0
  177. package/dist/types/registry/opencode-zen.d.ts +6 -0
  178. package/dist/types/registry/openrouter.d.ts +13 -0
  179. package/dist/types/registry/parallel.d.ts +14 -0
  180. package/dist/types/registry/perplexity.d.ts +7 -0
  181. package/dist/types/registry/qianfan.d.ts +7 -0
  182. package/dist/types/registry/qwen-portal.d.ts +7 -0
  183. package/dist/types/registry/registry.d.ts +303 -0
  184. package/dist/types/registry/sakana.d.ts +7 -0
  185. package/dist/types/registry/synthetic.d.ts +6 -0
  186. package/dist/types/registry/tavily.d.ts +14 -0
  187. package/dist/types/registry/together.d.ts +6 -0
  188. package/dist/types/registry/types.d.ts +51 -0
  189. package/dist/types/registry/umans.d.ts +7 -0
  190. package/dist/types/registry/venice.d.ts +13 -0
  191. package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
  192. package/dist/types/registry/vllm.d.ts +7 -0
  193. package/dist/types/registry/wafer-serverless.d.ts +6 -0
  194. package/dist/types/registry/xai-oauth.d.ts +7 -0
  195. package/dist/types/registry/xai.d.ts +4 -0
  196. package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
  197. package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
  198. package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
  199. package/dist/types/registry/xiaomi.d.ts +6 -0
  200. package/dist/types/registry/zai.d.ts +7 -0
  201. package/dist/types/registry/zenmux.d.ts +7 -0
  202. package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
  203. package/dist/types/stream.d.ts +44 -0
  204. package/dist/types/types.d.ts +715 -0
  205. package/dist/types/usage/claude.d.ts +4 -0
  206. package/dist/types/usage/gemini.d.ts +2 -0
  207. package/dist/types/usage/github-copilot.d.ts +7 -0
  208. package/dist/types/usage/google-antigravity.d.ts +15 -0
  209. package/dist/types/usage/kimi.d.ts +2 -0
  210. package/dist/types/usage/minimax-code.d.ts +2 -0
  211. package/dist/types/usage/ollama.d.ts +5 -0
  212. package/dist/types/usage/openai-codex-base-url.d.ts +18 -0
  213. package/dist/types/usage/openai-codex-reset.d.ts +79 -0
  214. package/dist/types/usage/openai-codex.d.ts +3 -0
  215. package/dist/types/usage/opencode-go.d.ts +2 -0
  216. package/dist/types/usage/shared.d.ts +1 -0
  217. package/dist/types/usage/zai.d.ts +2 -0
  218. package/dist/types/usage.d.ts +346 -0
  219. package/dist/types/utils/abort.d.ts +25 -0
  220. package/dist/types/utils/anthropic-auth.d.ts +35 -0
  221. package/dist/types/utils/block-symbols.d.ts +20 -0
  222. package/dist/types/utils/deterministic-id.d.ts +16 -0
  223. package/dist/types/utils/empty-completion-retry.d.ts +21 -0
  224. package/dist/types/utils/event-stream.d.ts +30 -0
  225. package/dist/types/utils/foundry.d.ts +1 -0
  226. package/dist/types/utils/google-validation.d.ts +2 -0
  227. package/dist/types/utils/harmony-leak.d.ts +118 -0
  228. package/dist/types/utils/http-inspector.d.ts +30 -0
  229. package/dist/types/utils/idle-iterator.d.ts +137 -0
  230. package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
  231. package/dist/types/utils/openai-http.d.ts +54 -0
  232. package/dist/types/utils/openrouter-headers.d.ts +1 -0
  233. package/dist/types/utils/parse-bind.d.ts +23 -0
  234. package/dist/types/utils/provider-response.d.ts +3 -0
  235. package/dist/types/utils/proxy.d.ts +29 -0
  236. package/dist/types/utils/request-debug.d.ts +29 -0
  237. package/dist/types/utils/retry-after.d.ts +4 -0
  238. package/dist/types/utils/retry.d.ts +14 -0
  239. package/dist/types/utils/schema/adapt.d.ts +24 -0
  240. package/dist/types/utils/schema/compatibility.d.ts +30 -0
  241. package/dist/types/utils/schema/dereference.d.ts +11 -0
  242. package/dist/types/utils/schema/draft.d.ts +10 -0
  243. package/dist/types/utils/schema/equality.d.ts +4 -0
  244. package/dist/types/utils/schema/fields.d.ts +54 -0
  245. package/dist/types/utils/schema/index.d.ts +15 -0
  246. package/dist/types/utils/schema/json-schema-validator.d.ts +20 -0
  247. package/dist/types/utils/schema/meta-validator.d.ts +2 -0
  248. package/dist/types/utils/schema/normalize.d.ts +124 -0
  249. package/dist/types/utils/schema/spill.d.ts +8 -0
  250. package/dist/types/utils/schema/stamps.d.ts +17 -0
  251. package/dist/types/utils/schema/strict-tool-validation.d.ts +16 -0
  252. package/dist/types/utils/schema/types.d.ts +4 -0
  253. package/dist/types/utils/schema/typescript.d.ts +18 -0
  254. package/dist/types/utils/schema/wire.d.ts +92 -0
  255. package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
  256. package/dist/types/utils/sdk-stream-timeout.d.ts +33 -0
  257. package/dist/types/utils/sse-debug.d.ts +5 -0
  258. package/dist/types/utils/stream-markup-healing.d.ts +87 -0
  259. package/dist/types/utils/thinking-loop.d.ts +102 -0
  260. package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
  261. package/dist/types/utils/tool-choice.d.ts +50 -0
  262. package/dist/types/utils/validation.d.ts +42 -0
  263. package/dist/types/utils.d.ts +24 -0
  264. package/package.json +139 -0
  265. package/src/api-registry.ts +109 -0
  266. package/src/auth-broker/client.ts +359 -0
  267. package/src/auth-broker/discover.ts +222 -0
  268. package/src/auth-broker/index.ts +7 -0
  269. package/src/auth-broker/refresher.ts +117 -0
  270. package/src/auth-broker/remote-store.ts +657 -0
  271. package/src/auth-broker/server.ts +646 -0
  272. package/src/auth-broker/snapshot-cache.ts +191 -0
  273. package/src/auth-broker/types.ts +130 -0
  274. package/src/auth-broker/wire-schemas.ts +249 -0
  275. package/src/auth-gateway/http.ts +194 -0
  276. package/src/auth-gateway/index.ts +3 -0
  277. package/src/auth-gateway/server.ts +802 -0
  278. package/src/auth-gateway/types.ts +151 -0
  279. package/src/auth-retry.ts +250 -0
  280. package/src/auth-storage.ts +5576 -0
  281. package/src/dialect/anthropic.md +31 -0
  282. package/src/dialect/anthropic.ts +608 -0
  283. package/src/dialect/catalog.ts +29 -0
  284. package/src/dialect/coercion.ts +136 -0
  285. package/src/dialect/deepseek.md +24 -0
  286. package/src/dialect/deepseek.ts +609 -0
  287. package/src/dialect/demotion.ts +36 -0
  288. package/src/dialect/examples.ts +33 -0
  289. package/src/dialect/factory.ts +34 -0
  290. package/src/dialect/fenced-thinking.ts +184 -0
  291. package/src/dialect/gemini.md +44 -0
  292. package/src/dialect/gemini.ts +597 -0
  293. package/src/dialect/gemma.md +33 -0
  294. package/src/dialect/gemma.ts +387 -0
  295. package/src/dialect/glm.md +32 -0
  296. package/src/dialect/glm.ts +456 -0
  297. package/src/dialect/harmony.md +31 -0
  298. package/src/dialect/harmony.ts +346 -0
  299. package/src/dialect/hermes.md +25 -0
  300. package/src/dialect/hermes.ts +206 -0
  301. package/src/dialect/history.ts +81 -0
  302. package/src/dialect/index.ts +15 -0
  303. package/src/dialect/inventory.ts +73 -0
  304. package/src/dialect/kimi.md +24 -0
  305. package/src/dialect/kimi.ts +340 -0
  306. package/src/dialect/minimax.md +31 -0
  307. package/src/dialect/minimax.ts +95 -0
  308. package/src/dialect/owned-stream.ts +470 -0
  309. package/src/dialect/prompt-template.md +12 -0
  310. package/src/dialect/qwen3.md +28 -0
  311. package/src/dialect/qwen3.ts +240 -0
  312. package/src/dialect/rendering.ts +249 -0
  313. package/src/dialect/thinking.ts +122 -0
  314. package/src/dialect/types.ts +57 -0
  315. package/src/dialect/xml.md +22 -0
  316. package/src/dialect/xml.ts +90 -0
  317. package/src/error/abort.ts +18 -0
  318. package/src/error/auth-classify.ts +30 -0
  319. package/src/error/auth.ts +48 -0
  320. package/src/error/aws.ts +31 -0
  321. package/src/error/classes.ts +186 -0
  322. package/src/error/finalize.ts +69 -0
  323. package/src/error/flags.ts +506 -0
  324. package/src/error/format.ts +45 -0
  325. package/src/error/gateway.ts +96 -0
  326. package/src/error/index.ts +13 -0
  327. package/src/error/oauth.ts +58 -0
  328. package/src/error/provider.ts +62 -0
  329. package/src/error/rate-limit.ts +161 -0
  330. package/src/error/retryable.ts +70 -0
  331. package/src/error/validation.ts +44 -0
  332. package/src/index.ts +49 -0
  333. package/src/provider-details.ts +90 -0
  334. package/src/providers/__tests__/google-auth.test.ts +144 -0
  335. package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
  336. package/src/providers/__tests__/openai-codex-error.test.ts +84 -0
  337. package/src/providers/amazon-bedrock.ts +1042 -0
  338. package/src/providers/anthropic-client.ts +295 -0
  339. package/src/providers/anthropic-messages-server-schema.ts +252 -0
  340. package/src/providers/anthropic-messages-server.ts +756 -0
  341. package/src/providers/anthropic-wire.ts +318 -0
  342. package/src/providers/anthropic.ts +4078 -0
  343. package/src/providers/aws-credentials.ts +586 -0
  344. package/src/providers/aws-eventstream.ts +181 -0
  345. package/src/providers/aws-sigv4.ts +218 -0
  346. package/src/providers/azure-openai-responses.ts +382 -0
  347. package/src/providers/cursor/proto/agent.proto +3526 -0
  348. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  349. package/src/providers/cursor/proto/buf.yaml +17 -0
  350. package/src/providers/cursor.ts +2695 -0
  351. package/src/providers/devin/proto/buf/validate/validate.proto +468 -0
  352. package/src/providers/devin/proto/buf.gen.yaml +33 -0
  353. package/src/providers/devin/proto/buf.yaml +17 -0
  354. package/src/providers/devin/proto/cel/expr/checked.proto +103 -0
  355. package/src/providers/devin/proto/cel/expr/eval.proto +38 -0
  356. package/src/providers/devin/proto/cel/expr/explain.proto +15 -0
  357. package/src/providers/devin/proto/cel/expr/syntax.proto +113 -0
  358. package/src/providers/devin/proto/cel/expr/value.proto +41 -0
  359. package/src/providers/devin/proto/connectext/grpc/status/v1/status.proto +11 -0
  360. package/src/providers/devin/proto/errorspb/errors.proto +56 -0
  361. package/src/providers/devin/proto/errorspb/hintdetail.proto +7 -0
  362. package/src/providers/devin/proto/errorspb/markers.proto +10 -0
  363. package/src/providers/devin/proto/errorspb/tags.proto +12 -0
  364. package/src/providers/devin/proto/errorspb/testing.proto +6 -0
  365. package/src/providers/devin/proto/exa/analytics_pb/analytics.proto +188 -0
  366. package/src/providers/devin/proto/exa/api_server_pb/api_server.proto +2461 -0
  367. package/src/providers/devin/proto/exa/auth_pb/auth.proto +19 -0
  368. package/src/providers/devin/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +79 -0
  369. package/src/providers/devin/proto/exa/browser_preview_pb/browser_preview.proto +32 -0
  370. package/src/providers/devin/proto/exa/bug_checker_pb/bug_checker.proto +22 -0
  371. package/src/providers/devin/proto/exa/cascade_plugins_pb/cascade_plugins.proto +262 -0
  372. package/src/providers/devin/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
  373. package/src/providers/devin/proto/exa/chat_pb/chat.proto +449 -0
  374. package/src/providers/devin/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
  375. package/src/providers/devin/proto/exa/codeium_common_pb/codeium_common.proto +4157 -0
  376. package/src/providers/devin/proto/exa/context_module_pb/context_module.proto +175 -0
  377. package/src/providers/devin/proto/exa/cortex_pb/cortex.proto +3268 -0
  378. package/src/providers/devin/proto/exa/dev_pb/dev.proto +26 -0
  379. package/src/providers/devin/proto/exa/diff_action_pb/diff_action.proto +75 -0
  380. package/src/providers/devin/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +103 -0
  381. package/src/providers/devin/proto/exa/eval_pb/eval.proto +1315 -0
  382. package/src/providers/devin/proto/exa/extension_server_pb/extension_server.proto +556 -0
  383. package/src/providers/devin/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
  384. package/src/providers/devin/proto/exa/index_pb/index.proto +461 -0
  385. package/src/providers/devin/proto/exa/knowledge_base_pb/knowledge_base.proto +144 -0
  386. package/src/providers/devin/proto/exa/language_server_pb/language_server.proto +2385 -0
  387. package/src/providers/devin/proto/exa/model_management_pb/model_management.proto +186 -0
  388. package/src/providers/devin/proto/exa/opensearch_clients_pb/opensearch_clients.proto +503 -0
  389. package/src/providers/devin/proto/exa/product_analytics_pb/product_analytics.proto +37 -0
  390. package/src/providers/devin/proto/exa/prompt_pb/prompt.proto +92 -0
  391. package/src/providers/devin/proto/exa/reactive_component_pb/reactive_component.proto +96 -0
  392. package/src/providers/devin/proto/exa/seat_management_pb/seat_management.proto +2680 -0
  393. package/src/providers/devin/proto/exa/tokenizer_pb/tokenizer.proto +37 -0
  394. package/src/providers/devin/proto/exa/trainer_pb/config.proto +647 -0
  395. package/src/providers/devin/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
  396. package/src/providers/devin/proto/exa/trust_pb/trust.proto +157 -0
  397. package/src/providers/devin/proto/exa/user_analytics_pb/user_analytics.proto +519 -0
  398. package/src/providers/devin/proto/google.golang.org/appengine/internal/base/api_base.proto +28 -0
  399. package/src/providers/devin/proto/google.golang.org/appengine/internal/datastore/datastore_v3.proto +484 -0
  400. package/src/providers/devin/proto/google.golang.org/appengine/internal/log/log_service.proto +136 -0
  401. package/src/providers/devin/proto/google.golang.org/appengine/internal/remote_api/remote_api.proto +42 -0
  402. package/src/providers/devin/proto/google.golang.org/appengine/internal/urlfetch/urlfetch_service.proto +61 -0
  403. package/src/providers/devin/proto/grpc/binlog/v1/binarylog.proto +84 -0
  404. package/src/providers/devin/proto/io/prometheus/client/metrics.proto +98 -0
  405. package/src/providers/devin.ts +577 -0
  406. package/src/providers/error-message.ts +21 -0
  407. package/src/providers/github-copilot-headers.ts +141 -0
  408. package/src/providers/gitlab-duo-workflow-chatml-note.md +1 -0
  409. package/src/providers/gitlab-duo-workflow.ts +3058 -0
  410. package/src/providers/gitlab-duo.ts +395 -0
  411. package/src/providers/google-auth.ts +350 -0
  412. package/src/providers/google-gemini-cli.ts +1362 -0
  413. package/src/providers/google-interactions.ts +753 -0
  414. package/src/providers/google-shared.ts +1103 -0
  415. package/src/providers/google-types.ts +180 -0
  416. package/src/providers/google-vertex.ts +183 -0
  417. package/src/providers/google.ts +87 -0
  418. package/src/providers/grammar.ts +70 -0
  419. package/src/providers/kimi.ts +52 -0
  420. package/src/providers/mock.ts +507 -0
  421. package/src/providers/ollama.ts +773 -0
  422. package/src/providers/openai-anthropic-shim.ts +152 -0
  423. package/src/providers/openai-chat-server-schema.ts +242 -0
  424. package/src/providers/openai-chat-server.ts +715 -0
  425. package/src/providers/openai-chat-wire.ts +847 -0
  426. package/src/providers/openai-codex/request-transformer.ts +295 -0
  427. package/src/providers/openai-codex/response-handler.ts +102 -0
  428. package/src/providers/openai-codex-responses.ts +3468 -0
  429. package/src/providers/openai-completions.ts +2173 -0
  430. package/src/providers/openai-reasoning-fallback.ts +269 -0
  431. package/src/providers/openai-responses-reasoning-suppression.md +1 -0
  432. package/src/providers/openai-responses-server-schema.ts +282 -0
  433. package/src/providers/openai-responses-server.ts +1280 -0
  434. package/src/providers/openai-responses-wire.ts +6391 -0
  435. package/src/providers/openai-responses.ts +1022 -0
  436. package/src/providers/openai-shared.ts +2648 -0
  437. package/src/providers/pi-native-client.ts +266 -0
  438. package/src/providers/pi-native-server.ts +242 -0
  439. package/src/providers/register-builtins.ts +475 -0
  440. package/src/providers/synthetic.ts +50 -0
  441. package/src/providers/transform-messages.ts +787 -0
  442. package/src/providers/vision-guard.ts +54 -0
  443. package/src/registry/aimlapi.ts +6 -0
  444. package/src/registry/alibaba-coding-plan.ts +95 -0
  445. package/src/registry/amazon-bedrock.ts +22 -0
  446. package/src/registry/anthropic.ts +26 -0
  447. package/src/registry/api-key-login.ts +112 -0
  448. package/src/registry/api-key-validation.ts +161 -0
  449. package/src/registry/azure.ts +6 -0
  450. package/src/registry/cerebras.ts +23 -0
  451. package/src/registry/cloudflare-ai-gateway.ts +45 -0
  452. package/src/registry/coreweave.ts +40 -0
  453. package/src/registry/cursor.ts +20 -0
  454. package/src/registry/deepseek.ts +46 -0
  455. package/src/registry/derived.ts +9 -0
  456. package/src/registry/devin.ts +15 -0
  457. package/src/registry/firepass.ts +32 -0
  458. package/src/registry/fireworks.ts +28 -0
  459. package/src/registry/github-copilot.ts +22 -0
  460. package/src/registry/gitlab-duo-workflow.ts +20 -0
  461. package/src/registry/gitlab-duo.ts +19 -0
  462. package/src/registry/google-antigravity.ts +22 -0
  463. package/src/registry/google-gemini-cli.ts +22 -0
  464. package/src/registry/google-vertex.ts +38 -0
  465. package/src/registry/google.ts +6 -0
  466. package/src/registry/groq.ts +6 -0
  467. package/src/registry/huggingface.ts +29 -0
  468. package/src/registry/index.ts +4 -0
  469. package/src/registry/kagi.ts +46 -0
  470. package/src/registry/kilo.ts +114 -0
  471. package/src/registry/kimi-code.ts +17 -0
  472. package/src/registry/litellm.ts +45 -0
  473. package/src/registry/llama-cpp.ts +35 -0
  474. package/src/registry/lm-studio.ts +31 -0
  475. package/src/registry/minimax-code-cn.ts +12 -0
  476. package/src/registry/minimax-code.ts +12 -0
  477. package/src/registry/minimax.ts +6 -0
  478. package/src/registry/mistral.ts +6 -0
  479. package/src/registry/moonshot.ts +22 -0
  480. package/src/registry/nanogpt.ts +22 -0
  481. package/src/registry/nvidia.ts +61 -0
  482. package/src/registry/oauth/__tests__/xai-oauth.test.ts +104 -0
  483. package/src/registry/oauth/anthropic.ts +311 -0
  484. package/src/registry/oauth/callback-server.ts +315 -0
  485. package/src/registry/oauth/cursor.ts +171 -0
  486. package/src/registry/oauth/devin.ts +124 -0
  487. package/src/registry/oauth/github-copilot.ts +369 -0
  488. package/src/registry/oauth/gitlab-duo-workflow.ts +146 -0
  489. package/src/registry/oauth/gitlab-duo.ts +222 -0
  490. package/src/registry/oauth/google-antigravity.ts +209 -0
  491. package/src/registry/oauth/google-gemini-cli.ts +273 -0
  492. package/src/registry/oauth/google-oauth-shared.ts +125 -0
  493. package/src/registry/oauth/index.ts +269 -0
  494. package/src/registry/oauth/kimi.ts +289 -0
  495. package/src/registry/oauth/minimax-code.ts +53 -0
  496. package/src/registry/oauth/oauth.html +311 -0
  497. package/src/registry/oauth/openai-codex.ts +364 -0
  498. package/src/registry/oauth/opencode.ts +50 -0
  499. package/src/registry/oauth/perplexity.ts +228 -0
  500. package/src/registry/oauth/pkce.ts +18 -0
  501. package/src/registry/oauth/types.ts +65 -0
  502. package/src/registry/oauth/wafer.ts +24 -0
  503. package/src/registry/oauth/xai-oauth.ts +394 -0
  504. package/src/registry/oauth/xiaomi.ts +211 -0
  505. package/src/registry/ollama-cloud.ts +36 -0
  506. package/src/registry/ollama.ts +43 -0
  507. package/src/registry/openai-codex-device.ts +18 -0
  508. package/src/registry/openai-codex.ts +19 -0
  509. package/src/registry/openai.ts +6 -0
  510. package/src/registry/opencode-go.ts +12 -0
  511. package/src/registry/opencode-zen.ts +12 -0
  512. package/src/registry/openrouter.ts +28 -0
  513. package/src/registry/parallel.ts +45 -0
  514. package/src/registry/perplexity.ts +13 -0
  515. package/src/registry/qianfan.ts +27 -0
  516. package/src/registry/qwen-portal.ts +50 -0
  517. package/src/registry/registry.ts +161 -0
  518. package/src/registry/sakana.ts +22 -0
  519. package/src/registry/synthetic.ts +21 -0
  520. package/src/registry/tavily.ts +45 -0
  521. package/src/registry/together.ts +22 -0
  522. package/src/registry/types.ts +56 -0
  523. package/src/registry/umans.ts +23 -0
  524. package/src/registry/venice.ts +33 -0
  525. package/src/registry/vercel-ai-gateway.ts +38 -0
  526. package/src/registry/vllm.ts +34 -0
  527. package/src/registry/wafer-serverless.ts +12 -0
  528. package/src/registry/xai-oauth.ts +17 -0
  529. package/src/registry/xai.ts +6 -0
  530. package/src/registry/xiaomi-token-plan-ams.ts +12 -0
  531. package/src/registry/xiaomi-token-plan-cn.ts +12 -0
  532. package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
  533. package/src/registry/xiaomi.ts +12 -0
  534. package/src/registry/zai.ts +27 -0
  535. package/src/registry/zenmux.ts +22 -0
  536. package/src/registry/zhipu-coding-plan.ts +27 -0
  537. package/src/stream.ts +1778 -0
  538. package/src/types.ts +856 -0
  539. package/src/usage/claude.ts +485 -0
  540. package/src/usage/gemini.ts +258 -0
  541. package/src/usage/github-copilot.ts +424 -0
  542. package/src/usage/google-antigravity.ts +497 -0
  543. package/src/usage/kimi.ts +271 -0
  544. package/src/usage/minimax-code.ts +30 -0
  545. package/src/usage/ollama.ts +41 -0
  546. package/src/usage/openai-codex-base-url.ts +35 -0
  547. package/src/usage/openai-codex-reset.ts +174 -0
  548. package/src/usage/openai-codex.ts +535 -0
  549. package/src/usage/opencode-go.ts +89 -0
  550. package/src/usage/shared.ts +10 -0
  551. package/src/usage/zai.ts +321 -0
  552. package/src/usage.ts +333 -0
  553. package/src/utils/abort.ts +67 -0
  554. package/src/utils/anthropic-auth.ts +93 -0
  555. package/src/utils/block-symbols.ts +32 -0
  556. package/src/utils/deterministic-id.ts +20 -0
  557. package/src/utils/empty-completion-retry.ts +159 -0
  558. package/src/utils/event-stream.ts +171 -0
  559. package/src/utils/foundry.ts +8 -0
  560. package/src/utils/google-validation.ts +25 -0
  561. package/src/utils/harmony-leak.ts +456 -0
  562. package/src/utils/http-inspector.ts +168 -0
  563. package/src/utils/idle-iterator.ts +473 -0
  564. package/src/utils/leaked-thinking-stream.ts +294 -0
  565. package/src/utils/openai-http.ts +122 -0
  566. package/src/utils/openrouter-headers.ts +12 -0
  567. package/src/utils/parse-bind.ts +56 -0
  568. package/src/utils/provider-response.ts +30 -0
  569. package/src/utils/proxy.ts +240 -0
  570. package/src/utils/request-debug.ts +351 -0
  571. package/src/utils/retry-after.ts +110 -0
  572. package/src/utils/retry.ts +59 -0
  573. package/src/utils/schema/CONSTRAINTS.md +166 -0
  574. package/src/utils/schema/adapt.ts +36 -0
  575. package/src/utils/schema/compatibility.ts +435 -0
  576. package/src/utils/schema/dereference.ts +98 -0
  577. package/src/utils/schema/draft.ts +341 -0
  578. package/src/utils/schema/equality.ts +97 -0
  579. package/src/utils/schema/fields.ts +207 -0
  580. package/src/utils/schema/index.ts +15 -0
  581. package/src/utils/schema/json-schema-validator.ts +595 -0
  582. package/src/utils/schema/meta-validator.ts +167 -0
  583. package/src/utils/schema/normalize.ts +1901 -0
  584. package/src/utils/schema/spill.ts +43 -0
  585. package/src/utils/schema/stamps.ts +109 -0
  586. package/src/utils/schema/strict-tool-validation.ts +117 -0
  587. package/src/utils/schema/types.ts +10 -0
  588. package/src/utils/schema/typescript.ts +198 -0
  589. package/src/utils/schema/wire.ts +789 -0
  590. package/src/utils/schema/zod-decontaminate.ts +331 -0
  591. package/src/utils/sdk-stream-timeout.ts +43 -0
  592. package/src/utils/sse-debug.ts +18 -0
  593. package/src/utils/stream-markup-healing.ts +247 -0
  594. package/src/utils/thinking-loop.ts +552 -0
  595. package/src/utils/tool-call-loop-guard.ts +107 -0
  596. package/src/utils/tool-choice.ts +99 -0
  597. package/src/utils/validation.ts +1507 -0
  598. package/src/utils.ts +171 -0
@@ -0,0 +1,3058 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ discoverGitLabDuoWorkflowRuntimeNamespace,
5
+ type GitLabDuoWorkflowNamespaceSelection,
6
+ } from "jeopi-catalog/discovery/gitlab-duo-workflow";
7
+ import * as AIError from "../error";
8
+ import type {
9
+ Api,
10
+ AssistantMessage,
11
+ Context,
12
+ FetchImpl,
13
+ Message,
14
+ Model,
15
+ ProviderSessionState,
16
+ StreamFunction,
17
+ StreamOptions,
18
+ Tool,
19
+ ToolCall,
20
+ ToolChoice,
21
+ ToolResultMessage,
22
+ } from "../types";
23
+ import { normalizeSystemPrompts } from "../utils";
24
+ import { AssistantMessageEventStream } from "../utils/event-stream";
25
+ import { toolWireSchema } from "../utils/schema/wire";
26
+ import chatmlHistoryNote from "./gitlab-duo-workflow-chatml-note.md" with { type: "text" };
27
+
28
+ export const GITLAB_DUO_WORKFLOW_PROVIDER_ID = "gitlab-duo-agent";
29
+ export const GITLAB_DUO_WORKFLOW_API = "gitlab-duo-agent";
30
+ export const GITLAB_DUO_WORKFLOW_DEFINITION = "ambient";
31
+ export type GitLabDuoWorkflowDefinition = "ambient" | (string & {});
32
+
33
+ const DEFAULT_GITLAB_BASE_URL = "https://gitlab.com";
34
+ const GITLAB_DUO_WORKFLOW_TRACE_ENV = "GITLAB_DUO_WORKFLOW_TRACE";
35
+ const GITLAB_DUO_WORKFLOW_TRACE_FILE_ENV = "GITLAB_DUO_WORKFLOW_TRACE_FILE";
36
+ const DEFAULT_GITLAB_DUO_WORKFLOW_TRACE_FILE = path.resolve(
37
+ import.meta.dir,
38
+ "../../../../.tmp/gitlab-duo-workflow-trace.log",
39
+ );
40
+ const GITLAB_DUO_WORKFLOW_CLIENT_TYPE = "node-websocket";
41
+ /**
42
+ * Idle deadline for the workflow WebSocket. The socket has no server-side
43
+ * keepalive contract OMP can rely on, so a connection silently going half-open
44
+ * (proxy/LB drops the TCP link without delivering FIN/RST) would otherwise leave
45
+ * `runGitLabDuoWorkflowSocket` waiting forever. If no frame arrives within this
46
+ * window — before open or between checkpoints — the socket is aborted and the
47
+ * run reconnects once on the same `workflowID` (server-side resume).
48
+ */
49
+ const GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS = 90_000;
50
+ /**
51
+ * How many times a single stream may restart on a FRESH workflow after the server
52
+ * reports its per-workflow step (graph-recursion) limit. Long OMP tool-call loops
53
+ * legitimately overrun the cap; each restart resets the budget. Bounded so a task
54
+ * that perpetually overruns degrades to a graceful stop instead of looping on quota.
55
+ */
56
+ const GITLAB_DUO_WORKFLOW_MAX_STEP_LIMIT_RESTARTS = 4;
57
+ /**
58
+ * How many times a single stream may restart on a FRESH workflow after the server
59
+ * returns its de-identified catch-all FAILED (transient upstream fault wrapper).
60
+ * Kept low because, unlike the step limit, a generic failure that repeats is more
61
+ * likely deterministic; one bounded retry covers the common transient case without
62
+ * looping on quota.
63
+ */
64
+ const GITLAB_DUO_WORKFLOW_MAX_GENERIC_ERROR_RETRIES = 1;
65
+ /**
66
+ * How many times a single stream may restart on a FRESH workflow after detecting a
67
+ * stalled workflow: the server emitted a fresh checkpoint at a tool-call boundary
68
+ * but its `ui_chat_log` total did NOT advance past the previous tool-call boundary
69
+ * of the SAME workflow. A healthy run strictly grows the log each turn (agent
70
+ * reasoning + tool boundary entries); a flat total means the server-side turn did
71
+ * not progress — the model re-issues the same tool call against a history that
72
+ * never gained its prior call/result (captured live: total pinned at 2 while the
73
+ * model repeated `next_step({"n":1})`). Restarting on a fresh workflow resends the
74
+ * full goal transcript (rebuilt from the agent loop's intact `context.messages`,
75
+ * so no in-flight tool result is lost) and the new run progresses. Bounded so a
76
+ * persistently stalling endpoint degrades to a surfaced result instead of a quota
77
+ * sink.
78
+ */
79
+ const GITLAB_DUO_WORKFLOW_MAX_STALL_RESTARTS = 2;
80
+ /**
81
+ * Surfaced when a workflow stalled (its `ui_chat_log` total stopped advancing) and
82
+ * every bounded fresh-workflow restart also stalled. Phrased as a transient
83
+ * server-side failure so the agent loop treats it as a normal error rather than a
84
+ * client bug.
85
+ */
86
+ const GITLAB_DUO_WORKFLOW_STALL_ERROR_MESSAGE =
87
+ "GitLab Duo Agent stopped making progress (the workflow's visible history did not advance after multiple restarts).";
88
+ /**
89
+ * Two rendered-`goal` byte thresholds bounding three reliability zones. Empirically
90
+ * the DWS/Workhorse transport accepts no fixed token wall (it has tokenized
91
+ * 970k-token goals) but its failure probability rises with the rendered-goal BYTE
92
+ * size: ≤~1MB is the reliable floor we now treat as the auto-compaction trigger,
93
+ * ~1.4–1.7MB is a jitter band where a request fails more often than not but can still
94
+ * go through, ≥~2MB basically always fails, and 4MB is the DWS gRPC `MAX_MESSAGE_SIZE`
95
+ * hard cap. The soft threshold was lowered from 1.25MB to 1MB because the higher value
96
+ * almost never fired in practice — auto-compaction needs to engage earlier.
97
+ *
98
+ * - `[0, SOFT)` reliable zone: send normally; an error here is a genuine upstream
99
+ * fault and surfaces verbatim.
100
+ * - `[SOFT, HARD)` jitter zone: still attempt once (it can succeed); if the run then
101
+ * ERRORS, the size is the likely cause, so re-label it as a context-overflow to
102
+ * drive auto-compaction.
103
+ * - `[HARD, ∞)` necessary-fail zone: do NOT spend the request — proactively end the
104
+ * stream with the overflow error so the session compacts immediately.
105
+ *
106
+ * `SOFT` is the auto-compaction trigger floor; `HARD` is the necessary-fail floor.
107
+ * Re-labeling uses {@link buildGitLabDuoWorkflowGoalOverflowMessage}.
108
+ */
109
+ const GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES = 1_048_576;
110
+ const GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES = 2_000_000;
111
+
112
+ // An overflow-pattern message for an oversized goal. The "prompt is too long" prefix
113
+ // is one of the shared overflow classifier patterns, so
114
+ // `isContextOverflow` recognizes it and the session triggers auto-compaction instead
115
+ // of surfacing a hard failure. Byte counts (not tokens) are reported because the
116
+ // budget is a byte budget.
117
+ function buildGitLabDuoWorkflowGoalOverflowMessage(goalBytes: number): string {
118
+ return `prompt is too long: ${goalBytes} bytes exceeds the GitLab Duo Agent goal byte budget (soft ${GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES}, hard ${GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES})`;
119
+ }
120
+ const GITLAB_DUO_WORKFLOW_LANGUAGE_SERVER_VERSION = "8.104.0";
121
+ const GITLAB_DUO_WORKFLOW_AVAILABLE_MODELS_QUERY = `query omp_gitlabDuoWorkflowAvailableModels($rootNamespaceId: GroupID!) {
122
+ aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
123
+ defaultModel { name ref }
124
+ selectableModels { name ref }
125
+ pinnedModel { name ref }
126
+ }
127
+ }`;
128
+
129
+ export const GITLAB_DUO_WORKFLOW_CLIENT_CAPABILITIES = [
130
+ "incremental_streaming",
131
+ "read_file_chunked",
132
+ "shell_command",
133
+ "command_timeout",
134
+ "tool_call_approval",
135
+ ] as const;
136
+
137
+ const GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME = "omp_agent";
138
+ const GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID = "omp_inline_prompt";
139
+ // `on_agent_reasoning` is what makes the server tag an agent's pre-tool-call
140
+ // commentary as `message_sub_type: "reasoning"` — the chain-of-thought the
141
+ // official Duo CLI surfaces. An inline flow must opt in explicitly.
142
+ const GITLAB_DUO_WORKFLOW_INLINE_UI_LOG_EVENTS = [
143
+ "on_agent_reasoning",
144
+ "on_agent_final_answer",
145
+ "on_tool_execution_success",
146
+ "on_tool_execution_failed",
147
+ ] as const;
148
+
149
+ const GITLAB_DUO_WORKFLOW_ACTION_NAMES = ["runMCPTool", "run_mcp_tool"] as const;
150
+
151
+ export interface GitLabMcpToolArgs {
152
+ name?: string;
153
+ tool_name?: string;
154
+ toolName?: string;
155
+ providerIdentifier?: string;
156
+ provider_identifier?: string;
157
+ toolCallId?: string;
158
+ tool_call_id?: string;
159
+ args?: Record<string, unknown> | string;
160
+ arguments?: Record<string, unknown> | string;
161
+ }
162
+
163
+ export interface GitLabPlainTextResponse {
164
+ response?: string;
165
+ error?: string;
166
+ }
167
+
168
+ export type PlainTextResponse = GitLabPlainTextResponse;
169
+ export interface GitLabDuoWorkflowOptions extends StreamOptions {
170
+ rootNamespaceId?: string;
171
+ namespaceId?: string;
172
+ projectId?: string;
173
+ projectPath?: string;
174
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
175
+ workflowId?: string;
176
+ workflowToken?: string;
177
+ cwd?: string;
178
+ webSocketFactory?: GitLabDuoWorkflowWebSocketFactory;
179
+ /** Idle WebSocket deadline (ms) before aborting and resuming; defaults to {@link GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS}. */
180
+ idleTimeoutMs?: number;
181
+ /**
182
+ * Tool-choice override forwarded from the stream layer. Only `"none"` is
183
+ * acted on: a side-request (e.g. handoff) keeps tool definitions in the cache
184
+ * prefix but disables tool use, so the provider must not advertise them to Duo.
185
+ */
186
+ toolChoice?: ToolChoice;
187
+ }
188
+
189
+ export interface GitLabDuoWorkflowWebSocketLike {
190
+ readyState?: number;
191
+ binaryType?: string;
192
+ onopen: ((event: Event) => void) | null;
193
+ onmessage: ((event: MessageEvent) => void) | null;
194
+ onerror: ((event: Event) => void) | null;
195
+ onclose: ((event: CloseEvent) => void) | null;
196
+ send(data: string): void;
197
+ close(code?: number, reason?: string): void;
198
+ }
199
+
200
+ export interface GitLabDuoWorkflowWebSocketFactoryOptions {
201
+ headers: Record<string, string>;
202
+ protocols?: string[];
203
+ }
204
+
205
+ export type GitLabDuoWorkflowWebSocketFactory = (
206
+ url: string,
207
+ options: GitLabDuoWorkflowWebSocketFactoryOptions,
208
+ ) => GitLabDuoWorkflowWebSocketLike;
209
+
210
+ export interface GitLabDirectAccessResponse {
211
+ token?: string;
212
+ access_token?: string;
213
+ jwt?: string;
214
+ workflow_token?: string;
215
+ duo_workflow_access_token?: string;
216
+ duo_workflow_service?: { token?: string; base_url?: string; headers?: Record<string, string> };
217
+ gitlab_rails?: { token?: string };
218
+ [key: string]: unknown;
219
+ }
220
+
221
+ interface GitLabDuoWorkflowDirectAccessConnection {
222
+ token: string;
223
+ baseUrl?: string;
224
+ headers: Record<string, string>;
225
+ serviceEndpoint: boolean;
226
+ }
227
+
228
+ interface GitLabCreateWorkflowResponse {
229
+ id?: string | number;
230
+ workflow_id?: string | number;
231
+ workflowId?: string | number;
232
+ [key: string]: unknown;
233
+ }
234
+
235
+ interface GitLabDuoWorkflowCreateBodyOptions {
236
+ projectId?: string;
237
+ goal?: string;
238
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
239
+ }
240
+
241
+ interface GitLabDuoWorkflowStartMetadataOptions {
242
+ projectId?: string;
243
+ projectPath?: string;
244
+ namespaceId?: string;
245
+ rootNamespaceId?: string;
246
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
247
+ inlineFlow?: boolean;
248
+ }
249
+ export interface GitLabMcpToolDefinition {
250
+ name: string;
251
+ originalToolName: string;
252
+ serverName: string;
253
+ description: string;
254
+ inputSchema: string;
255
+ isApproved: boolean;
256
+ }
257
+
258
+ export interface GitLabDuoWorkflowAdditionalContextItem {
259
+ id: string;
260
+ category: "agent_user_environment" | "user_rule";
261
+ content: string;
262
+ metadata: {
263
+ title: string;
264
+ enabled: boolean;
265
+ subType: "snippet";
266
+ icon: string;
267
+ secondaryText: string;
268
+ subTypeLabel: string;
269
+ };
270
+ }
271
+
272
+ export interface GitLabDuoWorkflowStartRequest {
273
+ workflowID: string;
274
+ clientVersion: "1.0";
275
+ workflowDefinition: GitLabDuoWorkflowDefinition;
276
+ goal: string;
277
+ workflowMetadata: string;
278
+ additional_context: readonly GitLabDuoWorkflowAdditionalContextItem[];
279
+ approval?: {
280
+ approval?: Record<string, never>;
281
+ rejection?: { message?: string };
282
+ };
283
+ clientCapabilities: readonly (typeof GITLAB_DUO_WORKFLOW_CLIENT_CAPABILITIES)[number][];
284
+ mcpTools: GitLabMcpToolDefinition[];
285
+ preapproved_tools: string[];
286
+ flowConfigSchemaVersion?: "v1";
287
+ flowConfigId?: string;
288
+ flowVersion?: string;
289
+ flowConfig?: GitLabDuoWorkflowInlineFlowConfig;
290
+ }
291
+
292
+ export interface GitLabDuoWorkflowInlineFlowComponent {
293
+ name: string;
294
+ type: "AgentComponent";
295
+ prompt_id: string;
296
+ toolset: string[];
297
+ inputs: { from: string; as: string }[];
298
+ ui_log_events: string[];
299
+ }
300
+
301
+ export interface GitLabDuoWorkflowInlineFlowPrompt {
302
+ name: string;
303
+ prompt_id: string;
304
+ unit_primitives: string[];
305
+ prompt_template: { system: string; user: string; placeholder: string };
306
+ }
307
+
308
+ export interface GitLabDuoWorkflowInlineFlowConfig {
309
+ version: "v1";
310
+ environment: "ambient";
311
+ flow: { entry_point: string };
312
+ components: GitLabDuoWorkflowInlineFlowComponent[];
313
+ routers: { from: string; to: string }[];
314
+ prompts: GitLabDuoWorkflowInlineFlowPrompt[];
315
+ }
316
+
317
+ export interface GitLabDuoWorkflowActionResponse {
318
+ actionResponse: {
319
+ requestID: string;
320
+ plainTextResponse?: GitLabPlainTextResponse;
321
+ };
322
+ }
323
+
324
+ interface GitLabDuoWorkflowActionDescriptor {
325
+ requestID: string;
326
+ name: string;
327
+ args: unknown;
328
+ }
329
+
330
+ export interface GitLabDuoWorkflowActiveSession {
331
+ workflowId: string;
332
+ startPayload: GitLabDuoWorkflowStartRequest;
333
+ ws: GitLabDuoWorkflowWebSocketLike;
334
+ // Best-effort server-side stop for THIS workflow, captured with its own
335
+ // fetch/baseUrl/apiKey so `ProviderSessionState.close()` (session reset/dispose)
336
+ // can stop a workflow the server is still running, even though it holds none of
337
+ // that context itself. Fire-and-forget; never throws.
338
+ stop?: () => void;
339
+ pendingActions?: GitLabDuoWorkflowActionDescriptor[];
340
+ checkpointAgentContentByKey?: Record<string, string>;
341
+ checkpointAgentContentSignatures?: Record<string, true>;
342
+ paused?: boolean;
343
+ pauseBuffer?: unknown[];
344
+ // Byte length of the server's last checkpoint observed at this workflow's tool-call
345
+ // boundaries. The control experiment proved a healthy turn emits checkpoints whose
346
+ // byte size varies and progresses, while a stalled workflow re-emits a byte-identical
347
+ // checkpoint — so equal lengths across consecutive boundaries flag a stall (see
348
+ // GITLAB_DUO_WORKFLOW_MAX_STALL_RESTARTS). Persisted on the session so the comparison
349
+ // survives the resume that reuses this socket.
350
+ lastToolBoundaryContentLength?: number;
351
+ }
352
+
353
+ export interface GitLabDuoWorkflowProviderSessionState extends ProviderSessionState {
354
+ active?: GitLabDuoWorkflowActiveSession;
355
+ }
356
+
357
+ export interface GitLabDuoWorkflowStreamState {
358
+ stream: AssistantMessageEventStream;
359
+ output: AssistantMessage;
360
+ activeTextIndex?: number;
361
+ activeThinkingIndex?: number;
362
+ activeCheckpointMessageKey?: string;
363
+ started: boolean;
364
+ checkpointAgentContentByKey?: Record<string, string>;
365
+ checkpointAgentContentSignatures?: Record<string, true>;
366
+ pauseRequested?: boolean;
367
+ stepLimitRequested?: boolean;
368
+ retryableErrorRequested?: boolean;
369
+ // Byte length of the server's latest checkpoint seen this socket run; the action
370
+ // handler compares it against the previous tool-call boundary's length to detect a
371
+ // stall (a byte-identical checkpoint means the server-side turn did not advance).
372
+ lastCheckpointContentLength?: number;
373
+ // Set when a tool-call boundary's checkpoint byte length did not change from the
374
+ // previous boundary — the socket settles "stalled" so the run restarts fresh.
375
+ stalledRequested?: boolean;
376
+ providerSessionState?: GitLabDuoWorkflowProviderSessionState;
377
+ lastApprovalStatus?: string;
378
+ // When the rendered goal exceeds the byte budget, this carries an overflow-pattern
379
+ // message. A terminal/exhausted error then surfaces THIS instead of the raw server
380
+ // error so `isContextOverflow` recognizes it and the agent loop auto-compacts. Left
381
+ // undefined for a goal within budget, so ordinary errors surface verbatim.
382
+ goalOverflowMessage?: string;
383
+ }
384
+
385
+ type GitLabDuoWorkflowSocketResult =
386
+ | "closed"
387
+ | "terminal"
388
+ | "approval"
389
+ | "action"
390
+ | "pause"
391
+ | "timeout"
392
+ | "step_limit"
393
+ | "retryable_error"
394
+ | "stalled";
395
+
396
+ export interface GitLabAvailableModel {
397
+ name?: string | null;
398
+ ref?: string | null;
399
+ }
400
+
401
+ export interface GitLabAvailableModelsPayload {
402
+ pinnedModel?: GitLabAvailableModel | null;
403
+ selectedModel?: GitLabAvailableModel | null;
404
+ defaultModel?: GitLabAvailableModel | null;
405
+ selectableModels?: GitLabAvailableModel[] | null;
406
+ }
407
+
408
+ export const streamGitLabDuoWorkflow: StreamFunction<"gitlab-duo-agent"> = (
409
+ model: Model<"gitlab-duo-agent">,
410
+ context: Context,
411
+ options: GitLabDuoWorkflowOptions,
412
+ ): AssistantMessageEventStream => {
413
+ const stream = new AssistantMessageEventStream();
414
+ const output = createAssistantMessage(model);
415
+ stream.push({ type: "start", partial: output });
416
+ const state: GitLabDuoWorkflowStreamState = { stream, output, started: true };
417
+
418
+ void runGitLabDuoWorkflow(model, context, options, state).catch(error => {
419
+ const errorText = gitLabDuoWorkflowErrorText(error);
420
+ if (!stream.done) {
421
+ output.stopReason = "error";
422
+ // A throw (socket reject, abnormal 1006 close, …) on a goal already past the
423
+ // byte budget is almost certainly the oversized request — surface it as a
424
+ // context-overflow so the session auto-compacts rather than hard-failing.
425
+ output.errorMessage = state.goalOverflowMessage ?? errorText;
426
+ stream.push({ type: "error", reason: "error", error: output });
427
+ }
428
+ });
429
+
430
+ return stream;
431
+ };
432
+
433
+ export function buildGitLabDuoWorkflowDirectAccessBody(
434
+ rootNamespaceId: string,
435
+ projectId?: string,
436
+ workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
437
+ ): Record<string, string> {
438
+ return {
439
+ workflow_definition: workflowDefinition,
440
+ root_namespace_id: toGitLabGraphQLNamespaceId(rootNamespaceId),
441
+ ...(projectId ? { project_id: projectId } : undefined),
442
+ };
443
+ }
444
+
445
+ export function buildGitLabDuoWorkflowCreateBody(
446
+ namespaceId?: string,
447
+ options: GitLabDuoWorkflowCreateBodyOptions = {},
448
+ ): Record<string, string | boolean | string[] | number[]> {
449
+ return {
450
+ workflow_definition: options.workflowDefinition ?? GITLAB_DUO_WORKFLOW_DEFINITION,
451
+ environment: "ide",
452
+ allow_agent_to_request_user: false,
453
+ agent_privileges: [6],
454
+ pre_approved_agent_privileges: [6],
455
+ requires_duo_cli_enabled: false,
456
+ ...(namespaceId && !options.projectId ? { namespace_id: namespaceId } : undefined),
457
+ ...(options.projectId ? { project_id: options.projectId } : undefined),
458
+ ...(options.goal !== undefined ? { goal: options.goal } : { goal: "" }),
459
+ };
460
+ }
461
+
462
+ export function buildGitLabDuoWorkflowStopBody(): Record<string, string> {
463
+ return { status_event: "stop" };
464
+ }
465
+
466
+ export function buildGitLabDuoWorkflowWebSocketUrl(
467
+ baseUrl: string,
468
+ options: {
469
+ projectId?: string;
470
+ namespaceId?: string;
471
+ rootNamespaceId?: string;
472
+ selectedModelIdentifier?: string;
473
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
474
+ serviceEndpoint?: boolean;
475
+ } = {},
476
+ ): string {
477
+ // serviceEndpoint connects to the DWS runway host (root path); otherwise route to the
478
+ // GitLab instance, preserving any relative install base path (e.g. `https://host/gitlab`).
479
+ const wsUrl = options.serviceEndpoint
480
+ ? new URL("/", normalizeGitLabBaseUrl(baseUrl))
481
+ : gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/ws");
482
+ wsUrl.protocol = wsUrl.protocol === "http:" ? "ws:" : "wss:";
483
+ if (options.projectId) wsUrl.searchParams.set("project_id", options.projectId);
484
+ if (options.namespaceId && !options.serviceEndpoint)
485
+ wsUrl.searchParams.set("namespace_id", toGitLabRestNamespaceId(options.namespaceId));
486
+ if (options.rootNamespaceId)
487
+ wsUrl.searchParams.set("root_namespace_id", toGitLabRestNamespaceId(options.rootNamespaceId));
488
+ if (options.selectedModelIdentifier)
489
+ wsUrl.searchParams.set("user_selected_model_identifier", options.selectedModelIdentifier);
490
+ if (options.workflowDefinition) wsUrl.searchParams.set("workflow_definition", options.workflowDefinition);
491
+ return wsUrl.toString();
492
+ }
493
+
494
+ export function buildGitLabDuoWorkflowWebSocketHeaders(options: {
495
+ token: string;
496
+ baseUrl?: string;
497
+ projectId?: string;
498
+ namespaceId?: string;
499
+ rootNamespaceId?: string;
500
+ extraHeaders?: Record<string, string>;
501
+ }): Record<string, string> {
502
+ const base = new URL(normalizeGitLabBaseUrl(options.baseUrl ?? DEFAULT_GITLAB_BASE_URL));
503
+ return {
504
+ ...options.extraHeaders,
505
+ authorization: `Bearer ${options.token}`,
506
+ "x-gitlab-client-type": GITLAB_DUO_WORKFLOW_CLIENT_TYPE,
507
+ "x-gitlab-language-server-version": GITLAB_DUO_WORKFLOW_LANGUAGE_SERVER_VERSION,
508
+ "user-agent": `unknown/unknown unknown/unknown gitlab-language-server/${GITLAB_DUO_WORKFLOW_LANGUAGE_SERVER_VERSION}`,
509
+ origin: base.origin,
510
+ ...(options.projectId ? { "x-gitlab-project-id": options.projectId } : {}),
511
+ ...(options.namespaceId ? { "x-gitlab-namespace-id": toGitLabRestNamespaceId(options.namespaceId) } : {}),
512
+ ...(options.rootNamespaceId
513
+ ? { "x-gitlab-root-namespace-id": toGitLabRestNamespaceId(options.rootNamespaceId) }
514
+ : {}),
515
+ };
516
+ }
517
+ export function buildGitLabDuoWorkflowStartRequest(
518
+ workflowId: string,
519
+ model: Model<"gitlab-duo-agent">,
520
+ context: Context,
521
+ tools: Tool[] | undefined = context.tools,
522
+ availableModels?: GitLabAvailableModelsPayload | null,
523
+ metadataOptions: GitLabDuoWorkflowStartMetadataOptions = {},
524
+ ): GitLabDuoWorkflowStartRequest {
525
+ const workflowMetadata = buildGitLabDuoWorkflowStartMetadata(model, availableModels, metadataOptions);
526
+ const mcpTools = buildGitLabDuoWorkflowMcpTools(tools);
527
+ return {
528
+ workflowID: workflowId,
529
+ clientVersion: "1.0",
530
+ workflowDefinition: metadataOptions.workflowDefinition ?? GITLAB_DUO_WORKFLOW_DEFINITION,
531
+ goal: buildGitLabDuoWorkflowGoal(context),
532
+ workflowMetadata: JSON.stringify(workflowMetadata),
533
+ additional_context: buildGitLabDuoWorkflowClientAdditionalContext(),
534
+ clientCapabilities: GITLAB_DUO_WORKFLOW_CLIENT_CAPABILITIES,
535
+ mcpTools,
536
+ preapproved_tools: mcpTools.map(tool => tool.name),
537
+ flowConfigSchemaVersion: "v1" as const,
538
+ flowConfig: buildGitLabDuoWorkflowInlineFlowConfig(buildGitLabDuoWorkflowSystemPrompt(context)),
539
+ };
540
+ }
541
+
542
+ // Build the inline ambient flow sent over the wire (Path B / `flowConfig`). The
543
+ // server constructs the whole flow from this struct: a single agent component
544
+ // whose system slot carries OMP's own authoritative system prompt (no GitLab jinja
545
+ // wrapper / project metadata) and `on_agent_reasoning` so pre-tool-call commentary
546
+ // streams back as reasoning. `toolset: []` because MCP tools auto-attach from
547
+ // `startRequest.mcpTools` when the workflow's `mcp_enabled` is true. The user slot
548
+ // is `{{goal}}`, which the provider fills with the flat conversation transcript.
549
+ export function buildGitLabDuoWorkflowInlineFlowConfig(systemPrompt: string): GitLabDuoWorkflowInlineFlowConfig {
550
+ return {
551
+ version: "v1",
552
+ environment: "ambient",
553
+ flow: { entry_point: GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME },
554
+ components: [
555
+ {
556
+ name: GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME,
557
+ type: "AgentComponent",
558
+ prompt_id: GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID,
559
+ toolset: [],
560
+ inputs: [{ from: "context:goal", as: "goal" }],
561
+ ui_log_events: [...GITLAB_DUO_WORKFLOW_INLINE_UI_LOG_EVENTS],
562
+ },
563
+ ],
564
+ routers: [{ from: GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME, to: "end" }],
565
+ prompts: [
566
+ {
567
+ name: GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID,
568
+ prompt_id: GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID,
569
+ unit_primitives: ["duo_agent_platform"],
570
+ prompt_template: { system: systemPrompt, user: "{{goal}}", placeholder: "history" },
571
+ },
572
+ ],
573
+ };
574
+ }
575
+
576
+ function buildGitLabDuoWorkflowStartMetadata(
577
+ model: Model<"gitlab-duo-agent">,
578
+ availableModels: GitLabAvailableModelsPayload | null | undefined,
579
+ metadataOptions: GitLabDuoWorkflowStartMetadataOptions,
580
+ ): Record<string, string> {
581
+ return {
582
+ environment: "ide",
583
+ client_type: GITLAB_DUO_WORKFLOW_CLIENT_TYPE,
584
+ ...(metadataOptions.projectId ? { projectId: metadataOptions.projectId } : undefined),
585
+ ...(metadataOptions.namespaceId
586
+ ? { namespaceId: toGitLabRestNamespaceId(metadataOptions.namespaceId) }
587
+ : undefined),
588
+ ...(metadataOptions.rootNamespaceId
589
+ ? { rootNamespaceId: toGitLabRestNamespaceId(metadataOptions.rootNamespaceId) }
590
+ : undefined),
591
+ selectedModelIdentifier: selectGitLabDuoWorkflowModelRef(model.id, availableModels),
592
+ };
593
+ }
594
+
595
+ export function buildGitLabDuoWorkflowClientAdditionalContext(): GitLabDuoWorkflowAdditionalContextItem[] {
596
+ return [];
597
+ }
598
+
599
+ export function buildGitLabDuoWorkflowMcpTools(tools: Tool[] | undefined): GitLabMcpToolDefinition[] {
600
+ return tools?.map(buildGitLabMcpToolDefinition) ?? [];
601
+ }
602
+
603
+ export function selectGitLabDuoWorkflowModelRef(
604
+ selectedModel: string,
605
+ availableModels?: GitLabAvailableModelsPayload | null,
606
+ ): string {
607
+ const pinned = availableModels?.pinnedModel?.ref;
608
+ if (pinned) return pinned;
609
+ return selectedModel;
610
+ }
611
+
612
+ export function buildGitLabPlainTextFromToolResult(toolResult: ToolResultMessage): GitLabPlainTextResponse {
613
+ const text = gitLabToolResultToText(toolResult);
614
+ return toolResult.isError ? { error: text } : { response: text };
615
+ }
616
+ function findGitLabDuoWorkflowToolResultById(
617
+ messages: readonly Message[],
618
+ requestID: string,
619
+ ): ToolResultMessage | undefined {
620
+ for (let index = messages.length - 1; index >= 0; index--) {
621
+ const message = messages[index];
622
+ if (message?.role === "toolResult" && message.toolCallId === requestID) return message;
623
+ }
624
+ return undefined;
625
+ }
626
+
627
+ // Resolve each pending action to its tool result. The serial inline flow yields a
628
+ // single pending action per turn, but the helper stays general; it returns the
629
+ // {requestID, result} pairs only when ALL are present, so a resume that fires
630
+ // before the agent loop appended the tool result is held back rather than sent.
631
+ function resolveGitLabDuoWorkflowActionBatch(
632
+ messages: readonly Message[],
633
+ actions: readonly GitLabDuoWorkflowActionDescriptor[],
634
+ ): { requestID: string; result: ToolResultMessage }[] | undefined {
635
+ const resolved: { requestID: string; result: ToolResultMessage }[] = [];
636
+ for (const action of actions) {
637
+ const result = findGitLabDuoWorkflowToolResultById(messages, action.requestID);
638
+ if (!result) return undefined;
639
+ resolved.push({ requestID: action.requestID, result });
640
+ }
641
+ return resolved;
642
+ }
643
+
644
+ // True when the user steered mid-tool-loop: a user/developer message sits AFTER the
645
+ // last tool result the pending batch resolves to. The DWS wire has no in-flight
646
+ // channel to inject a new user message into a running workflow (the only entry,
647
+ // human_input, is gated behind a LangGraph interrupt that ends the run and forces
648
+ // the broken same-id RESUME). So the steer would be dropped if we just returned the
649
+ // tool results on the live socket. Instead the caller abandons this workflow and
650
+ // re-seeds a fresh one, where the steer rides the goal transcript as the last turn —
651
+ // matching the official CLI, which on interrupt restarts with the new instruction.
652
+ function hasGitLabDuoWorkflowSteerAfterBatch(
653
+ messages: readonly Message[],
654
+ batch: readonly { requestID: string; result: ToolResultMessage }[],
655
+ ): boolean {
656
+ let lastBatchResultIndex = -1;
657
+ const requestIds = new Set(batch.map(entry => entry.requestID));
658
+ for (let index = messages.length - 1; index >= 0; index--) {
659
+ const message = messages[index];
660
+ if (message?.role === "toolResult" && requestIds.has(message.toolCallId)) {
661
+ lastBatchResultIndex = index;
662
+ break;
663
+ }
664
+ }
665
+ if (lastBatchResultIndex < 0) return false;
666
+ for (let index = lastBatchResultIndex + 1; index < messages.length; index++) {
667
+ const role = messages[index]?.role;
668
+ if (role === "user" || role === "developer") return true;
669
+ }
670
+ return false;
671
+ }
672
+
673
+ function buildGitLabDuoWorkflowResponseFromToolResult(toolResult: ToolResultMessage): GitLabPlainTextResponse {
674
+ return buildGitLabPlainTextFromToolResult(toolResult);
675
+ }
676
+
677
+ // Stream one tool_call into the assistant message and finalize the turn. The DWS
678
+ // inline ambient flow dispatches MCP tool calls serially: its ToolNode runs a
679
+ // `for tool_call ...: await tool.ainvoke(...)` loop, and each MCP `ainvoke`
680
+ // blocks in `put_action_and_wait_for_response` until this client returns the
681
+ // matching actionResponse. So only ONE `runMCPTool` action is ever in flight per
682
+ // model turn — the next is not dispatched until the previous is answered. There
683
+ // is no burst to batch; each action is its own assistant message (one `done`,
684
+ // one usage) and the single pending action is committed for the resume turn.
685
+ function emitGitLabDuoWorkflowActionToolCall(
686
+ state: GitLabDuoWorkflowStreamState,
687
+ action: GitLabDuoWorkflowActionDescriptor,
688
+ ): void {
689
+ endGitLabDuoWorkflowText(state);
690
+ endGitLabDuoWorkflowThinking(state);
691
+ const toolCall = buildGitLabDuoWorkflowActionToolCall(action);
692
+ state.output.content.push(toolCall);
693
+ const contentIndex = state.output.content.length - 1;
694
+ state.stream.push({ type: "toolcall_start", contentIndex, partial: state.output });
695
+ state.stream.push({
696
+ type: "toolcall_delta",
697
+ contentIndex,
698
+ delta: JSON.stringify(toolCall.arguments),
699
+ partial: state.output,
700
+ });
701
+ state.stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: state.output });
702
+ finishGitLabDuoWorkflowStream(state, "toolUse");
703
+ if (state.providerSessionState?.active) {
704
+ state.providerSessionState.active.pendingActions = [action];
705
+ }
706
+ }
707
+
708
+ // Decide whether THIS tool-call boundary signals a stalled workflow. The control
709
+ // experiment proved the checkpoint `ui_chat_log` length (messageCount) is an
710
+ // incremental-streaming slice window capped at ~2 even on a healthy FINISHED run,
711
+ // so it cannot discriminate a loop. The raw server checkpoint BYTE size does: a
712
+ // healthy turn emits checkpoints whose size varies and progresses, while a stalled
713
+ // workflow re-emits a byte-identical checkpoint (the server replays the same
714
+ // non-advancing state). So a fresh tool-call boundary whose checkpoint byte length
715
+ // exactly equals the previous boundary's length of the same workflow means the
716
+ // server-side turn did not progress. Persist the last length on the session so the
717
+ // comparison survives the resume that reuses this socket. Returns false until a
718
+ // comparable prior reading exists (first boundary of a workflow, or checkpoints that
719
+ // never carried a length) so a single boundary is never falsely flagged.
720
+ function detectGitLabDuoWorkflowStall(state: GitLabDuoWorkflowStreamState): boolean {
721
+ const active = state.providerSessionState?.active;
722
+ const length = state.lastCheckpointContentLength;
723
+ if (!active || length === undefined) return false;
724
+ const previousLength = active.lastToolBoundaryContentLength;
725
+ const stalled = previousLength !== undefined && length === previousLength;
726
+ active.lastToolBoundaryContentLength = length;
727
+ return stalled;
728
+ }
729
+
730
+ function buildGitLabDuoWorkflowActionToolCall(action: GitLabDuoWorkflowActionDescriptor): ToolCall {
731
+ const args =
732
+ action.args && typeof action.args === "object" && !Array.isArray(action.args)
733
+ ? (action.args as Record<string, unknown>)
734
+ : {};
735
+ const mapped = mapGitLabDuoWorkflowActionToOmpTool(action.name, args);
736
+ return {
737
+ type: "toolCall",
738
+ id: action.requestID,
739
+ name: mapped.name,
740
+ arguments: mapped.arguments,
741
+ };
742
+ }
743
+
744
+ function mapGitLabDuoWorkflowActionToOmpTool(
745
+ actionName: string,
746
+ args: Record<string, unknown>,
747
+ ): { name: string; arguments: Record<string, unknown> } {
748
+ switch (actionName) {
749
+ case "runMCPTool":
750
+ case "run_mcp_tool":
751
+ return mapGitLabDuoWorkflowMcpToolCall(args);
752
+ default:
753
+ return { name: actionName, arguments: { ...args } };
754
+ }
755
+ }
756
+
757
+ function mapGitLabDuoWorkflowMcpToolCall(args: Record<string, unknown>): {
758
+ name: string;
759
+ arguments: Record<string, unknown>;
760
+ } {
761
+ const rawName = stringField(args, "toolName") ?? stringField(args, "tool_name") ?? stringField(args, "name") ?? "";
762
+ const toolName = rawName.startsWith("mcp__omp__") ? rawName.slice("mcp__omp__".length) : rawName;
763
+ const parsedArgs = parseGitLabDuoWorkflowMcpArguments(args.args ?? args.arguments);
764
+ if (toolName === "edit" && typeof parsedArgs.input === "string") {
765
+ return { name: "edit", arguments: { input: parsedArgs.input } };
766
+ }
767
+ return { name: toolName, arguments: parsedArgs };
768
+ }
769
+
770
+ function parseGitLabDuoWorkflowMcpArguments(value: unknown): Record<string, unknown> {
771
+ if (value === undefined) return {};
772
+ if (typeof value === "string") {
773
+ try {
774
+ const parsed = JSON.parse(value) as unknown;
775
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
776
+ ? (parsed as Record<string, unknown>)
777
+ : {};
778
+ } catch {
779
+ return {};
780
+ }
781
+ }
782
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
783
+ }
784
+
785
+ function gitLabDuoWorkflowProviderSessionStateKey(
786
+ baseUrl: string,
787
+ modelId: string,
788
+ sessionId: string | undefined,
789
+ ): string {
790
+ return `gitlab-duo-agent:${baseUrl}\u0000${modelId}\u0000${sessionId ?? ""}`;
791
+ }
792
+
793
+ function createGitLabDuoWorkflowProviderSessionState(): GitLabDuoWorkflowProviderSessionState {
794
+ const state: GitLabDuoWorkflowProviderSessionState = {
795
+ close: () => {
796
+ // Stop the server-side workflow before tearing down the socket. The session
797
+ // is being reset/disposed, so no resume will return the result; without this
798
+ // PATCH a workflow the server is still running on OMP would be stranded.
799
+ try {
800
+ state.active?.stop?.();
801
+ } catch {
802
+ // Best-effort: never let a stop failure block disposal.
803
+ }
804
+ try {
805
+ state.active?.ws.close();
806
+ } catch {
807
+ // Ignore close failures from already-closed sockets.
808
+ }
809
+ state.active = undefined;
810
+ },
811
+ };
812
+ return state;
813
+ }
814
+
815
+ function getGitLabDuoWorkflowProviderSessionState(
816
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
817
+ baseUrl: string,
818
+ modelId: string,
819
+ sessionId: string | undefined,
820
+ ): GitLabDuoWorkflowProviderSessionState | undefined {
821
+ if (!providerSessionState) return undefined;
822
+ const key = gitLabDuoWorkflowProviderSessionStateKey(baseUrl, modelId, sessionId);
823
+ const existing = providerSessionState.get(key) as GitLabDuoWorkflowProviderSessionState | undefined;
824
+ if (existing) return existing;
825
+ const created = createGitLabDuoWorkflowProviderSessionState();
826
+ providerSessionState.set(key, created);
827
+ return created;
828
+ }
829
+
830
+ interface GitLabDuoWorkflowAccountState {
831
+ namespaceSelection?: GitLabDuoWorkflowNamespaceSelection;
832
+ // Once the namespace's Duo settings (agent platform + MCP + experiment flags)
833
+ // have been ensured for this ACCOUNT, later turns and side-requests should not
834
+ // re-send the best-effort enablement PUT. This is account-scoped, not session-
835
+ // scoped: compaction/handoff are independent side-requests that must benefit from
836
+ // the same prepared account state without reusing the main workflow session.
837
+ settingsEnsured?: boolean;
838
+ }
839
+
840
+ // Per-(account, workspace) provider state. The discovered root namespace is a
841
+ // function of the GitLab credential AND the current cwd's git remote (a token with
842
+ // several top-level groups resolves a different namespace per repo), so caching it
843
+ // account-only would reuse the first workspace's namespace in a second repo and skip
844
+ // re-discovery (and skip per-namespace settings enablement). Key by credential +
845
+ // baseUrl + cwd; reuse across turns/sessions in the SAME workspace, re-discover only
846
+ // when a cached namespace later proves invalid. Explicit namespace/project config
847
+ // bypasses this cache entirely. Keyed by a non-reversible credential fingerprint
848
+ // (never the raw token).
849
+ const gitLabDuoWorkflowAccountState = new Map<string, GitLabDuoWorkflowAccountState>();
850
+
851
+ function gitLabDuoWorkflowAccountKey(apiKey: string, baseUrl: string, cwd: string | undefined): string {
852
+ return `${Bun.hash(apiKey).toString(36)}\u0000${baseUrl}\u0000${cwd ?? ""}`;
853
+ }
854
+
855
+ function getGitLabDuoWorkflowAccountState(
856
+ apiKey: string,
857
+ baseUrl: string,
858
+ cwd: string | undefined,
859
+ ): GitLabDuoWorkflowAccountState {
860
+ const key = gitLabDuoWorkflowAccountKey(apiKey, baseUrl, cwd);
861
+ const existing = gitLabDuoWorkflowAccountState.get(key);
862
+ if (existing) return existing;
863
+ const created: GitLabDuoWorkflowAccountState = {};
864
+ gitLabDuoWorkflowAccountState.set(key, created);
865
+ return created;
866
+ }
867
+
868
+ function getGitLabDuoWorkflowCachedNamespace(
869
+ apiKey: string,
870
+ baseUrl: string,
871
+ cwd: string | undefined,
872
+ ): GitLabDuoWorkflowNamespaceSelection | undefined {
873
+ return getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).namespaceSelection;
874
+ }
875
+
876
+ function setGitLabDuoWorkflowCachedNamespace(
877
+ apiKey: string,
878
+ baseUrl: string,
879
+ cwd: string | undefined,
880
+ selection: GitLabDuoWorkflowNamespaceSelection,
881
+ ): void {
882
+ getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).namespaceSelection = selection;
883
+ }
884
+
885
+ function clearGitLabDuoWorkflowCachedNamespace(apiKey: string, baseUrl: string, cwd: string | undefined): void {
886
+ getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).namespaceSelection = undefined;
887
+ }
888
+
889
+ function isGitLabDuoWorkflowSettingsEnsured(apiKey: string, baseUrl: string, cwd: string | undefined): boolean {
890
+ return getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).settingsEnsured === true;
891
+ }
892
+
893
+ function markGitLabDuoWorkflowSettingsEnsured(apiKey: string, baseUrl: string, cwd: string | undefined): void {
894
+ getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).settingsEnsured = true;
895
+ }
896
+
897
+ // True when the user pinned a namespace/project explicitly (option or env). Explicit
898
+ // configuration is authoritative and cheap to resolve, so it bypasses the account
899
+ // cache entirely (neither read nor written).
900
+ function hasGitLabDuoWorkflowExplicitNamespace(options: GitLabDuoWorkflowOptions): boolean {
901
+ return Boolean(
902
+ nonEmptyString(options.rootNamespaceId) ??
903
+ nonEmptyString(options.namespaceId) ??
904
+ nonEmptyString(Bun.env.GITLAB_DUO_NAMESPACE_ID) ??
905
+ nonEmptyString(options.projectId) ??
906
+ nonEmptyString(options.projectPath) ??
907
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID) ??
908
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH),
909
+ );
910
+ }
911
+
912
+ export function gitLabDuoWorkflowErrorText(error: unknown): string {
913
+ return error instanceof Error ? error.message : String(error);
914
+ }
915
+
916
+ async function readGitLabDuoWorkflowResponseErrorMessage(response: Response): Promise<string | undefined> {
917
+ try {
918
+ const payload: unknown = await response.json();
919
+ const message =
920
+ getGitLabDuoWorkflowErrorField(payload, "message") ?? getGitLabDuoWorkflowErrorField(payload, "error");
921
+ return message ? gitLabDuoWorkflowErrorText(message) : undefined;
922
+ } catch {
923
+ return undefined;
924
+ }
925
+ }
926
+
927
+ function getGitLabDuoWorkflowErrorField(payload: unknown, field: "message" | "error"): string | undefined {
928
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
929
+ const value = (payload as Record<string, unknown>)[field];
930
+ if (typeof value !== "string" || value.trim().length === 0) return undefined;
931
+ return value;
932
+ }
933
+
934
+ // Everything `setupForNamespace` resolves for a chosen namespace: the REST/root ids,
935
+ // the discovered project scoping, the prepared START payload, and the direct_access
936
+ // connection. Named (not `ReturnType<...>`) per repo convention so the contract stays
937
+ // explicit for the cached-namespace and re-discovery branches that consume it.
938
+ interface GitLabDuoWorkflowNamespaceSetup {
939
+ rootNamespaceId: string;
940
+ restNamespaceId: string;
941
+ createNamespaceId: string;
942
+ restProjectId: string | undefined;
943
+ startPayload: GitLabDuoWorkflowStartRequest;
944
+ webSocketProjectId: string | undefined;
945
+ workflowConnection: GitLabDuoWorkflowDirectAccessConnection;
946
+ workflowId: string;
947
+ selectedModelIdentifier: string;
948
+ }
949
+
950
+ async function runGitLabDuoWorkflow(
951
+ model: Model<"gitlab-duo-agent">,
952
+ context: Context,
953
+ options: GitLabDuoWorkflowOptions,
954
+ state: GitLabDuoWorkflowStreamState,
955
+ ): Promise<void> {
956
+ const apiKey = options.apiKey;
957
+ if (!apiKey) throw new AIError.MissingApiKeyError("gitlab-duo-agent");
958
+ const baseUrl = normalizeGitLabBaseUrl(model.baseUrl || DEFAULT_GITLAB_BASE_URL);
959
+ const fetchImpl = options.fetch ?? fetch;
960
+ const providerSessionState = getGitLabDuoWorkflowProviderSessionState(
961
+ options.providerSessionState,
962
+ baseUrl,
963
+ model.id,
964
+ options.sessionId,
965
+ );
966
+ state.providerSessionState = providerSessionState;
967
+ const pendingSession = providerSessionState?.active;
968
+ if (pendingSession) {
969
+ hydrateGitLabDuoWorkflowCheckpointState(state, pendingSession);
970
+ }
971
+ const pendingActions = pendingSession?.pendingActions;
972
+ const resolvedBatch =
973
+ pendingSession && pendingActions && pendingActions.length > 0
974
+ ? resolveGitLabDuoWorkflowActionBatch(context.messages, pendingActions)
975
+ : undefined;
976
+ // Steer mid-tool-loop: the user added a new instruction after this batch's tool
977
+ // results. Returning the results on the live socket would silently drop the steer
978
+ // (no in-flight user-message channel). Abandon the workflow and re-seed a fresh one
979
+ // below — the steer rides the goal transcript as the last turn.
980
+ const steeredMidBatch = Boolean(
981
+ resolvedBatch && hasGitLabDuoWorkflowSteerAfterBatch(context.messages, resolvedBatch),
982
+ );
983
+ if (pendingSession && resolvedBatch && !steeredMidBatch) {
984
+ const responses = resolvedBatch.map(({ requestID, result }) =>
985
+ buildGitLabDuoWorkflowActionResponse(requestID, buildGitLabDuoWorkflowResponseFromToolResult(result)),
986
+ );
987
+ pendingSession.pendingActions = undefined;
988
+ const resumeResult = await resumeGitLabDuoWorkflowSocket(
989
+ { fetchImpl, baseUrl, apiKey, workflowId: pendingSession.workflowId, state, providerSessionState },
990
+ () => runGitLabDuoWorkflowSocket(pendingSession.ws, pendingSession.startPayload, state, options, responses),
991
+ );
992
+ // A stall on the resumed socket means the server-side turn stopped advancing even
993
+ // after the tool result was returned. The helper already stopped that workflow and
994
+ // dropped `active`; fall through to seed a FRESH workflow whose rebuilt goal
995
+ // transcript includes the just-returned tool result, breaking the loop.
996
+ if (resumeResult !== "stalled") return;
997
+ }
998
+ if (providerSessionState?.active?.paused) {
999
+ const session = providerSessionState.active;
1000
+ const replay = session.pauseBuffer ?? [];
1001
+ session.paused = false;
1002
+ session.pauseBuffer = [];
1003
+ const sessionWorkflowId = session.workflowId;
1004
+ const resumeResult = await resumeGitLabDuoWorkflowSocket(
1005
+ { fetchImpl, baseUrl, apiKey, workflowId: sessionWorkflowId, state, providerSessionState },
1006
+ () => runGitLabDuoWorkflowSocket(session.ws, session.startPayload, state, options, undefined, replay),
1007
+ );
1008
+ // As with the action resume, a stall falls through to a fresh-workflow seed
1009
+ // (the helper already stopped the stalled workflow and dropped `active`).
1010
+ if (resumeResult !== "stalled") return;
1011
+ }
1012
+ // Two cases reach here with a live `pendingSession` that must be abandoned before
1013
+ // seeding a fresh workflow:
1014
+ // 1. A mid-batch steer (resolvedBatch present, user message after it).
1015
+ // 2. Pending actions that did NOT resolve to tool results (resolvedBatch
1016
+ // undefined): the requestID↔toolResult.toolCallId pairing broke, so the live
1017
+ // socket can never be answered. Silently creating a fresh workflow while
1018
+ // leaving the old one running strands it server-side — its LangGraph still
1019
+ // treats the tool call as pending, so the model never sees the result and
1020
+ // re-issues the same tool call (the observed "repeats the same tool, ignores
1021
+ // the result" loop). Both cases need the same cleanup: close the socket, stop
1022
+ // the workflow server-side, and drop the resumable session so the fresh
1023
+ // workflow below owns `active`. The accumulated history (including the
1024
+ // unanswered tool's result) replays through the new goal transcript.
1025
+ const abandonStaleSession = Boolean(
1026
+ pendingSession && (steeredMidBatch || (pendingActions && pendingActions.length > 0 && !resolvedBatch)),
1027
+ );
1028
+ if (abandonStaleSession && pendingSession) {
1029
+ traceGitLabDuoWorkflow(steeredMidBatch ? "workflow.steer_restart" : "workflow.stale_action_restart", {
1030
+ workflowId: pendingSession.workflowId,
1031
+ });
1032
+ pendingSession.pendingActions = undefined;
1033
+ try {
1034
+ pendingSession.ws.close();
1035
+ } catch {
1036
+ // Ignore close failures from already-closed sockets.
1037
+ }
1038
+ if (providerSessionState) providerSessionState.active = undefined;
1039
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, pendingSession.workflowId);
1040
+ }
1041
+ const workflowDefinition = resolveGitLabDuoWorkflowDefinition(options.workflowDefinition);
1042
+ const explicitNamespace = hasGitLabDuoWorkflowExplicitNamespace(options);
1043
+ const configuredProjectPath = nonEmptyString(options.projectPath) ?? nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH);
1044
+ const configuredProjectId = nonEmptyString(options.projectId) ?? nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID);
1045
+ const goal = extractLatestUserPrompt(context.messages);
1046
+
1047
+ // Resolve the namespace and everything scoped to it (settings enable, project
1048
+ // auto-discovery, direct_access, workflow create). With auto-discovery the
1049
+ // namespace is cached per account and reused as the first choice; only if a
1050
+ // cached namespace turns out stale (the dependent calls fail) do we invalidate
1051
+ // it and re-discover once. Explicit namespace/project config bypasses the cache.
1052
+ const setupForNamespace = async (
1053
+ namespaceSelection: GitLabDuoWorkflowNamespaceSelection,
1054
+ ): Promise<GitLabDuoWorkflowNamespaceSetup> => {
1055
+ const rootNamespaceId = namespaceSelection.rootNamespaceId;
1056
+ const restNamespaceId = toGitLabRestNamespaceId(rootNamespaceId);
1057
+ const createNamespaceId = namespaceSelection.namespacePath ?? restNamespaceId;
1058
+ traceGitLabDuoWorkflow("run.start", {
1059
+ baseUrl,
1060
+ model: model.id,
1061
+ rootNamespaceId,
1062
+ restNamespaceId,
1063
+ namespaceSource: namespaceSelection.source,
1064
+ toolCount: context.tools?.length ?? 0,
1065
+ });
1066
+ // Once per session, make sure the namespace has the Duo agent-platform + MCP +
1067
+ // beta flags on. The inline ambient flow needs them; a fresh group ships with
1068
+ // them off. Best-effort (PUT needs maintainer) and idempotent, never blocks.
1069
+ if (
1070
+ !isGitLabDuoWorkflowSettingsEnsured(apiKey, baseUrl, options.cwd) &&
1071
+ isGitLabDuoWorkflowInlineFlow(workflowDefinition)
1072
+ ) {
1073
+ // Mark the workspace ensured only after a definitive attempt (HTTP response,
1074
+ // success or 4xx). A transient network error / 5xx returns false so a later
1075
+ // turn retries instead of permanently skipping the PUT on a namespace whose
1076
+ // flags are still off.
1077
+ if (await ensureGitLabDuoWorkflowSettings(fetchImpl, baseUrl, apiKey, restNamespaceId)) {
1078
+ markGitLabDuoWorkflowSettingsEnsured(apiKey, baseUrl, options.cwd);
1079
+ }
1080
+ }
1081
+ // The inline `ambient` flow fails server-side without a project, and OMP has
1082
+ // no project of its own, so auto-discover one when nothing is configured. Prefer
1083
+ // the project the namespace was resolved from (the workspace git remote or an
1084
+ // explicit project), so a group with multiple projects scopes to the actual
1085
+ // repository instead of a generic group-listing pick. Fall back to the generic
1086
+ // membership lookup only when the namespace carries no project. `chat` runs
1087
+ // namespace-only.
1088
+ const discoveredProject =
1089
+ !configuredProjectPath && !configuredProjectId && isGitLabDuoWorkflowInlineFlow(workflowDefinition)
1090
+ ? namespaceSelection.projectPath
1091
+ ? { path: namespaceSelection.projectPath }
1092
+ : await discoverGitLabDuoWorkflowProject(fetchImpl, baseUrl, apiKey, restNamespaceId)
1093
+ : undefined;
1094
+ if (discoveredProject) {
1095
+ traceGitLabDuoWorkflow("project.discover", {
1096
+ projectId: discoveredProject.id,
1097
+ hasPath: Boolean(discoveredProject.path),
1098
+ fromRemote: Boolean(namespaceSelection.projectPath),
1099
+ });
1100
+ }
1101
+ // A configured `projectId` that carries a slash is really a full `group/project`
1102
+ // path (namespace discovery accepts that form too): route it through the path flow
1103
+ // so `webSocketProjectId` is resolved to a numeric id instead of sending the raw
1104
+ // path string as `project_id` on the WebSocket, which fails project-scoped routing.
1105
+ const configuredProjectIdIsPath = Boolean(configuredProjectId?.includes("/"));
1106
+ const numericConfiguredProjectId = configuredProjectIdIsPath ? undefined : configuredProjectId;
1107
+ const pathConfiguredProjectId = configuredProjectIdIsPath ? configuredProjectId : undefined;
1108
+ const projectPath = configuredProjectPath ?? pathConfiguredProjectId ?? discoveredProject?.path;
1109
+ const projectId = numericConfiguredProjectId ?? discoveredProject?.id;
1110
+ const restProjectId = configuredProjectPath ?? configuredProjectId ?? discoveredProject?.path;
1111
+ const webSocketProjectId =
1112
+ projectId ??
1113
+ (projectPath
1114
+ ? await resolveGitLabDuoWorkflowNumericProjectId(fetchImpl, baseUrl, apiKey, projectPath)
1115
+ : undefined);
1116
+ const workflowConnection: GitLabDuoWorkflowDirectAccessConnection = options.workflowToken
1117
+ ? { token: options.workflowToken, headers: {}, serviceEndpoint: false }
1118
+ : await requestGitLabDuoWorkflowDirectAccess(
1119
+ fetchImpl,
1120
+ baseUrl,
1121
+ apiKey,
1122
+ rootNamespaceId,
1123
+ restProjectId,
1124
+ workflowDefinition,
1125
+ );
1126
+ const workflowId =
1127
+ options.workflowId ??
1128
+ (await createGitLabDuoWorkflow(
1129
+ fetchImpl,
1130
+ baseUrl,
1131
+ apiKey,
1132
+ createNamespaceId,
1133
+ goal,
1134
+ restProjectId,
1135
+ workflowDefinition,
1136
+ options.signal,
1137
+ ));
1138
+ const availableModels = await fetchGitLabDuoWorkflowAvailableModels(fetchImpl, baseUrl, apiKey, rootNamespaceId);
1139
+ const selectedModelIdentifier = selectGitLabDuoWorkflowModelRef(model.id, availableModels);
1140
+ // A `toolChoice: "none"` side-request (e.g. handoff keeps live tool definitions
1141
+ // in the cache prefix but disables tool use) must not advertise the tools to
1142
+ // Duo: if the model picked one, the provider would emit a `toolUse` message and
1143
+ // the text-only handoff consumer would yield an empty/partial document. Drop the
1144
+ // advertised tools in that case; named/`auto`/`any` choices keep them.
1145
+ const advertisedTools = options.toolChoice === "none" ? [] : context.tools;
1146
+ const startPayload = buildGitLabDuoWorkflowStartRequest(
1147
+ workflowId,
1148
+ model,
1149
+ context,
1150
+ advertisedTools,
1151
+ availableModels,
1152
+ {
1153
+ projectId: webSocketProjectId,
1154
+ projectPath,
1155
+ namespaceId: restNamespaceId,
1156
+ rootNamespaceId: restNamespaceId,
1157
+ workflowDefinition,
1158
+ inlineFlow: isGitLabDuoWorkflowInlineFlow(workflowDefinition),
1159
+ },
1160
+ );
1161
+ return {
1162
+ rootNamespaceId,
1163
+ restNamespaceId,
1164
+ createNamespaceId,
1165
+ restProjectId,
1166
+ startPayload,
1167
+ webSocketProjectId,
1168
+ workflowConnection,
1169
+ workflowId,
1170
+ selectedModelIdentifier,
1171
+ };
1172
+ };
1173
+
1174
+ const cachedNamespace = explicitNamespace
1175
+ ? undefined
1176
+ : getGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd);
1177
+ let setup: GitLabDuoWorkflowNamespaceSetup;
1178
+ if (cachedNamespace) {
1179
+ try {
1180
+ setup = await setupForNamespace(cachedNamespace);
1181
+ } catch (cachedError) {
1182
+ // The cached account namespace no longer works (revoked access, deleted
1183
+ // group, membership change). Drop it and re-discover once from scratch.
1184
+ traceGitLabDuoWorkflow("namespace.cache_invalidate", {
1185
+ rootNamespaceId: cachedNamespace.rootNamespaceId,
1186
+ error: gitLabDuoWorkflowErrorText(cachedError),
1187
+ });
1188
+ clearGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd);
1189
+ const rediscovered = await resolveGitLabDuoWorkflowNamespaceSelection(
1190
+ model,
1191
+ options,
1192
+ apiKey,
1193
+ baseUrl,
1194
+ fetchImpl,
1195
+ );
1196
+ setup = await setupForNamespace(rediscovered);
1197
+ setGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd, rediscovered);
1198
+ }
1199
+ } else {
1200
+ const namespaceSelection = await resolveGitLabDuoWorkflowNamespaceSelection(
1201
+ model,
1202
+ options,
1203
+ apiKey,
1204
+ baseUrl,
1205
+ fetchImpl,
1206
+ );
1207
+ setup = await setupForNamespace(namespaceSelection);
1208
+ // Cache the freshly discovered namespace per account so the next session/turn
1209
+ // reuses it instead of re-discovering. Explicit config is never cached.
1210
+ if (!explicitNamespace) {
1211
+ setGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd, namespaceSelection);
1212
+ }
1213
+ }
1214
+ const restNamespaceId = setup.restNamespaceId;
1215
+ const createNamespaceId = setup.createNamespaceId;
1216
+ const restProjectId = setup.restProjectId;
1217
+ const webSocketProjectId = setup.webSocketProjectId;
1218
+ const workflowConnection = setup.workflowConnection;
1219
+ const selectedModelIdentifier = setup.selectedModelIdentifier;
1220
+ let workflowId = setup.workflowId;
1221
+ let startPayload = setup.startPayload;
1222
+ // Three byte zones (see GITLAB_DUO_WORKFLOW_GOAL_*_OVERFLOW_BYTES):
1223
+ // - [HARD, ∞): necessary-fail. Do NOT spend the request — emit the overflow error
1224
+ // now so the session compacts immediately. The fresh-workflow already created in
1225
+ // setup is stopped by the `finally` below.
1226
+ // - [SOFT, HARD): jitter. Attempt once (it can succeed); stash the overflow label so
1227
+ // that IF the run errors it is re-labeled as a context-overflow rather than a
1228
+ // transient fault.
1229
+ // - [0, SOFT): reliable. Leave the label undefined; ordinary errors surface verbatim.
1230
+ const renderedGoalBytes = Buffer.byteLength(startPayload.goal, "utf8");
1231
+ if (renderedGoalBytes >= GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES) {
1232
+ traceGitLabDuoWorkflow("goal.over_budget", {
1233
+ renderedGoalBytes,
1234
+ zone: "hard",
1235
+ soft: GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES,
1236
+ hard: GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES,
1237
+ });
1238
+ if (!state.stream.done) {
1239
+ state.output.stopReason = "error";
1240
+ state.output.errorMessage = buildGitLabDuoWorkflowGoalOverflowMessage(renderedGoalBytes);
1241
+ state.stream.push({ type: "error", reason: "error", error: state.output });
1242
+ }
1243
+ // Stop the freshly created server-side workflow so it is not stranded, then
1244
+ // return without opening the socket — the request is never spent.
1245
+ if (providerSessionState) providerSessionState.active = undefined;
1246
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1247
+ return;
1248
+ }
1249
+ if (renderedGoalBytes >= GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES) {
1250
+ state.goalOverflowMessage = buildGitLabDuoWorkflowGoalOverflowMessage(renderedGoalBytes);
1251
+ traceGitLabDuoWorkflow("goal.over_budget", {
1252
+ renderedGoalBytes,
1253
+ zone: "jitter",
1254
+ soft: GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES,
1255
+ hard: GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES,
1256
+ });
1257
+ }
1258
+ let lastSocketResult: GitLabDuoWorkflowSocketResult = "closed";
1259
+ let timeoutReconnected = false;
1260
+ let stepLimitRestarts = 0;
1261
+ let genericErrorRetries = 0;
1262
+ let stallRestarts = 0;
1263
+ let settledNormally = false;
1264
+ try {
1265
+ for (let attempt = 0; attempt < 12; attempt++) {
1266
+ const ws = openGitLabDuoWorkflowSocket(workflowConnection.baseUrl ?? baseUrl, {
1267
+ token: workflowConnection.token,
1268
+ projectId: webSocketProjectId,
1269
+ // Pass the resolved namespace/root even when no numeric project id is
1270
+ // available (project path unresolved, or auto-discovery found none): the
1271
+ // REST direct_access/create calls may be namespace- or path-scoped, but the
1272
+ // socket must still route inside the selected namespace. Dropping them with
1273
+ // the project left the socket scope-less and could route/fail outside it.
1274
+ namespaceId: restNamespaceId,
1275
+ rootNamespaceId: restNamespaceId,
1276
+ selectedModelIdentifier,
1277
+ workflowDefinition,
1278
+ serviceEndpoint: workflowConnection.serviceEndpoint,
1279
+ extraHeaders: workflowConnection.headers,
1280
+ originBaseUrl: baseUrl,
1281
+ webSocketFactory: options.webSocketFactory,
1282
+ });
1283
+ if (providerSessionState) {
1284
+ // Capture the CURRENT workflow id (it is reassigned across timeout/step-limit/
1285
+ // retry restarts) so a later session-dispose stops the right workflow.
1286
+ const stopWorkflowId = workflowId;
1287
+ providerSessionState.active = {
1288
+ workflowId,
1289
+ startPayload,
1290
+ ws,
1291
+ stop: () => {
1292
+ void stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, stopWorkflowId);
1293
+ },
1294
+ };
1295
+ }
1296
+ lastSocketResult = await runGitLabDuoWorkflowSocket(ws, startPayload, state, options);
1297
+ if (lastSocketResult === "approval") {
1298
+ startPayload = buildGitLabDuoWorkflowApprovalStartRequest(startPayload);
1299
+ state.lastApprovalStatus = undefined;
1300
+ continue;
1301
+ }
1302
+ // A silent half-open socket (no frame within the idle window) leaves the
1303
+ // remote workflow stuck. Same-id reconnect is NOT recoverable on an inline
1304
+ // flow: a second connection re-compiles the flow from the live `flowConfig`
1305
+ // and the LangGraph checkpoint replay rejects the rebuilt graph topology
1306
+ // (server-side FAILED, agent never runs — verified live). So recover the
1307
+ // same way step_limit does: stop the dead workflow and create a FRESH one
1308
+ // (status CREATED → START branch, no checkpoint replay), then reopen the
1309
+ // socket. The accumulated conversation replays through the goal transcript.
1310
+ // Bounded to a single retry so a persistently dead endpoint can't loop on quota.
1311
+ if (lastSocketResult === "timeout" && !timeoutReconnected) {
1312
+ timeoutReconnected = true;
1313
+ traceGitLabDuoWorkflow("websocket.idle_restart", { workflowId });
1314
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1315
+ workflowId = await createGitLabDuoWorkflow(
1316
+ fetchImpl,
1317
+ baseUrl,
1318
+ apiKey,
1319
+ createNamespaceId,
1320
+ goal,
1321
+ restProjectId,
1322
+ workflowDefinition,
1323
+ options.signal,
1324
+ );
1325
+ startPayload = { ...startPayload, workflowID: workflowId };
1326
+ continue;
1327
+ }
1328
+ // The server caps each workflow at a fixed step (graph-recursion) limit.
1329
+ // A long but healthy OMP tool-call loop legitimately overruns it; that is
1330
+ // not a real failure. Stop the exhausted run and create a FRESH workflow
1331
+ // (a new id resets the step budget — unlike the timeout case, resending on
1332
+ // the same id would not), then reopen the socket. The conversation so far
1333
+ // (assistant text + tool results accumulated in `context`) replays through
1334
+ // the goal envelope, so the new workflow continues where it left off; the
1335
+ // checkpoint dedupe drops any re-sent ui_chat_log entries. Bounded so a
1336
+ // task that perpetually overruns degrades to a graceful stop, not a quota
1337
+ // sink.
1338
+ if (lastSocketResult === "step_limit" && stepLimitRestarts < GITLAB_DUO_WORKFLOW_MAX_STEP_LIMIT_RESTARTS) {
1339
+ stepLimitRestarts++;
1340
+ state.stepLimitRequested = false;
1341
+ traceGitLabDuoWorkflow("websocket.step_limit_restart", { workflowId, restart: stepLimitRestarts });
1342
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1343
+ workflowId = await createGitLabDuoWorkflow(
1344
+ fetchImpl,
1345
+ baseUrl,
1346
+ apiKey,
1347
+ createNamespaceId,
1348
+ goal,
1349
+ restProjectId,
1350
+ workflowDefinition,
1351
+ options.signal,
1352
+ );
1353
+ startPayload = { ...startPayload, workflowID: workflowId };
1354
+ continue;
1355
+ }
1356
+ // The server emitted a fresh tool-call boundary whose `ui_chat_log` total did
1357
+ // not advance past the previous boundary of this workflow — the server-side
1358
+ // turn stopped progressing (captured live: total pinned while the model
1359
+ // repeated one tool call). Recover exactly like step_limit: stop the stalled
1360
+ // workflow and create a FRESH one (a new id with no checkpoint replay), then
1361
+ // reopen the socket. The conversation replays through the goal transcript,
1362
+ // rebuilt from the agent loop's intact `context.messages`, so no in-flight
1363
+ // tool result is lost. Bounded so a persistently stalling endpoint degrades to
1364
+ // a surfaced result instead of looping on quota.
1365
+ if (lastSocketResult === "stalled" && stallRestarts < GITLAB_DUO_WORKFLOW_MAX_STALL_RESTARTS) {
1366
+ stallRestarts++;
1367
+ state.stalledRequested = false;
1368
+ traceGitLabDuoWorkflow("websocket.stall_restart", { workflowId, restart: stallRestarts });
1369
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1370
+ workflowId = await createGitLabDuoWorkflow(
1371
+ fetchImpl,
1372
+ baseUrl,
1373
+ apiKey,
1374
+ createNamespaceId,
1375
+ goal,
1376
+ restProjectId,
1377
+ workflowDefinition,
1378
+ options.signal,
1379
+ );
1380
+ startPayload = { ...startPayload, workflowID: workflowId };
1381
+ continue;
1382
+ }
1383
+ // The server returned its de-identified catch-all FAILED — a wrapper over a
1384
+ // transient upstream fault (model 5xx, AgentStuckError, …). Retry on a FRESH
1385
+ // workflow exactly like step_limit (same-id reconnect is broken on inline
1386
+ // flows): the conversation replays through the goal transcript. Bounded low
1387
+ // so a deterministic failure surfaces instead of looping on quota.
1388
+ if (
1389
+ lastSocketResult === "retryable_error" &&
1390
+ genericErrorRetries < GITLAB_DUO_WORKFLOW_MAX_GENERIC_ERROR_RETRIES
1391
+ ) {
1392
+ genericErrorRetries++;
1393
+ state.retryableErrorRequested = false;
1394
+ // Clear the stashed message: it only surfaces if the retry also fails.
1395
+ state.output.errorMessage = undefined;
1396
+ traceGitLabDuoWorkflow("websocket.generic_error_retry", { workflowId, retry: genericErrorRetries });
1397
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1398
+ workflowId = await createGitLabDuoWorkflow(
1399
+ fetchImpl,
1400
+ baseUrl,
1401
+ apiKey,
1402
+ createNamespaceId,
1403
+ goal,
1404
+ restProjectId,
1405
+ workflowDefinition,
1406
+ options.signal,
1407
+ );
1408
+ startPayload = { ...startPayload, workflowID: workflowId };
1409
+ continue;
1410
+ }
1411
+ // A retryable error that exhausted its retries must surface as a real error;
1412
+ // the FAILED branch suppressed the error event expecting a retry, so emit it
1413
+ // now before falling through to the terminal break.
1414
+ if (lastSocketResult === "retryable_error" && !state.stream.done) {
1415
+ state.output.stopReason = "error";
1416
+ // An oversized goal that exhausted its retry is almost certainly failing on
1417
+ // the byte size, not a transient fault — surface it as a context-overflow so
1418
+ // the session auto-compacts instead of hard-failing.
1419
+ if (state.goalOverflowMessage) state.output.errorMessage = state.goalOverflowMessage;
1420
+ state.stream.push({ type: "error", reason: "error", error: state.output });
1421
+ }
1422
+ // A stall that exhausted its fresh-workflow restarts is a persistent failure to
1423
+ // progress; surface it as a real error so the run does not stop silently.
1424
+ if (lastSocketResult === "stalled" && !state.stream.done) {
1425
+ state.output.stopReason = "error";
1426
+ state.output.errorMessage =
1427
+ state.goalOverflowMessage ?? state.output.errorMessage ?? GITLAB_DUO_WORKFLOW_STALL_ERROR_MESSAGE;
1428
+ state.stream.push({ type: "error", reason: "error", error: state.output });
1429
+ }
1430
+ break;
1431
+ }
1432
+ settledNormally = true;
1433
+ finalizeGitLabDuoWorkflowResumeResult(state, providerSessionState, lastSocketResult);
1434
+ } finally {
1435
+ // The socket loop can exit several ways that leave the remote workflow running
1436
+ // and `active` referencing a dead socket: a user abort; `runGitLabDuoWorkflowSocket`
1437
+ // rejecting (e.g. `ws.onerror`) so the settle block never ran (`settledNormally`
1438
+ // stays false); or the socket reached a half-open/stuck terminal state with no
1439
+ // real completion — `lastSocketResult === "closed"` (proxy/server drop),
1440
+ // `"timeout"` (idle deadline, retry already exhausted), or `"stalled"` (the
1441
+ // workflow's visible history stopped advancing and the bounded restarts were
1442
+ // exhausted). In all of these the local stream is finalized but the server
1443
+ // workflow has no explicit stop, so drop the resumable session and stop it with a
1444
+ // FRESH signal (the request's own signal may be aborted, which would cancel the
1445
+ // PATCH before it is sent). The happy path that intentionally keeps `active` for
1446
+ // an `action`/`pause` resume reaches a real terminal status, never
1447
+ // "closed"/"timeout"/"stalled", so it is not affected.
1448
+ const aborted = options.signal?.aborted ?? false;
1449
+ if (
1450
+ aborted ||
1451
+ !settledNormally ||
1452
+ lastSocketResult === "closed" ||
1453
+ lastSocketResult === "timeout" ||
1454
+ lastSocketResult === "stalled"
1455
+ ) {
1456
+ if (providerSessionState) {
1457
+ providerSessionState.active = undefined;
1458
+ }
1459
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ async function fetchGitLabDuoWorkflowAvailableModels(
1465
+ fetchImpl: FetchImpl,
1466
+ baseUrl: string,
1467
+ apiKey: string,
1468
+ rootNamespaceId: string,
1469
+ ): Promise<GitLabAvailableModelsPayload | undefined> {
1470
+ try {
1471
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/graphql"), {
1472
+ method: "POST",
1473
+ headers: {
1474
+ Authorization: `Bearer ${apiKey}`,
1475
+ "content-type": "application/json",
1476
+ },
1477
+ body: JSON.stringify({
1478
+ query: GITLAB_DUO_WORKFLOW_AVAILABLE_MODELS_QUERY,
1479
+ variables: { rootNamespaceId: toGitLabGraphQLNamespaceId(rootNamespaceId) },
1480
+ }),
1481
+ });
1482
+ if (!response.ok) return undefined;
1483
+ const payload: unknown = await response.json();
1484
+ const models = getRecord(getRecord(payload, "data"), "aiChatAvailableModels");
1485
+ return parseGitLabAvailableModelsPayload(models);
1486
+ } catch {
1487
+ return undefined;
1488
+ }
1489
+ }
1490
+
1491
+ function parseGitLabAvailableModelsPayload(value: unknown): GitLabAvailableModelsPayload | undefined {
1492
+ if (!value || typeof value !== "object") return undefined;
1493
+ return {
1494
+ pinnedModel: parseGitLabAvailableModel(getRecord(value, "pinnedModel")),
1495
+ selectedModel: parseGitLabAvailableModel(getRecord(value, "selectedModel")),
1496
+ defaultModel: parseGitLabAvailableModel(getRecord(value, "defaultModel")),
1497
+ selectableModels: parseGitLabAvailableModelArray((value as Record<string, unknown>).selectableModels),
1498
+ };
1499
+ }
1500
+
1501
+ function parseGitLabAvailableModel(value: unknown): GitLabAvailableModel | null {
1502
+ if (!value || typeof value !== "object") return null;
1503
+ return { name: getRecordString(value, "name") ?? null, ref: getRecordString(value, "ref") ?? null };
1504
+ }
1505
+
1506
+ function parseGitLabAvailableModelArray(value: unknown): GitLabAvailableModel[] | undefined {
1507
+ if (!Array.isArray(value)) return undefined;
1508
+ return value.map(parseGitLabAvailableModel).filter((model): model is GitLabAvailableModel => Boolean(model));
1509
+ }
1510
+
1511
+ async function resolveGitLabDuoWorkflowNumericProjectId(
1512
+ fetchImpl: FetchImpl,
1513
+ baseUrl: string,
1514
+ apiKey: string,
1515
+ projectPath: string,
1516
+ ): Promise<string | undefined> {
1517
+ try {
1518
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(projectPath)}`), {
1519
+ method: "GET",
1520
+ headers: {
1521
+ Authorization: `Bearer ${apiKey}`,
1522
+ "content-type": "application/json",
1523
+ },
1524
+ });
1525
+ if (!response.ok) return undefined;
1526
+ const payload: unknown = await response.json();
1527
+ return getRecordString(payload, "id");
1528
+ } catch {
1529
+ return undefined;
1530
+ }
1531
+ }
1532
+
1533
+ interface GitLabDuoWorkflowDiscoveredProject {
1534
+ // Numeric id is known when discovered via the projects API; for a project carried
1535
+ // from the resolved namespace (git remote / explicit path) only the full path is
1536
+ // known and the numeric id is resolved later from the path for WebSocket routing.
1537
+ id?: string;
1538
+ path: string;
1539
+ }
1540
+
1541
+ // OMP has no GitLab project of its own, but the inline `ambient` flow fails
1542
+ // server-side without a project context. When the caller did not configure a
1543
+ // project, discover one the credential can access: prefer a project inside the
1544
+ // resolved namespace group, then fall back to any membership project. Returns
1545
+ // the numeric id (WebSocket routing) and full path (REST scoping) together so
1546
+ // no second lookup is needed.
1547
+ async function discoverGitLabDuoWorkflowProject(
1548
+ fetchImpl: FetchImpl,
1549
+ baseUrl: string,
1550
+ apiKey: string,
1551
+ restNamespaceId: string,
1552
+ ): Promise<GitLabDuoWorkflowDiscoveredProject | undefined> {
1553
+ const query = "per_page=1&min_access_level=30&order_by=last_activity_at&sort=desc";
1554
+ const endpoints = [
1555
+ `/api/v4/groups/${encodeURIComponent(restNamespaceId)}/projects?include_subgroups=true&${query}`,
1556
+ `/api/v4/projects?membership=true&${query}`,
1557
+ ];
1558
+ for (const endpoint of endpoints) {
1559
+ try {
1560
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, endpoint), {
1561
+ method: "GET",
1562
+ headers: {
1563
+ Authorization: `Bearer ${apiKey}`,
1564
+ "content-type": "application/json",
1565
+ },
1566
+ });
1567
+ if (!response.ok) continue;
1568
+ const payload: unknown = await response.json();
1569
+ const first = Array.isArray(payload) ? payload[0] : undefined;
1570
+ const id = getRecordString(first, "id");
1571
+ const path = getRecordString(first, "path_with_namespace");
1572
+ if (id && path) return { id, path };
1573
+ } catch {}
1574
+ }
1575
+ return undefined;
1576
+ }
1577
+
1578
+ async function requestGitLabDuoWorkflowDirectAccess(
1579
+ fetchImpl: FetchImpl,
1580
+ baseUrl: string,
1581
+ apiKey: string,
1582
+ rootNamespaceId: string,
1583
+ projectId?: string,
1584
+ workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
1585
+ ): Promise<GitLabDuoWorkflowDirectAccessConnection> {
1586
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/direct_access"), {
1587
+ method: "POST",
1588
+ headers: {
1589
+ Authorization: `Bearer ${apiKey}`,
1590
+ "content-type": "application/json",
1591
+ },
1592
+ body: JSON.stringify(buildGitLabDuoWorkflowDirectAccessBody(rootNamespaceId, projectId, workflowDefinition)),
1593
+ });
1594
+ traceGitLabDuoWorkflow("direct_access.response", {
1595
+ status: response.status,
1596
+ ok: response.ok,
1597
+ rootNamespaceId,
1598
+ hasProjectId: Boolean(projectId),
1599
+ });
1600
+ if (!response.ok) {
1601
+ const message = await readGitLabDuoWorkflowResponseErrorMessage(response);
1602
+ // Always embed the HTTP status, even when the body carries a message: the
1603
+ // streaming auth-retry/rotation path (`extractStatusFromAssistantError` ->
1604
+ // `extractHttpStatusFromError`) refreshes/rotates broker credentials only
1605
+ // when the assistant error exposes `errorStatus` or the message embeds an
1606
+ // `HTTP <status>` token. A 401 `{"message":"Unauthorized"}` or a 429 quota
1607
+ // body would otherwise surface as a hard failure with no recoverable status.
1608
+ throw new AIError.GitLabDuoWorkflowApiError(
1609
+ message
1610
+ ? `GitLab Duo Workflow direct_access failed with HTTP ${response.status}: ${message}`
1611
+ : `GitLab Duo Workflow direct_access failed with HTTP ${response.status}`,
1612
+ response.status,
1613
+ );
1614
+ }
1615
+ const payload = (await response.json()) as GitLabDirectAccessResponse;
1616
+ const token = extractGitLabWorkflowToken(payload);
1617
+ if (!token) {
1618
+ throw new AIError.ProviderResponseError("GitLab Duo Workflow direct_access did not return credentials", {
1619
+ provider: "gitlab-duo-agent",
1620
+ kind: "empty-body",
1621
+ });
1622
+ }
1623
+ traceGitLabDuoWorkflow("direct_access.token", { hasToken: true });
1624
+ const serviceEndpoint = !payload.gitlab_rails?.token && Boolean(payload.duo_workflow_service?.base_url);
1625
+ return {
1626
+ token,
1627
+ ...(serviceEndpoint && payload.duo_workflow_service?.base_url
1628
+ ? { baseUrl: normalizeGitLabDuoWorkflowServiceBaseUrl(payload.duo_workflow_service.base_url) }
1629
+ : {}),
1630
+ headers: serviceEndpoint ? (payload.duo_workflow_service?.headers ?? {}) : {},
1631
+ serviceEndpoint,
1632
+ };
1633
+ }
1634
+
1635
+ async function createGitLabDuoWorkflow(
1636
+ fetchImpl: FetchImpl,
1637
+ baseUrl: string,
1638
+ apiKey: string,
1639
+ namespaceId: string,
1640
+ goal?: string,
1641
+ projectId?: string,
1642
+ workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
1643
+ signal?: AbortSignal,
1644
+ ): Promise<string> {
1645
+ const body = buildGitLabDuoWorkflowCreateBody(namespaceId, {
1646
+ goal: isGitLabDuoWorkflowInlineFlow(workflowDefinition) ? "" : goal,
1647
+ projectId,
1648
+ workflowDefinition,
1649
+ });
1650
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/workflows"), {
1651
+ method: "POST",
1652
+ headers: {
1653
+ Authorization: `Bearer ${apiKey}`,
1654
+ "content-type": "application/json",
1655
+ },
1656
+ body: JSON.stringify(body),
1657
+ signal,
1658
+ });
1659
+ traceGitLabDuoWorkflow("workflow.create.response", {
1660
+ status: response.status,
1661
+ ok: response.ok,
1662
+ namespaceId,
1663
+ hasProjectId: Boolean(projectId),
1664
+ });
1665
+ if (!response.ok) {
1666
+ throw new AIError.GitLabDuoWorkflowApiError(
1667
+ `GitLab Duo Workflow create failed with HTTP ${response.status}`,
1668
+ response.status,
1669
+ );
1670
+ }
1671
+ const payload = (await response.json()) as GitLabCreateWorkflowResponse;
1672
+ const workflowId = payload.id ?? payload.workflow_id ?? payload.workflowId;
1673
+ if (workflowId === undefined) {
1674
+ throw new AIError.ProviderResponseError(
1675
+ `GitLab Duo Workflow create response missing workflow id (HTTP ${response.status})`,
1676
+ { provider: "gitlab-duo-agent", kind: "empty-body" },
1677
+ );
1678
+ }
1679
+ traceGitLabDuoWorkflow("workflow.create.id", { workflowId });
1680
+ return String(workflowId);
1681
+ }
1682
+
1683
+ async function stopGitLabDuoWorkflow(
1684
+ fetchImpl: FetchImpl,
1685
+ baseUrl: string,
1686
+ apiKey: string,
1687
+ workflowId: string,
1688
+ ): Promise<void> {
1689
+ await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/ai/duo_workflows/workflows/${encodeURIComponent(workflowId)}`), {
1690
+ method: "PATCH",
1691
+ headers: {
1692
+ Authorization: `Bearer ${apiKey}`,
1693
+ "content-type": "application/json",
1694
+ },
1695
+ body: JSON.stringify(buildGitLabDuoWorkflowStopBody()),
1696
+ });
1697
+ }
1698
+
1699
+ // Body the group PUT carries to turn on exactly the three flags the inline MCP-only
1700
+ // ambient flow requires. Kept minimal on purpose: it never touches `duo_availability`,
1701
+ // foundational flows, tool-approval, usage-data, or any other setting the operator may
1702
+ // have configured. Idempotent — re-enabling an already-on flag is a server-side no-op.
1703
+ export function buildGitLabDuoWorkflowSettingsBody(): Record<string, unknown> {
1704
+ return {
1705
+ experiment_features_enabled: true,
1706
+ ai_settings_attributes: {
1707
+ duo_agent_platform_enabled: true,
1708
+ duo_workflow_mcp_enabled: true,
1709
+ },
1710
+ };
1711
+ }
1712
+
1713
+ // Best-effort enable of the namespace Duo settings the agent flow needs. Without
1714
+ // `duo_agent_platform_enabled` / `duo_workflow_mcp_enabled` / `experiment_features_enabled`
1715
+ // the inline ambient flow is rejected server-side, so a fresh group must have them on.
1716
+ // PUT requires owner/maintainer; a 4xx (insufficient rights, no namespace) is logged via
1717
+ // trace and swallowed — the run proceeds and surfaces the real error if the flow is still
1718
+ // disabled, rather than blocking login/turns on a permission the user may not hold.
1719
+ async function ensureGitLabDuoWorkflowSettings(
1720
+ fetchImpl: FetchImpl,
1721
+ baseUrl: string,
1722
+ apiKey: string,
1723
+ restNamespaceId: string,
1724
+ ): Promise<boolean> {
1725
+ // Returns whether the attempt was DEFINITIVE (so the caller may stop retrying):
1726
+ // any HTTP response — 2xx (flags now on) or 4xx (insufficient rights / no such
1727
+ // namespace, which retrying never fixes) — is definitive. A thrown network error
1728
+ // or a 5xx is transient, so the caller should keep the guard retryable and try
1729
+ // again on a later turn rather than permanently skipping the PUT.
1730
+ try {
1731
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/groups/${encodeURIComponent(restNamespaceId)}`), {
1732
+ method: "PUT",
1733
+ headers: {
1734
+ Authorization: `Bearer ${apiKey}`,
1735
+ "content-type": "application/json",
1736
+ },
1737
+ body: JSON.stringify(buildGitLabDuoWorkflowSettingsBody()),
1738
+ });
1739
+ traceGitLabDuoWorkflow("settings.ensure", { status: response.status, ok: response.ok });
1740
+ return response.status < 500;
1741
+ } catch (error) {
1742
+ traceGitLabDuoWorkflow("settings.ensure_error", { error: gitLabDuoWorkflowErrorText(error) });
1743
+ return false;
1744
+ }
1745
+ }
1746
+
1747
+ function openGitLabDuoWorkflowSocket(
1748
+ baseUrl: string,
1749
+ options: {
1750
+ token: string;
1751
+ projectId?: string;
1752
+ namespaceId?: string;
1753
+ rootNamespaceId?: string;
1754
+ selectedModelIdentifier?: string;
1755
+ originBaseUrl?: string;
1756
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
1757
+ serviceEndpoint?: boolean;
1758
+ extraHeaders?: Record<string, string>;
1759
+ webSocketFactory?: GitLabDuoWorkflowWebSocketFactory;
1760
+ },
1761
+ ): GitLabDuoWorkflowWebSocketLike {
1762
+ const url = buildGitLabDuoWorkflowWebSocketUrl(baseUrl, options);
1763
+ const headers = buildGitLabDuoWorkflowWebSocketHeaders({
1764
+ ...options,
1765
+ baseUrl: normalizeGitLabBaseUrl(options.originBaseUrl ?? baseUrl),
1766
+ });
1767
+ const factory = options.webSocketFactory ?? defaultGitLabDuoWorkflowWebSocketFactory;
1768
+ traceGitLabDuoWorkflow("websocket.create", { url });
1769
+ return factory(url, { headers });
1770
+ }
1771
+ function defaultGitLabDuoWorkflowWebSocketFactory(
1772
+ url: string,
1773
+ options: GitLabDuoWorkflowWebSocketFactoryOptions,
1774
+ ): GitLabDuoWorkflowWebSocketLike {
1775
+ return new (
1776
+ WebSocket as unknown as new (
1777
+ url: string,
1778
+ options: Bun.WebSocketOptions,
1779
+ ) => GitLabDuoWorkflowWebSocketLike
1780
+ )(url, { headers: options.headers });
1781
+ }
1782
+
1783
+ export function runGitLabDuoWorkflowSocket(
1784
+ ws: GitLabDuoWorkflowWebSocketLike,
1785
+ startPayload: GitLabDuoWorkflowStartRequest,
1786
+ state: GitLabDuoWorkflowStreamState,
1787
+ options: GitLabDuoWorkflowOptions,
1788
+ resumeResponse?: GitLabDuoWorkflowActionResponse | readonly GitLabDuoWorkflowActionResponse[],
1789
+ replayMessages?: readonly unknown[],
1790
+ ): Promise<GitLabDuoWorkflowSocketResult> {
1791
+ const { promise, resolve, reject } = Promise.withResolvers<GitLabDuoWorkflowSocketResult>();
1792
+ let settled = false;
1793
+ let idleTimer: NodeJS.Timeout | undefined;
1794
+ const clearIdleTimer = (): void => {
1795
+ if (idleTimer !== undefined) {
1796
+ clearTimeout(idleTimer);
1797
+ idleTimer = undefined;
1798
+ }
1799
+ };
1800
+ const settle = (result: GitLabDuoWorkflowSocketResult = "closed", error?: unknown): void => {
1801
+ if (settled) return;
1802
+ settled = true;
1803
+ clearIdleTimer();
1804
+ if (error) reject(error);
1805
+ else resolve(result);
1806
+ };
1807
+ const idleTimeoutMs =
1808
+ options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0
1809
+ ? options.idleTimeoutMs
1810
+ : GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS;
1811
+ const resetIdleTimer = (): void => {
1812
+ clearIdleTimer();
1813
+ if (settled) return;
1814
+ idleTimer = setTimeout(() => {
1815
+ traceGitLabDuoWorkflow("websocket.idle_timeout", { timeoutMs: idleTimeoutMs });
1816
+ close();
1817
+ settle("timeout");
1818
+ }, idleTimeoutMs);
1819
+ };
1820
+ const close = (): void => {
1821
+ try {
1822
+ ws.close();
1823
+ } catch {
1824
+ // Ignore close failures from test doubles or already closed sockets.
1825
+ }
1826
+ };
1827
+ const abort = (): void => {
1828
+ close();
1829
+ settle("closed", new AIError.AbortError("GitLab Duo Workflow request aborted"));
1830
+ };
1831
+ if (options.signal?.aborted) {
1832
+ abort();
1833
+ return promise;
1834
+ }
1835
+ options.signal?.addEventListener("abort", abort, { once: true });
1836
+
1837
+ const active = state.providerSessionState?.active;
1838
+ const handleSocketResult = (
1839
+ result: GitLabDuoWorkflowMessageResult,
1840
+ data: unknown,
1841
+ remaining: readonly unknown[],
1842
+ ): boolean => {
1843
+ if (result === "pause") {
1844
+ if (active) {
1845
+ active.paused = true;
1846
+ active.pauseBuffer = [data, ...remaining, ...(active.pauseBuffer ?? [])];
1847
+ }
1848
+ pauseGitLabDuoWorkflowStream(state);
1849
+ settle("pause");
1850
+ return false;
1851
+ }
1852
+ if (result === "action") {
1853
+ // One MCP tool_call per turn: DWS ToolNode awaits each action's response
1854
+ // before dispatching the next, so the turn is complete at this single
1855
+ // action. Settle now (the agent loop runs the tool, then resumes by
1856
+ // sending the actionResponse on this SAME socket — so do NOT close it).
1857
+ settle("action");
1858
+ return false;
1859
+ }
1860
+ if (result !== "continue") {
1861
+ close();
1862
+ settle(result);
1863
+ return false;
1864
+ }
1865
+ return true;
1866
+ };
1867
+ ws.onerror = event => {
1868
+ const detail = describeGitLabDuoWorkflowSocketEvent(event);
1869
+ traceGitLabDuoWorkflow("websocket.error", { event: detail });
1870
+ settle(
1871
+ "closed",
1872
+ new AIError.ProviderResponseError(`GitLab Duo Workflow WebSocket error: ${detail}`, {
1873
+ provider: "gitlab-duo-agent",
1874
+ kind: "runtime",
1875
+ }),
1876
+ );
1877
+ };
1878
+ ws.onclose = event => {
1879
+ traceGitLabDuoWorkflow("websocket.close", { code: event.code, reason: event.reason });
1880
+ settle(state.lastApprovalStatus ? "approval" : "closed");
1881
+ };
1882
+ ws.onmessage = event => {
1883
+ resetIdleTimer();
1884
+ if (active?.paused) {
1885
+ active.pauseBuffer ??= [];
1886
+ active.pauseBuffer.push(event.data);
1887
+ return;
1888
+ }
1889
+ void handleGitLabDuoWorkflowSocketMessage(event.data, state).then(
1890
+ result => {
1891
+ handleSocketResult(result, event.data, []);
1892
+ },
1893
+ error => settle("closed", error),
1894
+ );
1895
+ };
1896
+ if (replayMessages && replayMessages.length > 0) {
1897
+ ws.onopen = null;
1898
+ void (async () => {
1899
+ if (active) active.paused = true;
1900
+ const pending: unknown[] = [...replayMessages];
1901
+ while (!settled) {
1902
+ if (pending.length === 0) {
1903
+ if (active?.pauseBuffer && active.pauseBuffer.length > 0) {
1904
+ pending.push(...active.pauseBuffer);
1905
+ active.pauseBuffer = [];
1906
+ continue;
1907
+ }
1908
+ // Replay queue fully drained and no buffered frames remain.
1909
+ break;
1910
+ }
1911
+ const data = pending.shift();
1912
+ let result: GitLabDuoWorkflowMessageResult;
1913
+ try {
1914
+ result = await handleGitLabDuoWorkflowSocketMessage(data, state);
1915
+ } catch (error) {
1916
+ settle("closed", error);
1917
+ return;
1918
+ }
1919
+ if (!handleSocketResult(result, data, pending)) {
1920
+ // An `action` result stops the replay loop to hand the tool call back
1921
+ // to OMP. Clear the pause flag first: the live `onmessage` handler must
1922
+ // process the resume continuation directly instead of buffering it
1923
+ // (a buffered continuation would idle the turn until timeout).
1924
+ if (active) active.paused = false;
1925
+ return;
1926
+ }
1927
+ if (active?.pauseBuffer && active.pauseBuffer.length > 0) {
1928
+ pending.push(...active.pauseBuffer);
1929
+ active.pauseBuffer = [];
1930
+ }
1931
+ }
1932
+ if (!settled && active) active.paused = false;
1933
+ })();
1934
+ } else if (resumeResponse && (!Array.isArray(resumeResponse) || resumeResponse.length > 0)) {
1935
+ ws.onopen = null;
1936
+ // Resume the live socket by returning the tool result for the single pending
1937
+ // action of this turn. (Accepts an array for forward-compat, but the serial
1938
+ // inline flow only ever has one.) DWS matches it by requestID to the awaiting
1939
+ // outbox future and the workflow continues on the same connection.
1940
+ const responses = Array.isArray(resumeResponse) ? resumeResponse : [resumeResponse];
1941
+ for (const response of responses) {
1942
+ ws.send(JSON.stringify(response));
1943
+ }
1944
+ } else {
1945
+ ws.onopen = () => {
1946
+ traceGitLabDuoWorkflow("websocket.open", {
1947
+ workflowId: startPayload.workflowID,
1948
+ workflowDefinition: startPayload.workflowDefinition,
1949
+ flowConfigId: startPayload.flowConfigId,
1950
+ flowVersion: startPayload.flowVersion,
1951
+ flowConfigSchemaVersion: startPayload.flowConfigSchemaVersion,
1952
+ mcpTools: startPayload.mcpTools.length,
1953
+ preapprovedTools: startPayload.preapproved_tools.length,
1954
+ });
1955
+ ws.send(JSON.stringify({ startRequest: startPayload }));
1956
+ };
1957
+ }
1958
+ resetIdleTimer();
1959
+ return promise.finally(() => {
1960
+ clearIdleTimer();
1961
+ options.signal?.removeEventListener("abort", abort);
1962
+ });
1963
+ }
1964
+
1965
+ type GitLabDuoWorkflowMessageResult =
1966
+ | "continue"
1967
+ | "terminal"
1968
+ | "approval"
1969
+ | "action"
1970
+ | "pause"
1971
+ | "step_limit"
1972
+ | "retryable_error"
1973
+ | "stalled";
1974
+
1975
+ type GitLabDuoWorkflowCheckpointKind = "text" | "thinking";
1976
+
1977
+ interface GitLabDuoWorkflowCheckpointAgentEntry {
1978
+ kind: GitLabDuoWorkflowCheckpointKind;
1979
+ messageIndex: number;
1980
+ messageKey: string;
1981
+ content: string;
1982
+ }
1983
+
1984
+ interface GitLabDuoWorkflowCheckpointBoundaryEntry {
1985
+ kind: "boundary";
1986
+ messageIndex: number;
1987
+ }
1988
+
1989
+ type GitLabDuoWorkflowCheckpointEntry =
1990
+ | GitLabDuoWorkflowCheckpointAgentEntry
1991
+ | GitLabDuoWorkflowCheckpointBoundaryEntry;
1992
+
1993
+ interface GitLabDuoWorkflowContextUsage {
1994
+ used: number;
1995
+ window: number;
1996
+ }
1997
+
1998
+ interface GitLabDuoWorkflowCheckpointContent {
1999
+ entries: GitLabDuoWorkflowCheckpointEntry[];
2000
+ contentLength: number;
2001
+ latestMessageType?: string;
2002
+ contextUsage?: GitLabDuoWorkflowContextUsage;
2003
+ }
2004
+
2005
+ async function handleGitLabDuoWorkflowSocketMessage(
2006
+ data: unknown,
2007
+ state: GitLabDuoWorkflowStreamState,
2008
+ ): Promise<GitLabDuoWorkflowMessageResult> {
2009
+ const event = parseGitLabDuoWorkflowSocketData(data);
2010
+ if (!event) return "continue";
2011
+ const status =
2012
+ getRecordString(event, "status") ??
2013
+ getNestedRecordString(event, "workflowStatus", "status") ??
2014
+ getNestedRecordString(event, "newCheckpoint", "status");
2015
+ const checkpoint = extractGitLabDuoWorkflowCheckpoint(event);
2016
+ traceGitLabDuoWorkflow("websocket.message", {
2017
+ keys: Object.keys(event),
2018
+ status,
2019
+ hasCheckpoint: Boolean(getRecord(event, "newCheckpoint") ?? getRecord(event, "checkpoint")),
2020
+ checkpointLength: checkpoint?.contentLength ?? 0,
2021
+ });
2022
+ if (checkpoint) {
2023
+ emitGitLabDuoWorkflowCheckpoint(state, checkpoint);
2024
+ }
2025
+ if (state.pauseRequested) {
2026
+ state.pauseRequested = false;
2027
+ return "pause";
2028
+ }
2029
+ if (isGitLabWorkflowApprovalStatus(status)) {
2030
+ state.lastApprovalStatus = status;
2031
+ traceGitLabDuoWorkflow("websocket.approval", { status });
2032
+ return "approval";
2033
+ }
2034
+ if (isGitLabWorkflowCompletionStatus(status)) {
2035
+ traceGitLabDuoWorkflow("websocket.terminal", { status, checkpointLength: checkpoint?.contentLength ?? 0 });
2036
+ finishGitLabDuoWorkflowStream(state, "stop");
2037
+ return "terminal";
2038
+ }
2039
+ if (status === "FAILED" || status === "STOPPED") {
2040
+ const message = gitLabDuoWorkflowErrorText(
2041
+ getRecordString(event, "error") ?? getRecordString(event, "message") ?? status,
2042
+ );
2043
+ // The server caps each workflow at a fixed graph-recursion limit (DWS
2044
+ // RECURSION_LIMIT). A long but healthy OMP tool-call loop legitimately hits
2045
+ // it and surfaces as FAILED with this message. That is not a real failure —
2046
+ // resume by starting a fresh workflow that continues the same conversation
2047
+ // (the accumulated context/tool results replay via the goal envelope).
2048
+ if (status === "FAILED" && isGitLabDuoWorkflowStepLimitMessage(message)) {
2049
+ traceGitLabDuoWorkflow("websocket.step_limit", { status });
2050
+ state.stepLimitRequested = true;
2051
+ return "step_limit";
2052
+ }
2053
+ // The DWS catch-all FAILED ("...error processing your request in the Duo Agent
2054
+ // Platform...") is a de-identified wrapper over transient upstream faults
2055
+ // (model 5xx that exhausted retries, AgentStuckError, etc.). Retry ONCE on a
2056
+ // FRESH workflow (the broken same-id reconnect is never used): the accumulated
2057
+ // conversation replays through the goal transcript. Bounded so a deterministic
2058
+ // failure degrades to a surfaced error instead of a quota sink.
2059
+ if (status === "FAILED" && isGitLabDuoWorkflowGenericProcessingError(message)) {
2060
+ traceGitLabDuoWorkflow("websocket.generic_error", { status });
2061
+ state.retryableErrorRequested = true;
2062
+ // Stash the real message but do NOT push an error event yet: the loop retries
2063
+ // on a fresh workflow and only surfaces this if retries are exhausted.
2064
+ state.output.errorMessage = message;
2065
+ return "retryable_error";
2066
+ }
2067
+ traceGitLabDuoWorkflow("websocket.failed", { status });
2068
+ state.output.stopReason = "error";
2069
+ // An oversized goal that fails terminally is almost certainly failing on the byte
2070
+ // size — surface it as a context-overflow so the session auto-compacts.
2071
+ state.output.errorMessage = state.goalOverflowMessage ?? message;
2072
+ state.stream.push({ type: "error", reason: "error", error: state.output });
2073
+ return "terminal";
2074
+ }
2075
+ const action = extractGitLabDuoWorkflowAction(event);
2076
+ if (!action) return "continue";
2077
+ traceGitLabDuoWorkflow("websocket.action", {
2078
+ actionName: action.name,
2079
+ requestID: action.requestID,
2080
+ toolName:
2081
+ getRecordString(action.args as Record<string, unknown>, "name") ??
2082
+ getRecordString(action.args as Record<string, unknown>, "toolName") ??
2083
+ getRecordString(action.args as Record<string, unknown>, "tool_name"),
2084
+ argKeys: Object.keys(action.args as Record<string, unknown>).slice(0, 20),
2085
+ });
2086
+ // A fresh tool-call boundary whose `ui_chat_log` total did not advance past the
2087
+ // previous boundary of this workflow means the server-side turn did not progress:
2088
+ // emitting and answering this tool call would only feed the same non-advancing loop.
2089
+ // Settle "stalled" so the socket loop restarts on a fresh workflow (resending the
2090
+ // full goal transcript) instead of running the doomed tool call.
2091
+ if (detectGitLabDuoWorkflowStall(state)) {
2092
+ traceGitLabDuoWorkflow("websocket.stalled", {
2093
+ checkpointLength: state.lastCheckpointContentLength,
2094
+ actionName: action.name,
2095
+ });
2096
+ state.stalledRequested = true;
2097
+ return "stalled";
2098
+ }
2099
+ // Finalize this tool_call as its own assistant message and commit it as the
2100
+ // single pending action; the socket loop settles "action" so the agent loop
2101
+ // runs the tool and resumes.
2102
+ emitGitLabDuoWorkflowActionToolCall(state, action);
2103
+ return "action";
2104
+ }
2105
+ function isGitLabWorkflowApprovalStatus(status: string | undefined): boolean {
2106
+ return status === "PLAN_APPROVAL_REQUIRED" || status === "TOOL_CALL_APPROVAL_REQUIRED";
2107
+ }
2108
+
2109
+ function isGitLabWorkflowCompletionStatus(status: string | undefined): boolean {
2110
+ return status === "INPUT_REQUIRED" || status === "FINISHED";
2111
+ }
2112
+ // Matches the DWS GraphRecursionError surface ("The workflow reached its maximum
2113
+ // step limit and could not complete."). The leading clause is stable across
2114
+ // flows; match on it case-insensitively so a fresh workflow can continue the run.
2115
+ function isGitLabDuoWorkflowStepLimitMessage(message: string): boolean {
2116
+ return message.toLowerCase().includes("reached its maximum step limit");
2117
+ }
2118
+ // Matches the DWS de-identified catch-all FAILED ("There was an error processing
2119
+ // your request in the Duo Agent Platform, please contact support if the issue
2120
+ // persists.") — server-side wrapper over transient upstream faults. Match on the
2121
+ // stable middle clause case-insensitively (the surrounding text varies slightly
2122
+ // across server versions).
2123
+ function isGitLabDuoWorkflowGenericProcessingError(message: string): boolean {
2124
+ return message.toLowerCase().includes("error processing your request in the duo agent platform");
2125
+ }
2126
+ export function buildGitLabDuoWorkflowApprovalStartRequest(
2127
+ startPayload: GitLabDuoWorkflowStartRequest,
2128
+ ): GitLabDuoWorkflowStartRequest {
2129
+ return {
2130
+ ...startPayload,
2131
+ goal: "",
2132
+ additional_context: [],
2133
+ approval: { approval: {} },
2134
+ };
2135
+ }
2136
+
2137
+ function buildGitLabDuoWorkflowActionResponse(
2138
+ requestID: string,
2139
+ response: GitLabPlainTextResponse,
2140
+ ): GitLabDuoWorkflowActionResponse {
2141
+ return { actionResponse: { requestID, plainTextResponse: response } };
2142
+ }
2143
+
2144
+ function gitLabToolResultToText(toolResult: ToolResultMessage): string {
2145
+ return toolResult.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
2146
+ }
2147
+
2148
+ function buildGitLabMcpToolDefinition(tool: Tool): GitLabMcpToolDefinition {
2149
+ const schema = toolWireSchema(tool);
2150
+ // Register the tool under its BARE name (no `mcp__omp__` prefix). The server does
2151
+ // not strip prefixes — it registers `_executable_tools` and binds the model schema
2152
+ // under exactly the wire `name` (sanitize_llm_name only replaces illegal chars), so
2153
+ // the name the model sees, the toolset key it is matched against, and OMP's own
2154
+ // tool docs must all be the same bare name. A prefixed wire name only forced the
2155
+ // model to learn `mcp__omp__read` while OMP docs say `read`, with no upside.
2156
+ // `originalToolName`/`serverName` stay as MCP metadata; they are not the match key.
2157
+ return {
2158
+ name: tool.name,
2159
+ originalToolName: tool.name,
2160
+ serverName: "omp",
2161
+ description: tool.description || "",
2162
+ inputSchema: JSON.stringify(
2163
+ schema && typeof schema === "object" ? schema : { type: "object", properties: {}, required: [] },
2164
+ ),
2165
+ isApproved: true,
2166
+ };
2167
+ }
2168
+
2169
+ function createAssistantMessage(model: Model<Api>): AssistantMessage {
2170
+ return {
2171
+ role: "assistant",
2172
+ content: [],
2173
+ api: model.api,
2174
+ provider: model.provider,
2175
+ model: model.id,
2176
+ usage: {
2177
+ input: 0,
2178
+ output: 0,
2179
+ cacheRead: 0,
2180
+ cacheWrite: 0,
2181
+ totalTokens: 0,
2182
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2183
+ },
2184
+ stopReason: "stop",
2185
+ timestamp: Date.now(),
2186
+ };
2187
+ }
2188
+
2189
+ function hydrateGitLabDuoWorkflowCheckpointState(
2190
+ state: GitLabDuoWorkflowStreamState,
2191
+ session: GitLabDuoWorkflowActiveSession,
2192
+ ): void {
2193
+ state.checkpointAgentContentByKey = session.checkpointAgentContentByKey;
2194
+ state.checkpointAgentContentSignatures = session.checkpointAgentContentSignatures;
2195
+ }
2196
+
2197
+ function syncGitLabDuoWorkflowCheckpointState(state: GitLabDuoWorkflowStreamState): void {
2198
+ const active = state.providerSessionState?.active;
2199
+ if (!active) return;
2200
+ active.checkpointAgentContentByKey = state.checkpointAgentContentByKey;
2201
+ active.checkpointAgentContentSignatures = state.checkpointAgentContentSignatures;
2202
+ }
2203
+
2204
+ function emitGitLabDuoWorkflowCheckpoint(
2205
+ state: GitLabDuoWorkflowStreamState,
2206
+ checkpoint: GitLabDuoWorkflowCheckpointContent,
2207
+ ): void {
2208
+ if (checkpoint.contextUsage) {
2209
+ applyGitLabDuoWorkflowContextUsage(state, checkpoint.contextUsage);
2210
+ }
2211
+ // Track the server's latest checkpoint byte length so the action handler can detect
2212
+ // a workflow whose state stopped advancing (stall). The control experiment proved a
2213
+ // healthy turn emits checkpoints whose byte size varies and grows, while a stalled
2214
+ // workflow re-emits a byte-identical checkpoint.
2215
+ state.lastCheckpointContentLength = checkpoint.contentLength;
2216
+ // GitLab checkpoints are full ui_chat_log snapshots, so a later frame replays
2217
+ // earlier request/tool boundaries before the new agent delta. Pause only on a
2218
+ // boundary that follows a delta emitted in THIS checkpoint (`deltaThisCheckpoint`),
2219
+ // not any delta emitted earlier in the socket call — otherwise a stale replayed
2220
+ // boundary would fire one pause_turn per snapshot and hit the loop's continuation cap.
2221
+ let deltaThisCheckpoint = false;
2222
+ // Turn position within this full-snapshot replay: a request/tool boundary
2223
+ // starts a new turn. The content-signature fallback below is scoped to this
2224
+ // index so it suppresses only a replayed message reappearing at the SAME turn
2225
+ // position (e.g. GitLab renames a message_id across a shrunk snapshot, so the
2226
+ // per-key lookup misses but the text was already emitted for that turn). A
2227
+ // genuinely new later message with text equal to an earlier one lands at a
2228
+ // LATER turn (after an extra boundary), so its signature differs and it still
2229
+ // emits — repeated assistant output across turns is no longer swallowed.
2230
+ let turnIndex = 0;
2231
+ for (const entry of checkpoint.entries) {
2232
+ if (entry.kind === "boundary") {
2233
+ if (deltaThisCheckpoint && state.providerSessionState?.active) {
2234
+ state.pauseRequested = true;
2235
+ return;
2236
+ }
2237
+ endGitLabDuoWorkflowText(state);
2238
+ endGitLabDuoWorkflowThinking(state);
2239
+ turnIndex += 1;
2240
+ continue;
2241
+ }
2242
+
2243
+ const contentByKey = state.checkpointAgentContentByKey ?? {};
2244
+ const contentSignatures = state.checkpointAgentContentSignatures ?? {};
2245
+ const previousContent = contentByKey[entry.messageKey];
2246
+ const contentSignature = `${turnIndex}\u0000${entry.kind}\u0000${entry.content}`;
2247
+ const contentOnlySignature = `${turnIndex}\u0000content\u0000${entry.content}`;
2248
+ const duplicateContent =
2249
+ previousContent === undefined &&
2250
+ (contentSignatures[contentSignature] === true || contentSignatures[contentOnlySignature] === true);
2251
+ const rewroteExistingContent =
2252
+ previousContent !== undefined &&
2253
+ !entry.content.startsWith(previousContent) &&
2254
+ previousContent !== entry.content;
2255
+ const delta = duplicateContent
2256
+ ? ""
2257
+ : rewroteExistingContent
2258
+ ? ""
2259
+ : previousContent !== undefined
2260
+ ? entry.content.slice(previousContent.length)
2261
+ : entry.content;
2262
+
2263
+ contentByKey[entry.messageKey] = entry.content;
2264
+ contentSignatures[contentSignature] = true;
2265
+ contentSignatures[contentOnlySignature] = true;
2266
+ state.checkpointAgentContentByKey = contentByKey;
2267
+ state.checkpointAgentContentSignatures = contentSignatures;
2268
+ syncGitLabDuoWorkflowCheckpointState(state);
2269
+
2270
+ if (delta.length === 0) continue;
2271
+
2272
+ if (
2273
+ state.activeCheckpointMessageKey &&
2274
+ state.activeCheckpointMessageKey !== entry.messageKey &&
2275
+ previousContent === undefined
2276
+ ) {
2277
+ endGitLabDuoWorkflowText(state);
2278
+ endGitLabDuoWorkflowThinking(state);
2279
+ }
2280
+ emitGitLabDuoWorkflowCheckpointSegment(state, entry.kind, delta);
2281
+ state.activeCheckpointMessageKey = entry.messageKey;
2282
+ deltaThisCheckpoint = true;
2283
+ }
2284
+ }
2285
+
2286
+ // Map the server's per-agent context occupancy onto the assistant usage so the per-message
2287
+ // usage row reflects the real prompt/context size. total_tokens is GitLab's full-history
2288
+ // estimate (the input/prompt side); there is no separate billing usage on this transport.
2289
+ function applyGitLabDuoWorkflowContextUsage(
2290
+ state: GitLabDuoWorkflowStreamState,
2291
+ contextUsage: GitLabDuoWorkflowContextUsage,
2292
+ ): void {
2293
+ const usage = state.output.usage;
2294
+ usage.input = contextUsage.used;
2295
+ usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
2296
+ }
2297
+
2298
+ function emitGitLabDuoWorkflowCheckpointSegment(
2299
+ state: GitLabDuoWorkflowStreamState,
2300
+ kind: GitLabDuoWorkflowCheckpointKind,
2301
+ delta: string,
2302
+ ): void {
2303
+ if (kind === "thinking") {
2304
+ emitGitLabDuoWorkflowThinking(state, delta);
2305
+ return;
2306
+ }
2307
+ emitGitLabDuoWorkflowText(state, delta);
2308
+ }
2309
+
2310
+ function emitGitLabDuoWorkflowText(state: GitLabDuoWorkflowStreamState, text: string): void {
2311
+ if (!text) return;
2312
+ endGitLabDuoWorkflowThinking(state);
2313
+ let activeTextIndex = state.activeTextIndex;
2314
+ if (activeTextIndex === undefined) {
2315
+ const block = { type: "text" as const, text: "" };
2316
+ state.output.content.push(block);
2317
+ activeTextIndex = state.output.content.length - 1;
2318
+ state.activeTextIndex = activeTextIndex;
2319
+ state.stream.push({ type: "text_start", contentIndex: activeTextIndex, partial: state.output });
2320
+ }
2321
+ const block = state.output.content[activeTextIndex];
2322
+ if (block?.type !== "text") return;
2323
+ block.text += text;
2324
+ state.stream.push({ type: "text_delta", contentIndex: activeTextIndex, delta: text, partial: state.output });
2325
+ }
2326
+
2327
+ function emitGitLabDuoWorkflowThinking(state: GitLabDuoWorkflowStreamState, thinking: string): void {
2328
+ if (!thinking) return;
2329
+ endGitLabDuoWorkflowText(state);
2330
+ let activeThinkingIndex = state.activeThinkingIndex;
2331
+ if (activeThinkingIndex === undefined) {
2332
+ const block = { type: "thinking" as const, thinking: "" };
2333
+ state.output.content.push(block);
2334
+ activeThinkingIndex = state.output.content.length - 1;
2335
+ state.activeThinkingIndex = activeThinkingIndex;
2336
+ state.stream.push({ type: "thinking_start", contentIndex: activeThinkingIndex, partial: state.output });
2337
+ }
2338
+ const block = state.output.content[activeThinkingIndex];
2339
+ if (block?.type !== "thinking") return;
2340
+ block.thinking += thinking;
2341
+ state.stream.push({
2342
+ type: "thinking_delta",
2343
+ contentIndex: activeThinkingIndex,
2344
+ delta: thinking,
2345
+ partial: state.output,
2346
+ });
2347
+ }
2348
+
2349
+ function endGitLabDuoWorkflowText(state: GitLabDuoWorkflowStreamState): void {
2350
+ if (state.activeTextIndex === undefined) return;
2351
+ const block = state.output.content[state.activeTextIndex];
2352
+ if (block?.type === "text") {
2353
+ state.stream.push({
2354
+ type: "text_end",
2355
+ contentIndex: state.activeTextIndex,
2356
+ content: block.text,
2357
+ partial: state.output,
2358
+ });
2359
+ }
2360
+ state.activeTextIndex = undefined;
2361
+ }
2362
+
2363
+ function endGitLabDuoWorkflowThinking(state: GitLabDuoWorkflowStreamState): void {
2364
+ if (state.activeThinkingIndex === undefined) return;
2365
+ const block = state.output.content[state.activeThinkingIndex];
2366
+ if (block?.type === "thinking") {
2367
+ state.stream.push({
2368
+ type: "thinking_end",
2369
+ contentIndex: state.activeThinkingIndex,
2370
+ content: block.thinking,
2371
+ partial: state.output,
2372
+ });
2373
+ }
2374
+ state.activeThinkingIndex = undefined;
2375
+ }
2376
+
2377
+ function finishGitLabDuoWorkflowStream(
2378
+ state: GitLabDuoWorkflowStreamState,
2379
+ reason: Extract<AssistantMessage["stopReason"], "stop" | "length" | "toolUse">,
2380
+ ): void {
2381
+ endGitLabDuoWorkflowText(state);
2382
+ endGitLabDuoWorkflowThinking(state);
2383
+ state.output.stopReason = reason;
2384
+ state.stream.push({ type: "done", reason, message: state.output });
2385
+ }
2386
+
2387
+ // Finalize a resumed-socket turn. `action`/`pause` keep the session alive for the
2388
+ // next resume; every other result (`terminal`/`closed`/`approval`/`timeout`) drops
2389
+ // the resumable session, and — because only `terminal` carries a server `done` —
2390
+ // emits a terminal `done` for the rest so the assistant stream never hangs open
2391
+ // after a tool result the way the fresh-workflow loop already finalizes.
2392
+ function finalizeGitLabDuoWorkflowResumeResult(
2393
+ state: GitLabDuoWorkflowStreamState,
2394
+ providerSessionState: GitLabDuoWorkflowProviderSessionState | undefined,
2395
+ result: GitLabDuoWorkflowSocketResult,
2396
+ ): void {
2397
+ if (result === "action" || result === "pause") return;
2398
+ if (providerSessionState) {
2399
+ providerSessionState.active = undefined;
2400
+ }
2401
+ if (result !== "terminal" && !state.stream.done) {
2402
+ finishGitLabDuoWorkflowStream(state, "stop");
2403
+ }
2404
+ }
2405
+
2406
+ // Run a resume on a preserved socket (action-result or pause replay) and finalize it
2407
+ // the same way the fresh-workflow loop does, returning the settled socket result so
2408
+ // the caller can react to a stall. If the resume rejects — the preserved WebSocket
2409
+ // errored, or `ws.send` threw because it closed while the local tool ran — the
2410
+ // preserved session would otherwise be left with `active` still set and the server
2411
+ // workflow still running. Drop `active` and fire a best-effort stop before rethrowing
2412
+ // so the next turn never resumes a dead socket or strands the workflow.
2413
+ async function resumeGitLabDuoWorkflowSocket(
2414
+ args: {
2415
+ fetchImpl: FetchImpl;
2416
+ baseUrl: string;
2417
+ apiKey: string;
2418
+ workflowId: string;
2419
+ state: GitLabDuoWorkflowStreamState;
2420
+ providerSessionState: GitLabDuoWorkflowProviderSessionState | undefined;
2421
+ },
2422
+ run: () => Promise<GitLabDuoWorkflowSocketResult>,
2423
+ ): Promise<GitLabDuoWorkflowSocketResult> {
2424
+ let socketResult: GitLabDuoWorkflowSocketResult;
2425
+ try {
2426
+ socketResult = await run();
2427
+ } catch (error) {
2428
+ if (args.providerSessionState) {
2429
+ args.providerSessionState.active = undefined;
2430
+ }
2431
+ await stopGitLabDuoWorkflow(args.fetchImpl, args.baseUrl, args.apiKey, args.workflowId);
2432
+ throw error;
2433
+ }
2434
+ // A stall on the resumed socket must NOT finalize the stream: the caller re-seeds a
2435
+ // fresh workflow (rebuilt goal includes the just-returned tool result) to break the
2436
+ // non-advancing loop. Stop the stalled workflow and drop `active` here so the caller
2437
+ // owns a clean slate, but leave the stream open for the fresh run.
2438
+ if (socketResult === "stalled") {
2439
+ if (args.providerSessionState) args.providerSessionState.active = undefined;
2440
+ await stopGitLabDuoWorkflow(args.fetchImpl, args.baseUrl, args.apiKey, args.workflowId);
2441
+ return socketResult;
2442
+ }
2443
+ finalizeGitLabDuoWorkflowResumeResult(args.state, args.providerSessionState, socketResult);
2444
+ // `action`/`pause` keep the session alive for the next resume; `terminal` is a real
2445
+ // server completion. But `closed`/`timeout` (and an exhausted `approval`) settle the
2446
+ // local stream while the remote workflow may still be running — mirror the fresh-
2447
+ // workflow `finally` and send the stop PATCH so a half-open/dropped socket after a
2448
+ // tool result never strands the server-side workflow with no local handle left.
2449
+ if (socketResult === "closed" || socketResult === "timeout") {
2450
+ await stopGitLabDuoWorkflow(args.fetchImpl, args.baseUrl, args.apiKey, args.workflowId);
2451
+ }
2452
+ return socketResult;
2453
+ }
2454
+
2455
+ function pauseGitLabDuoWorkflowStream(state: GitLabDuoWorkflowStreamState): void {
2456
+ endGitLabDuoWorkflowText(state);
2457
+ endGitLabDuoWorkflowThinking(state);
2458
+ state.output.stopReason = "stop";
2459
+ state.output.stopDetails = { type: "pause_turn" };
2460
+ state.stream.push({ type: "done", reason: "stop", message: state.output });
2461
+ }
2462
+ interface GitLabDuoWorkflowReplayToolCall {
2463
+ id: string;
2464
+ name: string;
2465
+ arguments: Record<string, unknown>;
2466
+ }
2467
+
2468
+ interface GitLabDuoWorkflowReplayMessage {
2469
+ role: "user" | "assistant" | "tool";
2470
+ content: string;
2471
+ toolCalls?: GitLabDuoWorkflowReplayToolCall[];
2472
+ toolCallId?: string;
2473
+ toolName?: string;
2474
+ isError?: boolean;
2475
+ }
2476
+
2477
+ // Trimmed once: the static note tells the model the goal transcript's ChatML/`<ran>`
2478
+ // markers are a historical record, not a syntax to emit.
2479
+ const GITLAB_DUO_WORKFLOW_CHATML_HISTORY_NOTE = chatmlHistoryNote.trim();
2480
+
2481
+ // The OMP system prompt that rides the inline flow's `prompt_template.system` slot.
2482
+ // DWS wraps it in its own gateway boilerplate, but the slot content is delivered to
2483
+ // the model verbatim, so OMP's authoritative rules go here directly — no redirect
2484
+ // preamble and no embedding inside the goal. When the goal is a multi-turn ChatML
2485
+ // transcript (not a lone bare-text prompt), append the history-note so the model does
2486
+ // not mimic the transcript's `<|im_start|>`/`<ran …>` markers as its own tool-call
2487
+ // output — markers it kept copying even after they were reframed to past tense.
2488
+ function buildGitLabDuoWorkflowSystemPrompt(context: Context): string {
2489
+ const base = normalizeSystemPrompts(context.systemPrompt).join("\n\n");
2490
+ if (!isGitLabDuoWorkflowChatMlGoal(context)) return base;
2491
+ return base ? `${base}\n\n${GITLAB_DUO_WORKFLOW_CHATML_HISTORY_NOTE}` : GITLAB_DUO_WORKFLOW_CHATML_HISTORY_NOTE;
2492
+ }
2493
+
2494
+ // A goal renders as a literal ChatML transcript only when more than one turn survives
2495
+ // the replay filter; a lone turn is sent as bare text (see buildGitLabDuoWorkflowGoal),
2496
+ // so the history-note would describe markers that are not present.
2497
+ function isGitLabDuoWorkflowChatMlGoal(context: Context): boolean {
2498
+ return buildGitLabDuoWorkflowConversationHistory(context.messages).length > 1;
2499
+ }
2500
+
2501
+ // The goal carries ONLY the conversation, rendered as a bare ChatML transcript. The
2502
+ // system prompt lives in the flow's system slot, so the goal needs no envelope, no
2503
+ // `<instructions>` section, and no preamble. A lone turn is sent verbatim; a real
2504
+ // multi-turn session becomes the flat ChatML transcript, every turn equal-weight,
2505
+ // ending naturally on the last turn. ChatML markers are literal text here (DWS does
2506
+ // not tokenize the goal as a chat template), chosen because `<|im_start|>`/`<|im_end|>`
2507
+ // effectively never collide with natural message content and are not Claude-reserved
2508
+ // conversation sequences the way `Human:`/`Assistant:` are.
2509
+ function buildGitLabDuoWorkflowGoal(context: Context): string {
2510
+ const conversation = buildGitLabDuoWorkflowConversationHistory(context.messages);
2511
+ if (conversation.length <= 1) {
2512
+ return extractLatestUserPrompt(context.messages);
2513
+ }
2514
+ return renderGitLabDuoWorkflowChatMl(conversation);
2515
+ }
2516
+
2517
+ const GITLAB_DUO_WORKFLOW_CHATML_START = "<|im_start|>";
2518
+ const GITLAB_DUO_WORKFLOW_CHATML_END = "<|im_end|>";
2519
+
2520
+ // Render the flat transcript as literal ChatML. Each turn is
2521
+ // `<|im_start|>role\n<body><|im_end|>`. An assistant turn that issued tool calls
2522
+ // renders them after its text as `<ran NAME>{args}</ran>` records — a PAST-tense log
2523
+ // of a call that already executed, deliberately NOT the `{name,arguments}` shape the
2524
+ // live structured tool-use channel uses, so the model reads history as a record and
2525
+ // does not mimic it as emittable call grammar. The paired result rides the next
2526
+ // `tool` turn, linked by adjacency (1 call/turn), so the chain stays intact.
2527
+ function renderGitLabDuoWorkflowChatMl(conversation: readonly GitLabDuoWorkflowReplayMessage[]): string {
2528
+ return conversation.map(renderGitLabDuoWorkflowChatMlTurn).join("\n");
2529
+ }
2530
+
2531
+ function renderGitLabDuoWorkflowChatMlTurn(message: GitLabDuoWorkflowReplayMessage): string {
2532
+ const body = gitLabDuoWorkflowChatMlBody(message);
2533
+ return `${GITLAB_DUO_WORKFLOW_CHATML_START}${message.role}\n${body}${GITLAB_DUO_WORKFLOW_CHATML_END}`;
2534
+ }
2535
+
2536
+ function gitLabDuoWorkflowChatMlBody(message: GitLabDuoWorkflowReplayMessage): string {
2537
+ const parts: string[] = [];
2538
+ if (message.content.length > 0) parts.push(message.content);
2539
+ if (message.role === "assistant" && message.toolCalls) {
2540
+ for (const toolCall of message.toolCalls) {
2541
+ parts.push(renderGitLabDuoWorkflowChatMlToolCall(toolCall));
2542
+ }
2543
+ }
2544
+ if (message.role === "tool") {
2545
+ const header = gitLabDuoWorkflowChatMlToolResultHeader(message);
2546
+ return header ? `${header}\n${message.content}\n` : `${message.content}\n`;
2547
+ }
2548
+ return `${parts.join("\n")}\n`;
2549
+ }
2550
+
2551
+ function gitLabDuoWorkflowChatMlToolResultHeader(message: GitLabDuoWorkflowReplayMessage): string | undefined {
2552
+ if (!message.toolName && !message.toolCallId) return undefined;
2553
+ const status = message.isError ? " status=error" : "";
2554
+ // The tool name is omitted: the result rides the turn immediately after its call
2555
+ // (1:1, adjacent), so the model pairs them by position; repeating the name is dead
2556
+ // weight and makes the result read like an independent construct. `<ran:result>` is
2557
+ // past-tense — the adjacent output of the prior historical run, not emittable grammar.
2558
+ return `<ran:result${status}>`;
2559
+ }
2560
+
2561
+ function renderGitLabDuoWorkflowChatMlToolCall(toolCall: GitLabDuoWorkflowReplayToolCall): string {
2562
+ // The goal is a plain text transcript fed to the model, not an HTML/script
2563
+ // context, so `<`/`>` need no escaping. Render as a past-tense `<ran NAME>` record:
2564
+ // the tag names the tool, the body is just the arguments JSON (the `{name,arguments}`
2565
+ // wrapper is dropped — it was the exact shape the model copied as a would-be live
2566
+ // call). The call id is OMP-internal wiring the model never reads (call→result pair
2567
+ // by adjacency), so it is omitted to save bytes. `arguments` carries the `i` (intent)
2568
+ // key only at live dispatch; on replay it is stripped (see gitLabDuoWorkflowAssistantToolCalls).
2569
+ const args = JSON.stringify(toolCall.arguments) ?? "null";
2570
+ return `<ran ${toolCall.name}>${args}</ran>`;
2571
+ }
2572
+
2573
+ // The whole session as a flat, equal-weight transcript. Every turn — including the
2574
+ // latest user message — is one entry; nothing is elevated to a privileged
2575
+ // `<current_request>`. DWS' goal blob has no native turn priority, so elevating the
2576
+ // last turn (the old template) caused mid-task reminders / IRC wakes to outrank the
2577
+ // actual task. A flat transcript ending naturally on the last turn removes that skew.
2578
+ function buildGitLabDuoWorkflowConversationHistory(messages: readonly Message[]): GitLabDuoWorkflowReplayMessage[] {
2579
+ const history: GitLabDuoWorkflowReplayMessage[] = [];
2580
+ for (let index = 0; index < messages.length; index++) {
2581
+ const replayMessage = buildGitLabDuoWorkflowReplayMessage(messages[index]);
2582
+ if (replayMessage) history.push(replayMessage);
2583
+ }
2584
+ return history;
2585
+ }
2586
+
2587
+ function buildGitLabDuoWorkflowReplayMessage(message: Message | undefined): GitLabDuoWorkflowReplayMessage | undefined {
2588
+ if (!message) return undefined;
2589
+ if (message.role === "toolResult") {
2590
+ const content = gitLabDuoWorkflowMessageContentToText(message);
2591
+ return {
2592
+ role: "tool",
2593
+ content,
2594
+ toolCallId: message.toolCallId,
2595
+ toolName: message.toolName,
2596
+ isError: message.isError,
2597
+ };
2598
+ }
2599
+ if (message.role === "assistant") {
2600
+ const content = gitLabDuoWorkflowMessageContentToText(message);
2601
+ const toolCalls = gitLabDuoWorkflowAssistantToolCalls(message);
2602
+ if (content.length === 0 && toolCalls.length === 0) return undefined;
2603
+ return toolCalls.length > 0 ? { role: "assistant", content, toolCalls } : { role: "assistant", content };
2604
+ }
2605
+ const content = gitLabDuoWorkflowMessageContentToText(message);
2606
+ if (content.length === 0) return undefined;
2607
+ return { role: "user", content };
2608
+ }
2609
+
2610
+ function gitLabDuoWorkflowAssistantToolCalls(message: AssistantMessage): GitLabDuoWorkflowReplayToolCall[] {
2611
+ const toolCalls: GitLabDuoWorkflowReplayToolCall[] = [];
2612
+ for (const item of message.content) {
2613
+ if (item.type === "toolCall") {
2614
+ toolCalls.push({
2615
+ id: item.id,
2616
+ name: item.name,
2617
+ arguments: stripGitLabDuoWorkflowReplayIntent(item.arguments),
2618
+ });
2619
+ }
2620
+ }
2621
+ return toolCalls;
2622
+ }
2623
+
2624
+ // The `i` key is OMP's per-call intent narration (e.g. "Reading kernel smoke body").
2625
+ // It is UI-time metadata describing the call as it is made; on replay the tool name
2626
+ // plus arguments already say what the call did, so the intent is dead transcript
2627
+ // weight. Drop it from the rendered history. (Live dispatch never reads the replayed
2628
+ // args, so this only affects the bytes the model sees, never tool execution.)
2629
+ function stripGitLabDuoWorkflowReplayIntent(args: Record<string, unknown>): Record<string, unknown> {
2630
+ if (!("i" in args)) return args;
2631
+ const { i: _intent, ...rest } = args;
2632
+ return rest;
2633
+ }
2634
+
2635
+ function extractLatestUserPrompt(messages: readonly Message[]): string {
2636
+ const index = findLatestGitLabDuoWorkflowUserMessageIndex(messages);
2637
+ if (index < 0) return "";
2638
+ return gitLabDuoWorkflowUserContentToText(messages[index] as Exclude<Message, AssistantMessage>);
2639
+ }
2640
+
2641
+ function findLatestGitLabDuoWorkflowUserMessageIndex(messages: readonly Message[]): number {
2642
+ for (let index = messages.length - 1; index >= 0; index--) {
2643
+ const message = messages[index];
2644
+ if (message?.role === "user" || message?.role === "developer") return index;
2645
+ }
2646
+ return -1;
2647
+ }
2648
+
2649
+ function gitLabDuoWorkflowMessageContentToText(message: Message): string {
2650
+ if (message.role === "assistant") {
2651
+ return message.content
2652
+ .map(item => {
2653
+ if (item.type === "text") return item.text;
2654
+ if (item.type === "thinking" || item.type === "redactedThinking") return "";
2655
+ return "";
2656
+ })
2657
+ .join("\n");
2658
+ }
2659
+ return gitLabDuoWorkflowUserContentToText(message);
2660
+ }
2661
+
2662
+ function gitLabDuoWorkflowUserContentToText(message: Exclude<Message, AssistantMessage>): string {
2663
+ if (typeof message.content === "string") return message.content;
2664
+ return message.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
2665
+ }
2666
+
2667
+ export function describeGitLabDuoWorkflowSocketEvent(event: unknown): string {
2668
+ const fields: string[] = [];
2669
+ if (event && typeof event === "object") {
2670
+ const type = getRecordString(event, "type");
2671
+ const message = getRecordString(event, "message");
2672
+ const code = getRecordString(event, "code");
2673
+ const reason = getRecordString(event, "reason");
2674
+ const error = socketEventErrorText((event as Record<string, unknown>).error);
2675
+ if (type) fields.push(`type=${type}`);
2676
+ if (message) fields.push(`message=${message}`);
2677
+ if (error) fields.push(`error=${error}`);
2678
+ if (code) fields.push(`code=${code}`);
2679
+ if (reason) fields.push(`reason=${reason}`);
2680
+ }
2681
+ const fallback = fields.length > 0 ? fields.join(", ") : String(event);
2682
+ return gitLabDuoWorkflowErrorText(fallback);
2683
+ }
2684
+
2685
+ function socketEventErrorText(error: unknown): string | undefined {
2686
+ if (typeof error === "string" || typeof error === "number") return String(error);
2687
+ if (error instanceof Error) return error.message;
2688
+ if (error && typeof error === "object") {
2689
+ return getRecordString(error, "message") ?? getRecordString(error, "name");
2690
+ }
2691
+ return undefined;
2692
+ }
2693
+
2694
+ export function traceGitLabDuoWorkflow(event: string, data: Record<string, unknown> = {}): void {
2695
+ if (Bun.env[GITLAB_DUO_WORKFLOW_TRACE_ENV] !== "1") return;
2696
+ const traceFile = Bun.env[GITLAB_DUO_WORKFLOW_TRACE_FILE_ENV]?.trim() || DEFAULT_GITLAB_DUO_WORKFLOW_TRACE_FILE;
2697
+ const line = `${JSON.stringify({
2698
+ time: new Date().toISOString(),
2699
+ event,
2700
+ ...truncateGitLabTraceData(data),
2701
+ })}\n`;
2702
+ void fs
2703
+ .mkdir(path.dirname(traceFile), { recursive: true })
2704
+ .then(() => fs.appendFile(traceFile, line, "utf8"))
2705
+ .catch(() => {});
2706
+ }
2707
+
2708
+ function truncateGitLabTraceData(data: Record<string, unknown>): Record<string, unknown> {
2709
+ const truncated: Record<string, unknown> = {};
2710
+ for (const [key, value] of Object.entries(data)) {
2711
+ truncated[key] = truncateGitLabTraceValue(value);
2712
+ }
2713
+ return truncated;
2714
+ }
2715
+
2716
+ function truncateGitLabTraceValue(value: unknown): unknown {
2717
+ if (typeof value === "string") return value.slice(0, 500);
2718
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
2719
+ if (Array.isArray(value)) return value.slice(0, 20).map(item => truncateGitLabTraceValue(item));
2720
+ if (value && typeof value === "object") return truncateGitLabTraceData(value as Record<string, unknown>);
2721
+ return value;
2722
+ }
2723
+
2724
+ function normalizeGitLabBaseUrl(baseUrl: string): string {
2725
+ return baseUrl.replace(/\/+$/, "") || DEFAULT_GITLAB_BASE_URL;
2726
+ }
2727
+
2728
+ // Join a GitLab API path onto a base URL while preserving any relative install path
2729
+ // (e.g. self-managed `https://host/gitlab`). `new URL("/api/...", base)` discards the
2730
+ // base path; concatenating onto the trailing-slash-trimmed base keeps it.
2731
+ function gitLabApiUrl(baseUrl: string, path: string): URL {
2732
+ const normalized = normalizeGitLabBaseUrl(baseUrl);
2733
+ return new URL(`${normalized}${path.startsWith("/") ? path : `/${path}`}`);
2734
+ }
2735
+
2736
+ function normalizeGitLabDuoWorkflowServiceBaseUrl(baseUrl: string): string {
2737
+ const trimmed = baseUrl.trim();
2738
+ const absolute = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
2739
+ return normalizeGitLabBaseUrl(absolute);
2740
+ }
2741
+
2742
+ function toGitLabGraphQLNamespaceId(rootNamespaceId: string): string {
2743
+ if (/^\d+$/.test(rootNamespaceId)) return `gid://gitlab/Group/${rootNamespaceId}`;
2744
+ return rootNamespaceId;
2745
+ }
2746
+
2747
+ function toGitLabRestNamespaceId(rootNamespaceId: string): string {
2748
+ const match = rootNamespaceId.match(/^gid:\/\/gitlab\/(?:Group|Namespace)\/(\d+)$/);
2749
+ return match?.[1] ?? rootNamespaceId;
2750
+ }
2751
+
2752
+ export function extractGitLabWorkflowToken(payload: GitLabDirectAccessResponse): string | undefined {
2753
+ return (
2754
+ payload.gitlab_rails?.token ??
2755
+ payload.duo_workflow_service?.token ??
2756
+ payload.duo_workflow_access_token ??
2757
+ payload.workflow_token ??
2758
+ payload.token ??
2759
+ payload.access_token ??
2760
+ payload.jwt
2761
+ );
2762
+ }
2763
+
2764
+ export async function resolveGitLabDuoWorkflowNamespaceSelection(
2765
+ model: Model<"gitlab-duo-agent">,
2766
+ options: GitLabDuoWorkflowOptions,
2767
+ apiKey: string,
2768
+ baseUrl: string,
2769
+ fetchImpl: FetchImpl,
2770
+ ): Promise<GitLabDuoWorkflowNamespaceSelection> {
2771
+ // Re-discover the namespace from the current credentials/cwd each turn rather than
2772
+ // trusting model.gitlabDuoWorkflowRootNamespaceId, which can be stale (the account's
2773
+ // other top-level groups, or a cwd/env shift between model refresh and this turn).
2774
+ void model;
2775
+ const configured =
2776
+ nonEmptyString(options.rootNamespaceId) ??
2777
+ nonEmptyString(options.namespaceId) ??
2778
+ nonEmptyString(Bun.env.GITLAB_DUO_NAMESPACE_ID);
2779
+
2780
+ try {
2781
+ const projectId =
2782
+ nonEmptyString(options.projectId) ??
2783
+ nonEmptyString(options.projectPath) ??
2784
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID) ??
2785
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH);
2786
+ return await discoverGitLabDuoWorkflowRuntimeNamespace({
2787
+ apiKey,
2788
+ baseUrl,
2789
+ fetch: fetchImpl,
2790
+ namespaceId: configured,
2791
+ projectId,
2792
+ cwd: options.cwd,
2793
+ });
2794
+ } catch (error) {
2795
+ throw new AIError.ProviderResponseError(
2796
+ `GitLab Duo Workflow runtime namespace resolution failed: ${gitLabDuoWorkflowErrorText(error)}`,
2797
+ { provider: "gitlab-duo-agent", kind: "runtime" },
2798
+ );
2799
+ }
2800
+ }
2801
+
2802
+ export async function resolveGitLabDuoWorkflowRootNamespaceId(
2803
+ model: Model<"gitlab-duo-agent">,
2804
+ options: GitLabDuoWorkflowOptions,
2805
+ apiKey: string,
2806
+ baseUrl: string,
2807
+ fetchImpl: FetchImpl,
2808
+ ): Promise<string> {
2809
+ const selection = await resolveGitLabDuoWorkflowNamespaceSelection(model, options, apiKey, baseUrl, fetchImpl);
2810
+ return selection.rootNamespaceId;
2811
+ }
2812
+
2813
+ function nonEmptyString(value: unknown): string | undefined {
2814
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
2815
+ }
2816
+
2817
+ function resolveGitLabDuoWorkflowDefinition(
2818
+ workflowDefinition: GitLabDuoWorkflowDefinition | undefined,
2819
+ ): GitLabDuoWorkflowDefinition {
2820
+ const configured =
2821
+ nonEmptyString(workflowDefinition) ??
2822
+ nonEmptyString(Bun.env.GITLAB_DUO_WORKFLOW_DEFINITION) ??
2823
+ GITLAB_DUO_WORKFLOW_DEFINITION;
2824
+ return configured;
2825
+ }
2826
+
2827
+ // Every workflow definition OMP ships is the inline ambient flow (Path B /
2828
+ // `flowConfig`); the predicate is kept as a seam for future server-side flows.
2829
+ function isGitLabDuoWorkflowInlineFlow(workflowDefinition: GitLabDuoWorkflowDefinition): boolean {
2830
+ void workflowDefinition;
2831
+ return true;
2832
+ }
2833
+
2834
+ function parseGitLabDuoWorkflowSocketData(data: unknown): Record<string, unknown> | null {
2835
+ if (typeof data === "string") return parseJsonRecord(data);
2836
+ if (data instanceof ArrayBuffer) return parseJsonRecord(new TextDecoder().decode(data));
2837
+ if (data instanceof Uint8Array) return parseJsonRecord(new TextDecoder().decode(data));
2838
+ if (data && typeof data === "object") return data as Record<string, unknown>;
2839
+ return null;
2840
+ }
2841
+
2842
+ function parseJsonRecord(text: string): Record<string, unknown> | null {
2843
+ try {
2844
+ const parsed = JSON.parse(text) as unknown;
2845
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
2846
+ } catch {
2847
+ return null;
2848
+ }
2849
+ }
2850
+
2851
+ function numberField(record: Record<string, unknown>, key: string): number | undefined {
2852
+ const value = record[key];
2853
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
2854
+ }
2855
+
2856
+ function stringField(record: Record<string, unknown>, key: string): string | undefined {
2857
+ return nonEmptyString(record[key]);
2858
+ }
2859
+
2860
+ function extractGitLabDuoWorkflowCheckpoint(
2861
+ event: Record<string, unknown>,
2862
+ ): GitLabDuoWorkflowCheckpointContent | undefined {
2863
+ const action = getRecord(event, "action");
2864
+ const checkpoint =
2865
+ getRecord(action, "newCheckpoint") ?? getRecord(event, "newCheckpoint") ?? getRecord(event, "checkpoint");
2866
+ if (!checkpoint) return undefined;
2867
+ const directText =
2868
+ getRecordString(checkpoint, "message") ??
2869
+ getRecordString(checkpoint, "text") ??
2870
+ getRecordString(checkpoint, "content") ??
2871
+ getNestedRecordString(checkpoint, "checkpoint", "message") ??
2872
+ getNestedRecordString(checkpoint, "checkpoint", "text");
2873
+ const contextUsage = extractGitLabDuoWorkflowContextUsage(event, action, checkpoint);
2874
+ if (directText) {
2875
+ return {
2876
+ entries: [{ kind: "text", messageIndex: 0, messageKey: "direct:text", content: directText }],
2877
+ contentLength: directText.length,
2878
+ contextUsage,
2879
+ };
2880
+ }
2881
+ const checkpointJson = getRecordString(checkpoint, "checkpoint");
2882
+ const content = checkpointJson ? extractGitLabCheckpointEntries(checkpointJson) : undefined;
2883
+ if (content) {
2884
+ if (contextUsage) content.contextUsage = contextUsage;
2885
+ return content;
2886
+ }
2887
+ if (contextUsage) {
2888
+ return { entries: [], contentLength: 0, contextUsage };
2889
+ }
2890
+ return undefined;
2891
+ }
2892
+
2893
+ // GitLab Duo Workflow Service attaches per-agent context occupancy to every checkpoint
2894
+ // (`checkpointer/notifier.py`): agent_context_usage[<agent>] = { total_tokens, max_tokens }.
2895
+ // total_tokens is the server-side token estimate of that agent's full history; max_tokens
2896
+ // is the model context window (claude_opus_4_8 observed at 1_000_000). The field rides on
2897
+ // the event root in practice but can also appear under `action`/`newCheckpoint`.
2898
+ function extractGitLabDuoWorkflowContextUsage(
2899
+ ...sources: (Record<string, unknown> | undefined)[]
2900
+ ): GitLabDuoWorkflowContextUsage | undefined {
2901
+ for (const source of sources) {
2902
+ const usageMap = getRecord(source, "agent_context_usage");
2903
+ if (!usageMap) continue;
2904
+ const selected = selectGitLabDuoWorkflowContextUsageAgent(usageMap);
2905
+ if (selected) return selected;
2906
+ }
2907
+ return undefined;
2908
+ }
2909
+
2910
+ const GITLAB_DUO_WORKFLOW_CONTEXT_AGENT_PRIORITY = ["Chat Agent", "context_builder"];
2911
+
2912
+ function selectGitLabDuoWorkflowContextUsageAgent(
2913
+ usageMap: Record<string, unknown>,
2914
+ ): GitLabDuoWorkflowContextUsage | undefined {
2915
+ for (const preferred of GITLAB_DUO_WORKFLOW_CONTEXT_AGENT_PRIORITY) {
2916
+ const usage = readGitLabDuoWorkflowAgentUsage(usageMap[preferred]);
2917
+ if (usage) return usage;
2918
+ }
2919
+ for (const value of Object.values(usageMap)) {
2920
+ const usage = readGitLabDuoWorkflowAgentUsage(value);
2921
+ if (usage) return usage;
2922
+ }
2923
+ return undefined;
2924
+ }
2925
+
2926
+ function readGitLabDuoWorkflowAgentUsage(value: unknown): GitLabDuoWorkflowContextUsage | undefined {
2927
+ if (!value || typeof value !== "object") return undefined;
2928
+ const record = value as Record<string, unknown>;
2929
+ const used = numberField(record, "total_tokens");
2930
+ const window = numberField(record, "max_tokens");
2931
+ if (used === undefined || window === undefined || window <= 0) return undefined;
2932
+ return { used, window };
2933
+ }
2934
+
2935
+ function extractGitLabCheckpointEntries(checkpointJson: string): GitLabDuoWorkflowCheckpointContent | undefined {
2936
+ const checkpoint = parseJsonRecord(checkpointJson);
2937
+ const channelValues = getRecord(checkpoint, "channel_values");
2938
+ const chatLog = channelValues?.ui_chat_log;
2939
+ if (!Array.isArray(chatLog)) return undefined;
2940
+ const entries: GitLabDuoWorkflowCheckpointEntry[] = [];
2941
+ for (let index = 0; index < chatLog.length; index++) {
2942
+ const entry = chatLog[index];
2943
+ if (!entry || typeof entry !== "object") continue;
2944
+ const record = entry as Record<string, unknown>;
2945
+ const messageType = getRecordString(record, "message_type");
2946
+ if (messageType === "agent") {
2947
+ const content = getRecordString(record, "content");
2948
+ if (!content) continue;
2949
+ const messageId = getRecordString(record, "message_id");
2950
+ // `message_sub_type: "reasoning"` is the agent's pre-tool-call
2951
+ // commentary the inline flow opts into via `on_agent_reasoning`; map it
2952
+ // to a thinking block. Other agent text is the answer → text.
2953
+ const isReasoning = getRecordString(record, "message_sub_type") === "reasoning";
2954
+ const fallbackKey = isReasoning ? `reasoning:${index}` : `agent:${index}`;
2955
+ entries.push({
2956
+ kind: isReasoning ? "thinking" : "text",
2957
+ messageIndex: index,
2958
+ messageKey: messageId ? `agent:${messageId}` : fallbackKey,
2959
+ content,
2960
+ });
2961
+ continue;
2962
+ }
2963
+ if (messageType === "request" || messageType === "tool") {
2964
+ entries.push({ kind: "boundary", messageIndex: index });
2965
+ }
2966
+ }
2967
+ return {
2968
+ entries,
2969
+ contentLength: checkpointJson.length,
2970
+ latestMessageType: getGitLabDuoWorkflowLatestMessageType(chatLog),
2971
+ };
2972
+ }
2973
+
2974
+ function getGitLabDuoWorkflowLatestMessageType(chatLog: unknown[]): string | undefined {
2975
+ for (let index = chatLog.length - 1; index >= 0; index--) {
2976
+ const entry = chatLog[index];
2977
+ if (!entry || typeof entry !== "object") continue;
2978
+ const messageType = getRecordString(entry, "message_type");
2979
+ if (messageType) return messageType;
2980
+ }
2981
+ return undefined;
2982
+ }
2983
+
2984
+ function extractGitLabDuoWorkflowAction(event: Record<string, unknown>): GitLabDuoWorkflowActionDescriptor | undefined {
2985
+ const wrappedAction =
2986
+ getRecord(event, "action") ?? getRecord(event, "workflowAction") ?? getRecord(event, "toolCall");
2987
+ if (wrappedAction) {
2988
+ if (getRecord(wrappedAction, "newCheckpoint")) return undefined;
2989
+ const name =
2990
+ getRecordString(wrappedAction, "name") ??
2991
+ getRecordString(wrappedAction, "action") ??
2992
+ getRecordString(wrappedAction, "type") ??
2993
+ getRecordString(event, "actionName");
2994
+ if (!name) return undefined;
2995
+ const requestID =
2996
+ getRecordString(wrappedAction, "requestID") ??
2997
+ getRecordString(wrappedAction, "requestId") ??
2998
+ getRecordString(wrappedAction, "id") ??
2999
+ getRecordString(event, "requestID") ??
3000
+ getRecordString(event, "requestId");
3001
+ const resolvedRequestID = requireGitLabDuoWorkflowRequestID(requestID, name, wrappedAction);
3002
+ const args = getRecord(wrappedAction, "args") ?? getRecord(wrappedAction, "arguments") ?? wrappedAction;
3003
+ return { requestID: resolvedRequestID, name, args: withGitLabDuoWorkflowToolCallId(args, resolvedRequestID) };
3004
+ }
3005
+ for (const name of GITLAB_DUO_WORKFLOW_ACTION_NAMES) {
3006
+ const args = getRecord(event, name);
3007
+ if (args) {
3008
+ const requestID = getRecordString(event, "requestID") ?? getRecordString(event, "requestId");
3009
+ const resolvedRequestID = requireGitLabDuoWorkflowRequestID(requestID, name, event);
3010
+ return { requestID: resolvedRequestID, name, args: withGitLabDuoWorkflowToolCallId(args, resolvedRequestID) };
3011
+ }
3012
+ }
3013
+ return undefined;
3014
+ }
3015
+
3016
+ // DWS assigns every executor Action a non-empty `requestID` (contract.proto Action
3017
+ // field 1; emitted verbatim by Workhorse's proto->JSON relay). The client MUST echo
3018
+ // that exact id back in `actionResponse.requestID` or the server's outbox silently
3019
+ // discards the response (outbox.set_action_response: a non-empty id that misses the
3020
+ // awaiting-futures map hits the "doesn't expect responses, discarding" branch) and
3021
+ // the tool call's future never resolves — the model then re-issues the same tool
3022
+ // call, looping. A synthesized id is therefore never correct: it is either redundant
3023
+ // (the real id was present) or actively harmful (guaranteed-discarded). Fail fast so
3024
+ // the socket loop surfaces a protocol drift instead of stalling.
3025
+ function requireGitLabDuoWorkflowRequestID(
3026
+ requestID: string | undefined,
3027
+ actionName: string,
3028
+ source: Record<string, unknown>,
3029
+ ): string {
3030
+ if (requestID) return requestID;
3031
+ throw new AIError.ValidationError(
3032
+ `GitLab Duo Workflow action "${actionName}" missing requestID (keys: ${Object.keys(source).slice(0, 20).join(", ")})`,
3033
+ );
3034
+ }
3035
+
3036
+ function withGitLabDuoWorkflowToolCallId(args: unknown, requestID: string): unknown {
3037
+ const record = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
3038
+ if (typeof record.toolCallId === "string" || typeof record.tool_call_id === "string") {
3039
+ return record;
3040
+ }
3041
+ return { ...record, toolCallId: requestID, tool_call_id: requestID };
3042
+ }
3043
+
3044
+ function getRecord(value: unknown, key: string): Record<string, unknown> | undefined {
3045
+ if (!value || typeof value !== "object") return undefined;
3046
+ const nested = (value as Record<string, unknown>)[key];
3047
+ return nested && typeof nested === "object" ? (nested as Record<string, unknown>) : undefined;
3048
+ }
3049
+
3050
+ function getRecordString(value: unknown, key: string): string | undefined {
3051
+ if (!value || typeof value !== "object") return undefined;
3052
+ const nested = (value as Record<string, unknown>)[key];
3053
+ return typeof nested === "string" || typeof nested === "number" ? String(nested) : undefined;
3054
+ }
3055
+
3056
+ function getNestedRecordString(value: unknown, parentKey: string, key: string): string | undefined {
3057
+ return getRecordString(getRecord(value, parentKey), key);
3058
+ }