terramend 0.2.0

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 (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,1440 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { performance } from "node:perf_hooks";
7
+ import {
8
+ type AssistantMessage,
9
+ createOpencodeClient,
10
+ type EventSubscribeResponse,
11
+ type OpencodeClient,
12
+ type Part,
13
+ } from "@opencode-ai/sdk/v2";
14
+ import { fetch as undiciFetch } from "undici";
15
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import {
17
+ bootOpencodeServer,
18
+ buildSecurityConfig,
19
+ buildUsage,
20
+ consumeEvents,
21
+ dispatchEvent,
22
+ extractTextFromParts,
23
+ formatPartDuration,
24
+ formatPromptError,
25
+ newTurn,
26
+ opencode,
27
+ parseModel,
28
+ processTerminalToolPart,
29
+ type RunnerContext,
30
+ runPromptTurn,
31
+ runTurnGuarded,
32
+ startInnerActivityWatchdog,
33
+ } from "#app/agents/opencode";
34
+ import { TERRAMEND_OPENCODE_GATE_PLUGIN_FILENAME } from "#app/agents/opencodePlugin";
35
+ import { runPostRunRetryLoop } from "#app/agents/postRun";
36
+ import { REVIEWER_AGENT_NAME } from "#app/agents/reviewer";
37
+ import { SessionLabeler } from "#app/agents/sessionLabeler";
38
+ import type { AgentRunContext } from "#app/agents/shared";
39
+ import type { ToolState } from "#app/toolState";
40
+ import { AGENT_ACTIVITY_TIMEOUT_MS } from "#app/utils/activity";
41
+ import { installCodexAuth } from "#app/utils/codexHome";
42
+ import type { TodoTracker } from "#app/utils/todoTracking";
43
+
44
+ // the harness spawns the opencode server binary directly — fake the child
45
+ // process so tests never start a real subprocess on any OS.
46
+ vi.mock("node:child_process", async (importOriginal) => {
47
+ const actual = await importOriginal<typeof import("node:child_process")>();
48
+ return { ...actual, spawn: vi.fn() };
49
+ });
50
+ // the SDK client talks loopback HTTP — replace with a scriptable fake.
51
+ vi.mock("@opencode-ai/sdk/v2", () => ({ createOpencodeClient: vi.fn() }));
52
+ // undici Agent would open real sockets; the fetch override is exercised
53
+ // directly against the mock.
54
+ vi.mock("undici", () => {
55
+ class FakeUndiciAgent {
56
+ options: unknown;
57
+ close = vi.fn(async () => {});
58
+ constructor(options: unknown) {
59
+ this.options = options;
60
+ }
61
+ }
62
+ return { Agent: FakeUndiciAgent, fetch: vi.fn(async () => new Response("ok")) };
63
+ });
64
+ // CLI install hits the npm registry.
65
+ vi.mock("#app/agents/opencodeShared", async (importOriginal) => {
66
+ const actual = await importOriginal<typeof import("#app/agents/opencodeShared")>();
67
+ return { ...actual, installOpencodeCli: vi.fn(async () => "/fake/bin/opencode.exe") };
68
+ });
69
+ // the retry loop shells out to git via collectPostRunIssues — passthrough.
70
+ vi.mock("#app/agents/postRun", async (importOriginal) => {
71
+ const actual = await importOriginal<typeof import("#app/agents/postRun")>();
72
+ return { ...actual, runPostRunRetryLoop: vi.fn() };
73
+ });
74
+ // codex auth materialization touches /var/lib in CI.
75
+ vi.mock("#app/utils/codexHome", async (importOriginal) => {
76
+ const actual = await importOriginal<typeof import("#app/utils/codexHome")>();
77
+ return { ...actual, installCodexAuth: vi.fn(() => null) };
78
+ });
79
+ // skills install writes into the fake HOME.
80
+ vi.mock("#app/utils/skills", () => ({ installBundledSkills: vi.fn() }));
81
+ // terraform-mcp-server resolution probes for docker — pin it per test via the
82
+ // mutable state (a plain closure, so mock resets can't wipe the default).
83
+ const terraformMcpState = vi.hoisted(() => ({
84
+ resolution: { kind: "disabled" } as { kind: string; command?: string; args?: string[] },
85
+ }));
86
+ vi.mock("#app/utils/terraformMcp", async (importOriginal) => {
87
+ const actual = await importOriginal<typeof import("#app/utils/terraformMcp")>();
88
+ return { ...actual, resolveTerraformMcp: () => terraformMcpState.resolution };
89
+ });
90
+ // child tracking installs process-wide signal handlers.
91
+ vi.mock("#app/utils/subprocess", async (importOriginal) => {
92
+ const actual = await importOriginal<typeof import("#app/utils/subprocess")>();
93
+ return { ...actual, trackChild: vi.fn(), untrackChild: vi.fn() };
94
+ });
95
+
96
+ const nodeSpawnMock = vi.mocked(nodeSpawn);
97
+ const createOpencodeClientMock = vi.mocked(createOpencodeClient);
98
+ const undiciFetchMock = vi.mocked(undiciFetch);
99
+ const runPostRunRetryLoopMock = vi.mocked(runPostRunRetryLoop);
100
+ const installCodexAuthMock = vi.mocked(installCodexAuth);
101
+
102
+ const MCP_URL = "http://127.0.0.1:7777/mcp";
103
+ const MCP_TOKEN = "test-mcp-token";
104
+
105
+ function makeCtx(): AgentRunContext {
106
+ // buildSecurityConfig reads ctx.mcpServerUrl + ctx.mcpServerToken + ctx.payload.
107
+ return {
108
+ mcpServerUrl: MCP_URL,
109
+ mcpServerToken: MCP_TOKEN,
110
+ payload: { terraformMcp: false },
111
+ } as unknown as AgentRunContext;
112
+ }
113
+
114
+ interface ParsedSecurityConfig {
115
+ permission: Record<string, string>;
116
+ mcp: Record<
117
+ string,
118
+ { type: string; url?: string; timeout?: number; command?: string[]; enabled?: boolean }
119
+ >;
120
+ agent: Record<string, { mode?: string; prompt?: string }>;
121
+ provider: Record<string, { npm?: string; models?: Record<string, unknown> }>;
122
+ model?: string;
123
+ enabled_providers?: string[];
124
+ }
125
+
126
+ function parseConfig(model: string | undefined): ParsedSecurityConfig {
127
+ return JSON.parse(buildSecurityConfig(makeCtx(), model)) as ParsedSecurityConfig;
128
+ }
129
+
130
+ describe("buildSecurityConfig", () => {
131
+ it("denies native bash and allows the file/web surfaces", () => {
132
+ const config = parseConfig(undefined);
133
+ expect(config.permission).toEqual({
134
+ bash: "deny",
135
+ edit: "allow",
136
+ read: "allow",
137
+ webfetch: "allow",
138
+ external_directory: "allow",
139
+ skill: "allow",
140
+ });
141
+ });
142
+
143
+ it("injects the terramend MCP server with the extended tool timeout and bearer auth", () => {
144
+ const config = parseConfig(undefined);
145
+ expect(config.mcp.terramend).toEqual({
146
+ type: "remote",
147
+ url: MCP_URL,
148
+ // env-expansion placeholder — opencode resolves {env:VAR} from its env.
149
+ headers: { Authorization: "Bearer {env:TERRAMEND_MCP_TOKEN}" },
150
+ timeout: 300_000,
151
+ });
152
+ });
153
+
154
+ it("omits the terraform MCP server by default", () => {
155
+ expect(parseConfig(undefined).mcp.terraform).toBeUndefined();
156
+ });
157
+
158
+ it("registers the terraform MCP server as a local stdio command when available (P2.2)", () => {
159
+ terraformMcpState.resolution = {
160
+ kind: "available",
161
+ command: "docker",
162
+ args: ["run", "-i", "--rm", "hashicorp/terraform-mcp-server:0.5.2", "--toolsets=registry"],
163
+ };
164
+ try {
165
+ const config = parseConfig(undefined);
166
+ expect(config.mcp.terraform).toEqual({
167
+ type: "local",
168
+ command: [
169
+ "docker",
170
+ "run",
171
+ "-i",
172
+ "--rm",
173
+ "hashicorp/terraform-mcp-server:0.5.2",
174
+ "--toolsets=registry",
175
+ ],
176
+ enabled: true,
177
+ });
178
+ // the terramend server is unaffected by the second registration.
179
+ expect(config.mcp.terramend).toEqual({
180
+ type: "remote",
181
+ url: MCP_URL,
182
+ headers: { Authorization: "Bearer {env:TERRAMEND_MCP_TOKEN}" },
183
+ timeout: 300_000,
184
+ });
185
+ } finally {
186
+ terraformMcpState.resolution = { kind: "disabled" };
187
+ }
188
+ });
189
+
190
+ it("registers the reviewer subagent", () => {
191
+ const config = parseConfig(undefined);
192
+ const reviewer = config.agent[REVIEWER_AGENT_NAME];
193
+ expect(reviewer?.mode).toBe("subagent");
194
+ expect(reviewer?.prompt?.length ?? 0).toBeGreaterThan(0);
195
+ });
196
+
197
+ it("omits model and enabled_providers when no model resolved", () => {
198
+ const config = parseConfig(undefined);
199
+ expect(config.model).toBeUndefined();
200
+ expect(config.enabled_providers).toBeUndefined();
201
+ });
202
+
203
+ it("pins the model and restricts enabled_providers to its provider", () => {
204
+ const config = parseConfig("anthropic/claude-opus-4-7");
205
+ expect(config.model).toBe("anthropic/claude-opus-4-7");
206
+ expect(config.enabled_providers).toEqual(["anthropic"]);
207
+ });
208
+
209
+ it("lowercases the provider id for enabled_providers", () => {
210
+ expect(parseConfig("OpenAI/gpt-5.5").enabled_providers).toEqual(["openai"]);
211
+ });
212
+
213
+ it("sets no enabled_providers for a model without a provider prefix", () => {
214
+ const config = parseConfig("gemini-2.5-pro");
215
+ expect(config.model).toBe("gemini-2.5-pro");
216
+ expect(config.enabled_providers).toBeUndefined();
217
+ });
218
+
219
+ it("pins the fixed openrouter stream parser for moonshot models only", () => {
220
+ const moonshot = parseConfig("openrouter/moonshotai/kimi-k2.6");
221
+ expect(moonshot.enabled_providers).toEqual(["openrouter"]);
222
+ expect(moonshot.provider.openrouter?.npm).toBe("@openrouter/ai-sdk-provider@2.9.0");
223
+ expect(moonshot.provider.openrouter?.models).toEqual({ "moonshotai/kimi-k2.6": {} });
224
+
225
+ const other = parseConfig("openrouter/qwen/qwen3-coder");
226
+ expect(other.provider.openrouter).toBeUndefined();
227
+ });
228
+
229
+ it("always carries the gemini high-thinking overrides", () => {
230
+ const config = parseConfig("anthropic/claude-opus-4-7");
231
+ expect(config.provider.google).toBeDefined();
232
+ });
233
+ });
234
+
235
+ describe("parseModel", () => {
236
+ it("splits provider/model into the SDK prompt shape", () => {
237
+ expect(parseModel("anthropic/claude-opus-4-7")).toEqual({
238
+ providerID: "anthropic",
239
+ modelID: "claude-opus-4-7",
240
+ });
241
+ });
242
+
243
+ it("keeps everything after the first slash in the modelID", () => {
244
+ expect(parseModel("openrouter/moonshotai/kimi-k2.6")).toEqual({
245
+ providerID: "openrouter",
246
+ modelID: "moonshotai/kimi-k2.6",
247
+ });
248
+ });
249
+
250
+ it("returns undefined for undefined, bare, and leading-slash values", () => {
251
+ expect(parseModel(undefined)).toBeUndefined();
252
+ expect(parseModel("claude-opus-4-7")).toBeUndefined();
253
+ expect(parseModel("/oops")).toBeUndefined();
254
+ });
255
+ });
256
+
257
+ describe("extractTextFromParts", () => {
258
+ function textOnlyPart(text: string): Part {
259
+ return { type: "text", text } as unknown as Part;
260
+ }
261
+
262
+ it("returns undefined for missing or text-free parts", () => {
263
+ expect(extractTextFromParts(undefined)).toBeUndefined();
264
+ expect(extractTextFromParts([])).toBeUndefined();
265
+ expect(extractTextFromParts([{ type: "step-start" } as unknown as Part])).toBeUndefined();
266
+ expect(extractTextFromParts([textOnlyPart("")])).toBeUndefined();
267
+ });
268
+
269
+ it("joins text parts and skips non-text parts", () => {
270
+ const parts = [
271
+ textOnlyPart("first"),
272
+ { type: "step-start" } as unknown as Part,
273
+ textOnlyPart("second"),
274
+ ];
275
+ expect(extractTextFromParts(parts)).toBe("first\nsecond");
276
+ });
277
+ });
278
+
279
+ describe("formatPromptError", () => {
280
+ it("passes strings through", () => {
281
+ expect(formatPromptError("boom")).toBe("boom");
282
+ });
283
+
284
+ it("prefers message, then error.message, then JSON", () => {
285
+ expect(formatPromptError({ message: "top-level" })).toBe("top-level");
286
+ expect(formatPromptError({ error: { message: "nested" } })).toBe("nested");
287
+ expect(formatPromptError({ data: { code: 500 } })).toBe('{"data":{"code":500}}');
288
+ });
289
+
290
+ it("stringifies primitives and unserializable objects", () => {
291
+ expect(formatPromptError(42)).toBe("42");
292
+ const circular: { self?: unknown } = {};
293
+ circular.self = circular;
294
+ expect(formatPromptError(circular)).toBe("[object Object]");
295
+ });
296
+ });
297
+
298
+ describe("formatPartDuration", () => {
299
+ it("returns an empty string when timing is missing or non-positive", () => {
300
+ expect(formatPartDuration(undefined)).toBe("");
301
+ expect(formatPartDuration({ start: 100 })).toBe("");
302
+ expect(formatPartDuration({ start: 100, end: 100 })).toBe("");
303
+ expect(formatPartDuration({ start: 200, end: 100 })).toBe("");
304
+ });
305
+
306
+ it("renders the duration in seconds", () => {
307
+ expect(formatPartDuration({ start: 0, end: 1500 })).toBe(" (1.5s)");
308
+ });
309
+ });
310
+
311
+ // ── in-process SDK harness ──────────────────────────────────────────────────────
312
+
313
+ const ORCH_SESSION = "ses_orch";
314
+ const tempDirs: string[] = [];
315
+
316
+ afterAll(() => {
317
+ for (const dir of tempDirs) {
318
+ rmSync(dir, { recursive: true, force: true });
319
+ }
320
+ });
321
+
322
+ afterEach(() => {
323
+ vi.unstubAllEnvs();
324
+ vi.restoreAllMocks();
325
+ });
326
+
327
+ /** ChildProcess stand-in: EventEmitter with stdout/stderr streams and a kill
328
+ * that emits `close` on the next tick (matching async signal delivery). */
329
+ class FakeChildProcess extends EventEmitter {
330
+ stdout = new EventEmitter();
331
+ stderr = new EventEmitter();
332
+ pid = 424242;
333
+ killed = false;
334
+ kill = vi.fn((signal?: NodeJS.Signals | number): boolean => {
335
+ this.killed = true;
336
+ setImmediate(() => {
337
+ this.emit("close", null, typeof signal === "string" ? signal : "SIGTERM");
338
+ });
339
+ return true;
340
+ });
341
+ }
342
+
343
+ function stubSpawnedProcess(proc: FakeChildProcess): void {
344
+ nodeSpawnMock.mockImplementation(() => proc as unknown as ReturnType<typeof nodeSpawn>);
345
+ }
346
+
347
+ /** route process-group kills to the per-child fallback (no real signals). */
348
+ function stubProcessKill(): void {
349
+ vi.spyOn(process, "kill").mockImplementation((pid: number) => {
350
+ if (pid < 0) {
351
+ throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" });
352
+ }
353
+ return true;
354
+ });
355
+ }
356
+
357
+ interface FakeClient {
358
+ session: {
359
+ create: ReturnType<typeof vi.fn>;
360
+ prompt: ReturnType<typeof vi.fn>;
361
+ messages: ReturnType<typeof vi.fn>;
362
+ };
363
+ event: { subscribe: ReturnType<typeof vi.fn> };
364
+ }
365
+
366
+ function makeClient(): FakeClient {
367
+ return {
368
+ session: {
369
+ create: vi.fn(async () => ({ data: { id: ORCH_SESSION } })),
370
+ prompt: vi.fn(async () => ({ data: { info: assistantInfo(), parts: [] } })),
371
+ messages: vi.fn(async () => ({ data: [] })),
372
+ },
373
+ event: {
374
+ subscribe: vi.fn(async () => ({ stream: (async function* () {})() })),
375
+ },
376
+ };
377
+ }
378
+
379
+ function assistantInfo(overrides?: Record<string, unknown>): AssistantMessage {
380
+ return {
381
+ id: "msg_assistant",
382
+ sessionID: ORCH_SESSION,
383
+ role: "assistant",
384
+ time: { created: Date.now() },
385
+ parentID: "",
386
+ modelID: "claude-opus-4-7",
387
+ providerID: "anthropic",
388
+ mode: "build",
389
+ agent: "general",
390
+ path: { cwd: "/", root: "/" },
391
+ cost: 0.1,
392
+ tokens: { input: 50, output: 9, reasoning: 0, cache: { read: 3, write: 4 } },
393
+ ...overrides,
394
+ } as unknown as AssistantMessage;
395
+ }
396
+
397
+ function makeRunnerCtx(client: FakeClient, overrides?: Partial<RunnerContext>): RunnerContext {
398
+ const labeler = new SessionLabeler();
399
+ labeler.labelFor(ORCH_SESSION);
400
+ return {
401
+ client: client as unknown as OpencodeClient,
402
+ sessionID: ORCH_SESSION,
403
+ label: "Terramend",
404
+ orchestratorSessionID: ORCH_SESSION,
405
+ labeler,
406
+ toolState: {} as unknown as ToolState,
407
+ todoTracker: undefined,
408
+ onActivityTimeout: undefined,
409
+ onToolUse: undefined,
410
+ currentTurn: null,
411
+ eventCount: 0,
412
+ lastEventAt: performance.now(),
413
+ taskDispatchByCallID: new Map(),
414
+ loggedToolCallIDs: new Set(),
415
+ recentStderr: [],
416
+ diagnostic: {
417
+ label: "Terramend",
418
+ recentStderr: [],
419
+ lastProviderError: undefined,
420
+ eventCount: 0,
421
+ },
422
+ ...overrides,
423
+ };
424
+ }
425
+
426
+ function makeTodoTracker(): TodoTracker & {
427
+ update: ReturnType<typeof vi.fn>;
428
+ cancel: ReturnType<typeof vi.fn>;
429
+ flush: ReturnType<typeof vi.fn>;
430
+ } {
431
+ return {
432
+ enabled: true,
433
+ update: vi.fn(),
434
+ cancel: vi.fn(),
435
+ flush: vi.fn(async () => {}),
436
+ } as unknown as TodoTracker & {
437
+ update: ReturnType<typeof vi.fn>;
438
+ cancel: ReturnType<typeof vi.fn>;
439
+ flush: ReturnType<typeof vi.fn>;
440
+ };
441
+ }
442
+
443
+ function sdkTextPart(text: string, sessionID = ORCH_SESSION, end?: number): Part {
444
+ return {
445
+ id: `prt_${text.slice(0, 8)}`,
446
+ sessionID,
447
+ messageID: "msg_1",
448
+ type: "text",
449
+ text,
450
+ time: { start: 1, ...(end === undefined ? {} : { end }) },
451
+ } as unknown as Part;
452
+ }
453
+
454
+ type ToolPartShape = Extract<Part, { type: "tool" }>;
455
+
456
+ function sdkToolPart(params: {
457
+ tool: string;
458
+ callID: string;
459
+ sessionID?: string;
460
+ status: "pending" | "running" | "completed" | "error";
461
+ input?: Record<string, unknown>;
462
+ output?: string;
463
+ error?: string;
464
+ }): ToolPartShape {
465
+ const state =
466
+ params.status === "completed"
467
+ ? {
468
+ status: "completed",
469
+ input: params.input ?? {},
470
+ output: params.output ?? "",
471
+ title: "title",
472
+ metadata: {},
473
+ time: { start: 1, end: 2 },
474
+ }
475
+ : params.status === "error"
476
+ ? {
477
+ status: "error",
478
+ input: params.input ?? {},
479
+ error: params.error ?? "tool exploded",
480
+ time: { start: 1, end: 2 },
481
+ }
482
+ : params.status === "running"
483
+ ? { status: "running", input: params.input ?? {}, time: { start: 1 } }
484
+ : { status: "pending" };
485
+ return {
486
+ id: `prt_${params.callID}`,
487
+ sessionID: params.sessionID ?? ORCH_SESSION,
488
+ messageID: "msg_1",
489
+ type: "tool",
490
+ callID: params.callID,
491
+ tool: params.tool,
492
+ state,
493
+ } as unknown as ToolPartShape;
494
+ }
495
+
496
+ function partUpdated(part: Part): EventSubscribeResponse {
497
+ return {
498
+ type: "message.part.updated",
499
+ properties: { part },
500
+ } as unknown as EventSubscribeResponse;
501
+ }
502
+
503
+ function sessionErrorEvent(sessionID: string, error?: unknown): EventSubscribeResponse {
504
+ return {
505
+ type: "session.error",
506
+ properties: { sessionID, error },
507
+ } as unknown as EventSubscribeResponse;
508
+ }
509
+
510
+ describe("bootOpencodeServer", () => {
511
+ beforeEach(() => {
512
+ vi.clearAllMocks();
513
+ stubProcessKill();
514
+ });
515
+
516
+ function boot(proc: FakeChildProcess) {
517
+ stubSpawnedProcess(proc);
518
+ return bootOpencodeServer({ cliPath: "/fake/bin/opencode.exe", env: {}, cwd: process.cwd() });
519
+ }
520
+
521
+ it("resolves with the base URL once the listening line arrives (split across chunks)", async () => {
522
+ const proc = new FakeChildProcess();
523
+ const promise = boot(proc);
524
+
525
+ proc.stderr.emit("data", Buffer.from("warming up\n"));
526
+ proc.stdout.emit("data", Buffer.from("opencode server listen"));
527
+ proc.stdout.emit("data", Buffer.from("ing on http://127.0.0.1:43117\nextra noise\n"));
528
+
529
+ const handle = await promise;
530
+ expect(handle.baseUrl).toBe("http://127.0.0.1:43117");
531
+ expect(handle.recentStderr).toContain("warming up");
532
+ expect(nodeSpawnMock).toHaveBeenCalledWith(
533
+ "/fake/bin/opencode.exe",
534
+ ["serve", "--port", "0", "--hostname", "127.0.0.1"],
535
+ expect.objectContaining({ detached: true }),
536
+ );
537
+
538
+ await handle.close();
539
+ expect(proc.kill).toHaveBeenCalledTimes(1);
540
+ expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
541
+ // idempotent: a second close must not re-kill.
542
+ await handle.close();
543
+ expect(proc.kill).toHaveBeenCalledTimes(1);
544
+ });
545
+
546
+ it("escalates to SIGKILL when the server ignores SIGTERM for 2s", async () => {
547
+ vi.useFakeTimers();
548
+ try {
549
+ const proc = new FakeChildProcess();
550
+ const promise = boot(proc);
551
+ proc.stdout.emit(
552
+ "data",
553
+ Buffer.from("opencode server listening on http://127.0.0.1:43117\n"),
554
+ );
555
+ const handle = await promise;
556
+
557
+ // first kill (SIGTERM) is swallowed — no close event, killed stays false
558
+ proc.kill.mockImplementationOnce(() => true);
559
+ const closePromise = handle.close();
560
+ await vi.advanceTimersByTimeAsync(2_000);
561
+ await vi.runAllTimersAsync(); // flush the SIGKILL kill's close emission
562
+ await closePromise;
563
+
564
+ expect(proc.kill).toHaveBeenCalledTimes(2);
565
+ expect(proc.kill).toHaveBeenLastCalledWith("SIGKILL");
566
+ } finally {
567
+ vi.useRealTimers();
568
+ }
569
+ });
570
+
571
+ it("rejects when the spawn itself errors", async () => {
572
+ const proc = new FakeChildProcess();
573
+ const promise = boot(proc);
574
+ const assertion = expect(promise).rejects.toThrow(
575
+ "failed to spawn opencode serve: ENOENT opencode",
576
+ );
577
+ proc.emit("error", new Error("ENOENT opencode"));
578
+ await assertion;
579
+ });
580
+
581
+ it("rejects with the stderr tail when the server exits before ready", async () => {
582
+ const proc = new FakeChildProcess();
583
+ const promise = boot(proc);
584
+ const assertion = expect(promise).rejects.toThrow(
585
+ /exited before ready \(code=1 signal=null\)[\s\S]*port already in use/,
586
+ );
587
+ proc.stderr.emit("data", Buffer.from("port already in use\n"));
588
+ proc.emit("close", 1, null);
589
+ await assertion;
590
+ });
591
+
592
+ it("rejects after the 30s boot timeout", async () => {
593
+ vi.useFakeTimers();
594
+ try {
595
+ const proc = new FakeChildProcess();
596
+ const promise = boot(proc);
597
+ const assertion = expect(promise).rejects.toThrow(/timed out after 30s waiting/);
598
+ vi.advanceTimersByTime(30_000);
599
+ await assertion;
600
+ } finally {
601
+ vi.useRealTimers();
602
+ }
603
+ });
604
+ });
605
+
606
+ describe("dispatchEvent", () => {
607
+ it("captures completed orchestrator text as the turn's final text", async () => {
608
+ const ctx = makeRunnerCtx(makeClient());
609
+ ctx.currentTurn = newTurn();
610
+ const before = ctx.lastEventAt;
611
+
612
+ await dispatchEvent(ctx, partUpdated(sdkTextPart("the answer", ORCH_SESSION, 9)));
613
+
614
+ expect(ctx.currentTurn.finalText).toBe("the answer");
615
+ expect(ctx.lastEventAt).toBeGreaterThanOrEqual(before);
616
+ });
617
+
618
+ it("ignores text without time.end and text from subagent sessions", async () => {
619
+ const ctx = makeRunnerCtx(makeClient());
620
+ ctx.currentTurn = newTurn();
621
+
622
+ await dispatchEvent(ctx, partUpdated(sdkTextPart("streaming…", ORCH_SESSION)));
623
+ expect(ctx.currentTurn.finalText).toBe("");
624
+
625
+ await dispatchEvent(ctx, partUpdated(sdkTextPart("subagent says", "ses_sub", 9)));
626
+ expect(ctx.currentTurn.finalText).toBe("");
627
+ });
628
+
629
+ it("logs reasoning parts (long ones truncated) without touching the turn", async () => {
630
+ const ctx = makeRunnerCtx(makeClient());
631
+ ctx.currentTurn = newTurn();
632
+ const reasoning = {
633
+ id: "prt_r",
634
+ sessionID: ORCH_SESSION,
635
+ messageID: "msg_1",
636
+ type: "reasoning",
637
+ text: "deep thought ".repeat(40),
638
+ time: { start: 0, end: 2_000 },
639
+ } as unknown as Part;
640
+
641
+ await dispatchEvent(ctx, partUpdated(reasoning));
642
+
643
+ expect(ctx.currentTurn.finalText).toBe("");
644
+ });
645
+
646
+ it("aggregates step-finish tokens and cost across orchestrator and subagents", async () => {
647
+ const ctx = makeRunnerCtx(makeClient());
648
+ ctx.currentTurn = newTurn();
649
+ const stepFinish = (sessionID: string, cost: number) =>
650
+ partUpdated({
651
+ id: "prt_sf",
652
+ sessionID,
653
+ messageID: "msg_1",
654
+ type: "step-finish",
655
+ reason: "stop",
656
+ cost,
657
+ tokens: { input: 10, output: 4, reasoning: 0, cache: { read: 2, write: 3 } },
658
+ } as unknown as Part);
659
+
660
+ await dispatchEvent(ctx, stepFinish(ORCH_SESSION, 0.25));
661
+ await dispatchEvent(ctx, stepFinish("ses_sub", 0.5));
662
+ // non-finite cost must not poison the sum
663
+ await dispatchEvent(
664
+ ctx,
665
+ partUpdated({
666
+ id: "prt_sf2",
667
+ sessionID: ORCH_SESSION,
668
+ messageID: "msg_1",
669
+ type: "step-finish",
670
+ reason: "stop",
671
+ cost: Number.NaN,
672
+ tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
673
+ } as unknown as Part),
674
+ );
675
+
676
+ expect(ctx.currentTurn.tokens).toEqual({ input: 21, output: 9, cacheRead: 4, cacheWrite: 6 });
677
+ expect(ctx.currentTurn.costUsd).toBe(0.75);
678
+ });
679
+
680
+ it("ignores step-finish events between turns", async () => {
681
+ const ctx = makeRunnerCtx(makeClient());
682
+ await expect(
683
+ dispatchEvent(
684
+ ctx,
685
+ partUpdated({
686
+ id: "prt_sf",
687
+ sessionID: ORCH_SESSION,
688
+ messageID: "msg_1",
689
+ type: "step-finish",
690
+ reason: "stop",
691
+ cost: 1,
692
+ tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
693
+ } as unknown as Part),
694
+ ),
695
+ ).resolves.toBeUndefined();
696
+ });
697
+
698
+ it("records orchestrator session errors and ignores foreign sessions", async () => {
699
+ const ctx = makeRunnerCtx(makeClient());
700
+ ctx.currentTurn = newTurn();
701
+
702
+ await dispatchEvent(ctx, sessionErrorEvent("ses_other", { name: "ProviderAuthError" }));
703
+ expect(ctx.currentTurn.sessionError).toBeNull();
704
+
705
+ await dispatchEvent(
706
+ ctx,
707
+ sessionErrorEvent(ORCH_SESSION, { name: "ProviderAuthError", data: { message: "401" } }),
708
+ );
709
+ expect(ctx.currentTurn.sessionError).toBe("401");
710
+
711
+ await dispatchEvent(ctx, sessionErrorEvent(ORCH_SESSION, undefined));
712
+ expect(ctx.currentTurn.sessionError).toBe("(no error payload)");
713
+ });
714
+
715
+ it("binds a task dispatch to a subagent label that future sessions inherit", async () => {
716
+ const ctx = makeRunnerCtx(makeClient());
717
+ ctx.currentTurn = newTurn();
718
+
719
+ await dispatchEvent(
720
+ ctx,
721
+ partUpdated(
722
+ sdkToolPart({
723
+ tool: "task",
724
+ callID: "call_task",
725
+ status: "running",
726
+ input: { description: "Security review", prompt: "lens: security\ngo deep" },
727
+ }),
728
+ ),
729
+ );
730
+
731
+ const dispatch = ctx.taskDispatchByCallID.get("call_task");
732
+ expect(dispatch?.label).toBe("lens:security");
733
+
734
+ // the next unseen sessionID consumes the queued label (FIFO contract)
735
+ await dispatchEvent(ctx, partUpdated(sdkTextPart("sub text", "ses_sub", 5)));
736
+ expect(ctx.labeler.entries()).toContainEqual(["ses_sub", "lens:security"]);
737
+ expect(ctx.currentTurn.finalText).toBe("");
738
+ });
739
+ });
740
+
741
+ describe("processTerminalToolPart", () => {
742
+ it("logs a completed orchestrator call once and forwards it to onToolUse", () => {
743
+ const onToolUse = vi.fn();
744
+ const ctx = makeRunnerCtx(makeClient(), { onToolUse });
745
+ const part = sdkToolPart({
746
+ tool: "read",
747
+ callID: "call_1",
748
+ status: "completed",
749
+ input: { filePath: "src/main.ts" },
750
+ output: "contents",
751
+ });
752
+
753
+ processTerminalToolPart(ctx, part, "orchestrator", true);
754
+ processTerminalToolPart(ctx, part, "orchestrator", true);
755
+
756
+ expect(onToolUse).toHaveBeenCalledTimes(1);
757
+ expect(onToolUse).toHaveBeenCalledWith({
758
+ toolName: "read",
759
+ input: { filePath: "src/main.ts" },
760
+ });
761
+ expect(ctx.loggedToolCallIDs.has("call_1")).toBe(true);
762
+ });
763
+
764
+ it("ignores pending/running states", () => {
765
+ const onToolUse = vi.fn();
766
+ const ctx = makeRunnerCtx(makeClient(), { onToolUse });
767
+
768
+ processTerminalToolPart(
769
+ ctx,
770
+ sdkToolPart({ tool: "read", callID: "call_p", status: "pending" }),
771
+ "orchestrator",
772
+ true,
773
+ );
774
+ processTerminalToolPart(
775
+ ctx,
776
+ sdkToolPart({ tool: "read", callID: "call_r", status: "running" }),
777
+ "orchestrator",
778
+ true,
779
+ );
780
+
781
+ expect(onToolUse).not.toHaveBeenCalled();
782
+ expect(ctx.loggedToolCallIDs.size).toBe(0);
783
+ });
784
+
785
+ it("records orchestrator tool errors on the current turn", () => {
786
+ const ctx = makeRunnerCtx(makeClient());
787
+ ctx.currentTurn = newTurn();
788
+
789
+ processTerminalToolPart(
790
+ ctx,
791
+ sdkToolPart({ tool: "edit", callID: "call_e", status: "error", error: "patch failed" }),
792
+ "orchestrator",
793
+ true,
794
+ );
795
+
796
+ expect(ctx.currentTurn.lastToolError).toBe("patch failed");
797
+ });
798
+
799
+ it("emits the subagent-finished summary and clears the dispatch entry", () => {
800
+ const ctx = makeRunnerCtx(makeClient());
801
+ ctx.taskDispatchByCallID.set("call_task", {
802
+ label: "lens:security",
803
+ startedAt: performance.now() - 1_000,
804
+ });
805
+
806
+ processTerminalToolPart(
807
+ ctx,
808
+ sdkToolPart({
809
+ tool: "task",
810
+ callID: "call_task",
811
+ status: "completed",
812
+ output: "x".repeat(200),
813
+ }),
814
+ "orchestrator",
815
+ true,
816
+ );
817
+
818
+ expect(ctx.taskDispatchByCallID.has("call_task")).toBe(false);
819
+ });
820
+
821
+ it("drives the todo tracker: todowrite updates, report_progress cancels", () => {
822
+ const todoTracker = makeTodoTracker();
823
+ const ctx = makeRunnerCtx(makeClient(), { todoTracker });
824
+ const todos = { todos: [{ content: "a", status: "pending" }] };
825
+
826
+ processTerminalToolPart(
827
+ ctx,
828
+ sdkToolPart({ tool: "todowrite", callID: "call_t", status: "completed", input: todos }),
829
+ "orchestrator",
830
+ true,
831
+ );
832
+ expect(todoTracker.update).toHaveBeenCalledWith(todos);
833
+
834
+ // subagent todowrite must NOT update the tracker
835
+ processTerminalToolPart(
836
+ ctx,
837
+ sdkToolPart({
838
+ tool: "todowrite",
839
+ callID: "call_t2",
840
+ sessionID: "ses_sub",
841
+ status: "completed",
842
+ input: todos,
843
+ }),
844
+ "lens:security",
845
+ false,
846
+ );
847
+ expect(todoTracker.update).toHaveBeenCalledTimes(1);
848
+
849
+ processTerminalToolPart(
850
+ ctx,
851
+ sdkToolPart({
852
+ tool: "terramend_report_progress",
853
+ callID: "call_rp",
854
+ status: "completed",
855
+ }),
856
+ "orchestrator",
857
+ true,
858
+ );
859
+ expect(todoTracker.cancel).toHaveBeenCalled();
860
+ });
861
+ });
862
+
863
+ describe("consumeEvents", () => {
864
+ it("pumps the stream, counts events, and survives a dispatch throw", async () => {
865
+ const client = makeClient();
866
+ const malformed = { type: "message.part.updated" } as unknown as EventSubscribeResponse;
867
+ client.event.subscribe.mockResolvedValue({
868
+ stream: (async function* () {
869
+ yield malformed; // event.properties.part access throws — must be caught
870
+ yield partUpdated(sdkTextPart("ok", ORCH_SESSION, 3));
871
+ })(),
872
+ });
873
+ const ctx = makeRunnerCtx(client);
874
+ ctx.currentTurn = newTurn();
875
+
876
+ await consumeEvents(ctx, new AbortController().signal);
877
+
878
+ expect(ctx.eventCount).toBe(2);
879
+ expect(ctx.diagnostic.eventCount).toBe(2);
880
+ expect(ctx.currentTurn.finalText).toBe("ok");
881
+ });
882
+
883
+ it("stops consuming once the signal aborts", async () => {
884
+ const client = makeClient();
885
+ const abortController = new AbortController();
886
+ client.event.subscribe.mockResolvedValue({
887
+ stream: (async function* () {
888
+ yield partUpdated(sdkTextPart("first", ORCH_SESSION, 3));
889
+ abortController.abort();
890
+ yield partUpdated(sdkTextPart("second", ORCH_SESSION, 4));
891
+ })(),
892
+ });
893
+ const ctx = makeRunnerCtx(client);
894
+
895
+ await consumeEvents(ctx, abortController.signal);
896
+
897
+ expect(ctx.eventCount).toBe(1);
898
+ const subscribeOptions = client.event.subscribe.mock.calls[0]?.[1] as { signal: AbortSignal };
899
+ expect(subscribeOptions.signal).toBe(abortController.signal);
900
+ });
901
+ });
902
+
903
+ describe("runPromptTurn", () => {
904
+ const sdkModel = { providerID: "anthropic", modelID: "claude-opus-4-7" };
905
+
906
+ it("aggregates authoritative usage from the message store and replays unseen tool calls", async () => {
907
+ const client = makeClient();
908
+ const onToolUse = vi.fn();
909
+ client.session.prompt.mockResolvedValue({
910
+ data: { info: assistantInfo(), parts: [sdkTextPart("turn answer", ORCH_SESSION, 9)] },
911
+ });
912
+ client.session.messages.mockImplementation(async (args: { sessionID: string }) => {
913
+ // a subagent session whose read fails must not poison the aggregate.
914
+ if (args.sessionID === "ses_sub") throw new Error("session store unavailable");
915
+ return {
916
+ data: [
917
+ {
918
+ info: {
919
+ role: "assistant",
920
+ time: { created: Date.now() + 60_000 },
921
+ tokens: { input: 100, output: 25, reasoning: 0, cache: { read: 10, write: 5 } },
922
+ cost: 0.7,
923
+ },
924
+ parts: [
925
+ sdkToolPart({
926
+ tool: "grep",
927
+ callID: "call_missed",
928
+ status: "completed",
929
+ input: { pattern: "needle" },
930
+ }),
931
+ sdkTextPart("not a tool part", ORCH_SESSION, 3),
932
+ ],
933
+ },
934
+ {
935
+ info: { role: "user", time: { created: Date.now() + 60_000 } },
936
+ parts: [],
937
+ },
938
+ {
939
+ // landed before this turn started — excluded from the aggregate
940
+ info: {
941
+ role: "assistant",
942
+ time: { created: Date.now() - 60_000 },
943
+ tokens: { input: 999, output: 999, reasoning: 0, cache: { read: 0, write: 0 } },
944
+ cost: 9,
945
+ },
946
+ parts: [],
947
+ },
948
+ ],
949
+ };
950
+ });
951
+ const ctx = makeRunnerCtx(client, { onToolUse });
952
+ ctx.labeler.labelFor("ses_sub");
953
+
954
+ const result = await runPromptTurn(ctx, {
955
+ text: "go",
956
+ model: sdkModel,
957
+ signal: new AbortController().signal,
958
+ });
959
+
960
+ expect(result.success).toBe(true);
961
+ expect(result.output).toBe("turn answer");
962
+ expect(result.usage).toEqual({
963
+ agent: "terramend",
964
+ inputTokens: 115,
965
+ outputTokens: 25,
966
+ cacheReadTokens: 10,
967
+ cacheWriteTokens: 5,
968
+ costUsd: 0.7,
969
+ });
970
+ // SSE-connect race fallback: the tool call seen only in the message store
971
+ // must still reach onToolUse exactly once.
972
+ expect(onToolUse).toHaveBeenCalledWith({ toolName: "grep", input: { pattern: "needle" } });
973
+ expect(client.session.prompt).toHaveBeenCalledWith(
974
+ { sessionID: ORCH_SESSION, parts: [{ type: "text", text: "go" }], model: sdkModel },
975
+ { signal: expect.any(AbortSignal) },
976
+ );
977
+ });
978
+
979
+ it("falls back to the assistant message usage when the store has nothing", async () => {
980
+ const client = makeClient();
981
+ const ctx = makeRunnerCtx(client);
982
+
983
+ const result = await runPromptTurn(ctx, {
984
+ text: "go",
985
+ model: undefined,
986
+ signal: new AbortController().signal,
987
+ });
988
+
989
+ expect(result.success).toBe(true);
990
+ expect(result.usage).toEqual({
991
+ agent: "terramend",
992
+ inputTokens: 57, // 50 + cache read 3 + cache write 4
993
+ outputTokens: 9,
994
+ cacheReadTokens: 3,
995
+ cacheWriteTokens: 4,
996
+ costUsd: 0.1,
997
+ });
998
+ // no model → prompt body must not carry one
999
+ const promptBody = client.session.prompt.mock.calls[0]?.[0] as Record<string, unknown>;
1000
+ expect("model" in promptBody).toBe(false);
1001
+ });
1002
+
1003
+ it("fails on a transport error from session.prompt", async () => {
1004
+ const client = makeClient();
1005
+ client.session.prompt.mockResolvedValue({ error: { message: "bad gateway" } });
1006
+ const ctx = makeRunnerCtx(client);
1007
+
1008
+ const result = await runPromptTurn(ctx, {
1009
+ text: "go",
1010
+ model: sdkModel,
1011
+ signal: new AbortController().signal,
1012
+ });
1013
+
1014
+ expect(result.success).toBe(false);
1015
+ expect(result.error).toBe("opencode prompt failed: bad gateway");
1016
+ });
1017
+
1018
+ it("fails when the response carries neither data nor error", async () => {
1019
+ const client = makeClient();
1020
+ client.session.prompt.mockResolvedValue({});
1021
+ const ctx = makeRunnerCtx(client);
1022
+
1023
+ const result = await runPromptTurn(ctx, {
1024
+ text: "go",
1025
+ model: sdkModel,
1026
+ signal: new AbortController().signal,
1027
+ });
1028
+
1029
+ expect(result.success).toBe(false);
1030
+ expect(result.error).toBe(
1031
+ "opencode prompt failed: opencode prompt returned neither data nor error",
1032
+ );
1033
+ });
1034
+
1035
+ it("classifies a watchdog-aborted prompt as an activity timeout", async () => {
1036
+ const client = makeClient();
1037
+ const abortController = new AbortController();
1038
+ client.session.prompt.mockImplementation(async () => {
1039
+ abortController.abort();
1040
+ throw new Error("This operation was aborted");
1041
+ });
1042
+ const ctx = makeRunnerCtx(client);
1043
+
1044
+ const result = await runPromptTurn(ctx, {
1045
+ text: "go",
1046
+ model: sdkModel,
1047
+ signal: abortController.signal,
1048
+ });
1049
+
1050
+ expect(result.success).toBe(false);
1051
+ expect(result.error).toContain("activity timeout:");
1052
+ expect(result.error).toContain("This operation was aborted");
1053
+ });
1054
+
1055
+ it("classifies an assistant-level provider error", async () => {
1056
+ const client = makeClient();
1057
+ client.session.prompt.mockResolvedValue({
1058
+ data: {
1059
+ info: assistantInfo({
1060
+ error: { name: "ProviderAuthError", data: { message: "invalid api key" } },
1061
+ }),
1062
+ parts: [],
1063
+ },
1064
+ });
1065
+ const ctx = makeRunnerCtx(client);
1066
+
1067
+ const result = await runPromptTurn(ctx, {
1068
+ text: "go",
1069
+ model: sdkModel,
1070
+ signal: new AbortController().signal,
1071
+ });
1072
+
1073
+ expect(result.success).toBe(false);
1074
+ expect(result.error).toBe("provider error: invalid api key");
1075
+ });
1076
+
1077
+ it("surfaces a session.error observed during the turn", async () => {
1078
+ const client = makeClient();
1079
+ const ctx = makeRunnerCtx(client);
1080
+ client.session.prompt.mockImplementation(async () => {
1081
+ const turn = ctx.currentTurn;
1082
+ if (turn) turn.sessionError = "session exploded";
1083
+ return { data: { info: assistantInfo(), parts: [] } };
1084
+ });
1085
+
1086
+ const result = await runPromptTurn(ctx, {
1087
+ text: "go",
1088
+ model: sdkModel,
1089
+ signal: new AbortController().signal,
1090
+ });
1091
+
1092
+ expect(result.success).toBe(false);
1093
+ expect(result.error).toBe("session error: session exploded");
1094
+ });
1095
+ });
1096
+
1097
+ describe("runTurnGuarded", () => {
1098
+ it("returns the result when the turn resolves", async () => {
1099
+ const ctx = makeRunnerCtx(makeClient());
1100
+ const result = await runTurnGuarded(ctx, async () => ({ success: true, output: "ok" }));
1101
+ expect(result).toEqual({ success: true, output: "ok" });
1102
+ });
1103
+
1104
+ it("converts an escaped throw into a failure result with the turn's text", async () => {
1105
+ const ctx = makeRunnerCtx(makeClient());
1106
+ ctx.currentTurn = newTurn();
1107
+ ctx.currentTurn.finalText = "partial text";
1108
+
1109
+ const result = await runTurnGuarded(ctx, async () => {
1110
+ throw new Error("post-prompt bookkeeping exploded");
1111
+ });
1112
+
1113
+ expect(result).toEqual({
1114
+ success: false,
1115
+ output: "partial text",
1116
+ error: "post-prompt bookkeeping exploded",
1117
+ });
1118
+ });
1119
+ });
1120
+
1121
+ describe("buildUsage", () => {
1122
+ it("prefers the step-finish accumulator over the final assistant message", () => {
1123
+ const turn = newTurn();
1124
+ turn.tokens = { input: 100, output: 30, cacheRead: 20, cacheWrite: 10 };
1125
+ turn.costUsd = 1.5;
1126
+
1127
+ expect(buildUsage(turn, assistantInfo())).toEqual({
1128
+ agent: "terramend",
1129
+ inputTokens: 130,
1130
+ outputTokens: 30,
1131
+ cacheReadTokens: 20,
1132
+ cacheWriteTokens: 10,
1133
+ costUsd: 1.5,
1134
+ });
1135
+ });
1136
+
1137
+ it("returns undefined when both the accumulator and the assistant are empty", () => {
1138
+ expect(buildUsage(newTurn(), undefined)).toBeUndefined();
1139
+ expect(
1140
+ buildUsage(
1141
+ newTurn(),
1142
+ assistantInfo({
1143
+ cost: 0,
1144
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
1145
+ }),
1146
+ ),
1147
+ ).toBeUndefined();
1148
+ });
1149
+ });
1150
+
1151
+ describe("startInnerActivityWatchdog", () => {
1152
+ it("aborts and notifies after sustained event silence, firing only once", () => {
1153
+ vi.useFakeTimers();
1154
+ try {
1155
+ const onActivityTimeout = vi.fn();
1156
+ const ctx = makeRunnerCtx(makeClient(), { onActivityTimeout });
1157
+ ctx.lastEventAt = performance.now() - AGENT_ACTIVITY_TIMEOUT_MS - 60_000;
1158
+ const abortController = new AbortController();
1159
+
1160
+ const watchdog = startInnerActivityWatchdog({
1161
+ ctx,
1162
+ timeoutMs: AGENT_ACTIVITY_TIMEOUT_MS,
1163
+ abortController,
1164
+ });
1165
+
1166
+ vi.advanceTimersByTime(5_000);
1167
+ expect(abortController.signal.aborted).toBe(true);
1168
+ expect(onActivityTimeout).toHaveBeenCalledTimes(1);
1169
+
1170
+ vi.advanceTimersByTime(15_000);
1171
+ expect(onActivityTimeout).toHaveBeenCalledTimes(1);
1172
+ watchdog.stop();
1173
+ } finally {
1174
+ vi.useRealTimers();
1175
+ }
1176
+ });
1177
+
1178
+ it("stays quiet while events are flowing and tolerates a throwing callback", () => {
1179
+ vi.useFakeTimers();
1180
+ try {
1181
+ const ctx = makeRunnerCtx(makeClient(), {
1182
+ onActivityTimeout: vi.fn(() => {
1183
+ throw new Error("callback exploded");
1184
+ }),
1185
+ });
1186
+ const abortController = new AbortController();
1187
+ const watchdog = startInnerActivityWatchdog({
1188
+ ctx,
1189
+ timeoutMs: AGENT_ACTIVITY_TIMEOUT_MS,
1190
+ abortController,
1191
+ });
1192
+
1193
+ ctx.lastEventAt = performance.now();
1194
+ vi.advanceTimersByTime(10_000);
1195
+ expect(abortController.signal.aborted).toBe(false);
1196
+
1197
+ // now go silent past the budget — the throwing callback must be caught
1198
+ ctx.lastEventAt = performance.now() - AGENT_ACTIVITY_TIMEOUT_MS - 60_000;
1199
+ expect(() => vi.advanceTimersByTime(5_000)).not.toThrow();
1200
+ expect(abortController.signal.aborted).toBe(true);
1201
+ watchdog.stop();
1202
+ } finally {
1203
+ vi.useRealTimers();
1204
+ }
1205
+ });
1206
+ });
1207
+
1208
+ describe("opencode.run", () => {
1209
+ function makeRunCtx(overrides?: Partial<AgentRunContext>): {
1210
+ ctx: AgentRunContext;
1211
+ toolState: ToolState;
1212
+ todoTracker: ReturnType<typeof makeTodoTracker>;
1213
+ onToolUse: ReturnType<typeof vi.fn>;
1214
+ } {
1215
+ const dir = mkdtempSync(join(tmpdir(), "terramend-opencode-run-"));
1216
+ tempDirs.push(dir);
1217
+ const toolState = {} as unknown as ToolState;
1218
+ const todoTracker = makeTodoTracker();
1219
+ const onToolUse = vi.fn();
1220
+ const ctx = {
1221
+ payload: {},
1222
+ resolvedModel: "anthropic/claude-opus-4-7",
1223
+ mcpServerUrl: MCP_URL,
1224
+ mcpServerToken: MCP_TOKEN,
1225
+ tmpdir: dir,
1226
+ instructions: {
1227
+ full: "do the task",
1228
+ system: "",
1229
+ user: "",
1230
+ eventInstructions: "",
1231
+ event: "",
1232
+ runtime: "",
1233
+ },
1234
+ toolState,
1235
+ todoTracker,
1236
+ onToolUse,
1237
+ apiToken: "",
1238
+ ...overrides,
1239
+ } as unknown as AgentRunContext;
1240
+ return { ctx, toolState, todoTracker, onToolUse };
1241
+ }
1242
+
1243
+ let proc: FakeChildProcess;
1244
+ let client: FakeClient;
1245
+
1246
+ beforeEach(() => {
1247
+ vi.clearAllMocks();
1248
+ vi.stubEnv("BEDROCK_MODEL_ID", undefined);
1249
+ stubProcessKill();
1250
+ proc = new FakeChildProcess();
1251
+ nodeSpawnMock.mockImplementation(() => {
1252
+ queueMicrotask(() => {
1253
+ proc.stdout.emit(
1254
+ "data",
1255
+ Buffer.from("opencode server listening on http://127.0.0.1:43117\n"),
1256
+ );
1257
+ });
1258
+ return proc as unknown as ReturnType<typeof nodeSpawn>;
1259
+ });
1260
+ client = makeClient();
1261
+ createOpencodeClientMock.mockReturnValue(client as unknown as OpencodeClient);
1262
+ // exercise one warm-session resume turn (gate retry / reflection re-entry)
1263
+ // before settling on the resumed result.
1264
+ runPostRunRetryLoopMock.mockImplementation(async (params) => {
1265
+ if (!params.initialResult.success) return params.initialResult;
1266
+ return await params.resume({
1267
+ prompt: "gate retry prompt",
1268
+ previousResult: params.initialResult,
1269
+ });
1270
+ });
1271
+ });
1272
+
1273
+ it("boots the server, runs the prompt turn in-process, and tears everything down", async () => {
1274
+ const { ctx, toolState, todoTracker, onToolUse } = makeRunCtx();
1275
+ toolState.learningsFilePath = "/tmp/run/learnings.md";
1276
+ client.session.prompt.mockImplementation(async () => {
1277
+ // server stderr → provider-error attribution (handler attached by run())
1278
+ proc.stderr.emit("data", Buffer.from('upstream said "status": 429 try later\n'));
1279
+ return {
1280
+ data: { info: assistantInfo(), parts: [sdkTextPart("all done", ORCH_SESSION, 9)] },
1281
+ };
1282
+ });
1283
+ client.session.messages.mockResolvedValue({
1284
+ data: [
1285
+ {
1286
+ info: {
1287
+ role: "assistant",
1288
+ time: { created: Date.now() + 60_000 },
1289
+ tokens: { input: 100, output: 25, reasoning: 0, cache: { read: 10, write: 5 } },
1290
+ cost: 0.7,
1291
+ },
1292
+ parts: [
1293
+ sdkToolPart({
1294
+ tool: "read",
1295
+ callID: "call_seen_late",
1296
+ status: "completed",
1297
+ input: { filePath: "src/x.ts" },
1298
+ }),
1299
+ ],
1300
+ },
1301
+ ],
1302
+ });
1303
+
1304
+ const result = await opencode.run(ctx);
1305
+
1306
+ expect(result.success).toBe(true);
1307
+ expect(result.output).toBe("all done");
1308
+ expect(result.usage).toEqual({
1309
+ agent: "terramend",
1310
+ inputTokens: 115,
1311
+ outputTokens: 25,
1312
+ cacheReadTokens: 10,
1313
+ cacheWriteTokens: 5,
1314
+ costUsd: 0.7,
1315
+ });
1316
+
1317
+ // model propagation: footer badge + SDK prompt model split
1318
+ expect(toolState.model).toBe("anthropic/claude-opus-4-7");
1319
+ const promptBody = client.session.prompt.mock.calls[0]?.[0] as {
1320
+ sessionID: string;
1321
+ parts: Array<{ type: string; text: string }>;
1322
+ model?: { providerID: string; modelID: string };
1323
+ };
1324
+ expect(promptBody.sessionID).toBe(ORCH_SESSION);
1325
+ expect(promptBody.parts).toEqual([{ type: "text", text: "do the task" }]);
1326
+ expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" });
1327
+
1328
+ // spawn env carries the security config + sandbox + redirected HOME
1329
+ const spawnOptions = nodeSpawnMock.mock.calls[0]?.[2] as {
1330
+ env: NodeJS.ProcessEnv;
1331
+ cwd: string;
1332
+ };
1333
+ expect(spawnOptions.cwd).toBe(process.cwd());
1334
+ expect(spawnOptions.env.HOME).toBe(ctx.tmpdir);
1335
+ expect(spawnOptions.env.PWD).toBe(process.cwd());
1336
+ // the MCP bearer token reaches the opencode server via env so it can expand
1337
+ // {env:TERRAMEND_MCP_TOKEN} in the remote-MCP Authorization header.
1338
+ expect(spawnOptions.env.TERRAMEND_MCP_TOKEN).toBe(MCP_TOKEN);
1339
+ const securityConfig = JSON.parse(
1340
+ spawnOptions.env.OPENCODE_CONFIG_CONTENT ?? "{}",
1341
+ ) as ParsedSecurityConfig;
1342
+ expect(securityConfig.model).toBe("anthropic/claude-opus-4-7");
1343
+ expect(securityConfig.permission.bash).toBe("deny");
1344
+ // the config header is only the placeholder — the raw token is never in it.
1345
+ expect(JSON.stringify(securityConfig.mcp ?? {})).not.toContain(MCP_TOKEN);
1346
+ const permission = JSON.parse(spawnOptions.env.OPENCODE_PERMISSION ?? "{}") as {
1347
+ external_directory: Record<string, string>;
1348
+ };
1349
+ expect(permission.external_directory["*"]).toBe("deny");
1350
+ expect(permission.external_directory["/tmp/*"]).toBe("allow");
1351
+
1352
+ // gate plugin dropped into the tmpdir-redirected XDG plugin dir
1353
+ expect(
1354
+ existsSync(
1355
+ join(ctx.tmpdir, ".config", "opencode", "plugin", TERRAMEND_OPENCODE_GATE_PLUGIN_FILENAME),
1356
+ ),
1357
+ ).toBe(true);
1358
+
1359
+ // client wired against the booted server with the custom undici fetch
1360
+ const clientConfig = createOpencodeClientMock.mock.calls[0]?.[0] as {
1361
+ baseUrl: string;
1362
+ directory: string;
1363
+ fetch: typeof fetch;
1364
+ };
1365
+ expect(clientConfig.baseUrl).toBe("http://127.0.0.1:43117");
1366
+ expect(clientConfig.directory).toBe(process.cwd());
1367
+ await clientConfig.fetch(new Request("http://127.0.0.1:43117/ping"));
1368
+ expect(undiciFetchMock).toHaveBeenCalledWith(
1369
+ "http://127.0.0.1:43117/ping",
1370
+ expect.objectContaining({ method: "GET", duplex: "half" }),
1371
+ );
1372
+
1373
+ // stderr provider-error attribution reached the shared diagnostic
1374
+ expect(toolState.agentDiagnostic?.lastProviderError).toBe("rate limited (429)");
1375
+
1376
+ // end-of-turn fallback replayed the store-only tool call
1377
+ expect(onToolUse).toHaveBeenCalledWith({ toolName: "read", input: { filePath: "src/x.ts" } });
1378
+
1379
+ // success path flushes todos; teardown kills the server process
1380
+ expect(todoTracker.flush).toHaveBeenCalledTimes(1);
1381
+ expect(todoTracker.cancel).not.toHaveBeenCalled();
1382
+ expect(proc.kill).toHaveBeenCalled();
1383
+ expect(runPostRunRetryLoopMock).toHaveBeenCalledTimes(1);
1384
+ // learningsFilePath + non-skipped mode → reflection prompt wired through
1385
+ expect(runPostRunRetryLoopMock).toHaveBeenCalledWith(
1386
+ expect.objectContaining({
1387
+ reflectionPrompt: expect.stringContaining("/tmp/run/learnings.md"),
1388
+ }),
1389
+ );
1390
+ });
1391
+
1392
+ it("redirects XDG_DATA_HOME and strips OPENAI_API_KEY when codex auth is installed", async () => {
1393
+ vi.stubEnv("OPENAI_API_KEY", "sk-should-be-stripped");
1394
+ installCodexAuthMock.mockReturnValue({
1395
+ authPath: "/var/lib/terramend/opencode/auth.json",
1396
+ xdgDataHome: "/var/lib/terramend",
1397
+ originalRefresh: "refresh-token-1",
1398
+ });
1399
+ const { ctx } = makeRunCtx();
1400
+
1401
+ const result = await opencode.run(ctx);
1402
+
1403
+ expect(result.success).toBe(true);
1404
+ const spawnOptions = nodeSpawnMock.mock.calls[0]?.[2] as { env: NodeJS.ProcessEnv };
1405
+ expect(spawnOptions.env.XDG_DATA_HOME).toBe("/var/lib/terramend");
1406
+ expect("OPENAI_API_KEY" in spawnOptions.env).toBe(false);
1407
+ });
1408
+
1409
+ it("fails fast when session.create errors, surviving a teardown kill failure", async () => {
1410
+ const { ctx } = makeRunCtx();
1411
+ client.session.create.mockResolvedValue({ error: { message: "out of sessions" } });
1412
+ // both the group kill and the direct kill fail — close() rejects and the
1413
+ // run() finally must swallow it (debug log) without masking the result.
1414
+ proc.kill.mockImplementation(() => {
1415
+ throw new Error("kill EPERM");
1416
+ });
1417
+
1418
+ const result = await opencode.run(ctx);
1419
+
1420
+ expect(result.success).toBe(false);
1421
+ expect(result.error).toBe("opencode session.create failed: out of sessions");
1422
+ expect(client.session.prompt).not.toHaveBeenCalled();
1423
+ expect(proc.kill).toHaveBeenCalled();
1424
+ });
1425
+
1426
+ it("cancels the todo tracker when the final verdict is a failure", async () => {
1427
+ const { ctx, todoTracker } = makeRunCtx();
1428
+ client.session.prompt.mockResolvedValue({ error: { message: "transport down" } });
1429
+ // a broken SSE subscription must be survivable (warning, not a crash)
1430
+ client.event.subscribe.mockRejectedValue(new Error("sse broke"));
1431
+
1432
+ const result = await opencode.run(ctx);
1433
+
1434
+ expect(result.success).toBe(false);
1435
+ expect(result.error).toBe("opencode prompt failed: transport down");
1436
+ expect(todoTracker.cancel).toHaveBeenCalled();
1437
+ expect(todoTracker.flush).not.toHaveBeenCalled();
1438
+ expect(proc.kill).toHaveBeenCalled();
1439
+ });
1440
+ });