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,873 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { agents } from "#app/agents/index";
5
+ import type { Agent, AgentResult, AgentRunContext } from "#app/agents/shared";
6
+ import { main } from "#app/main";
7
+ import { reportProgress } from "#app/mcp/comment";
8
+ import { startInstallation } from "#app/mcp/dependencies";
9
+ import { startMcpHttpServer, type ToolContext } from "#app/mcp/server";
10
+ import type { ToolState } from "#app/toolState";
11
+ import { resolveAgent, resolveModel } from "#app/utils/agent";
12
+ import { validateAgentApiKey } from "#app/utils/apiKeys";
13
+ import { isBackendConfigured } from "#app/utils/apiUrl";
14
+ import { resolveBody } from "#app/utils/body";
15
+ import {
16
+ buildUnavailableModelError,
17
+ hasProviderKeyForModel,
18
+ selectFallbackModelIfNeeded,
19
+ } from "#app/utils/byokFallback";
20
+ import { log } from "#app/utils/cli";
21
+ import { installCodexAuth, TERRAMEND_DATA_DIR } from "#app/utils/codexHome";
22
+ import { recordDiffReadFromToolUse } from "#app/utils/diffCoverage";
23
+ import { onExitSignal } from "#app/utils/exitHandler";
24
+ import { resolveGit } from "#app/utils/gitAuth";
25
+ import { writeGitHubUsageSummaryToFile } from "#app/utils/github";
26
+ import { persistLearnings, seedLearningsFile } from "#app/utils/learnings";
27
+ import { executeLifecycleHook } from "#app/utils/lifecycle";
28
+ import { normalizeEnv } from "#app/utils/normalizeEnv";
29
+ import { captureAuthorizedModels, captureBaselineModels } from "#app/utils/openCodeModels";
30
+ import { applyOverrides } from "#app/utils/overrides";
31
+ import { ensurePackageManager, resolvePackageManagerSpec } from "#app/utils/packageManager";
32
+ import { aggregateUsage, patchWorkflowRunFields } from "#app/utils/patchWorkflowRunFields";
33
+ import { type ResolvedPayload, resolveOutputSchema, resolvePayload } from "#app/utils/payload";
34
+ import { fetchPreviousSnapshot, persistSummary, seedSummaryFile } from "#app/utils/prSummary";
35
+ import { handleAgentResult } from "#app/utils/run";
36
+ import { type RunContextData, resolveRunContextData } from "#app/utils/runContextData";
37
+ import { renderRunError } from "#app/utils/runErrorRenderer";
38
+ import {
39
+ finalizeSuccessRun,
40
+ persistRunArtifacts,
41
+ writeRunErrorOutputs,
42
+ } from "#app/utils/runLifecycle";
43
+ import { setEnvAllowlist } from "#app/utils/secrets";
44
+ import { setupGit, wipeRunnerLeakSurface } from "#app/utils/setup";
45
+ import { killTrackedChildren } from "#app/utils/subprocess";
46
+ import { createTodoTracker, type TodoTracker } from "#app/utils/todoTracking";
47
+ import {
48
+ cleanupVertexCredentials,
49
+ materializeVertexCredentials,
50
+ type VertexCredentials,
51
+ } from "#app/utils/vertex";
52
+
53
+ vi.mock("node:fs", async (importOriginal) => {
54
+ const actual = await importOriginal<typeof import("node:fs")>();
55
+ return { ...actual, existsSync: vi.fn(() => false), readdirSync: vi.fn(() => []) };
56
+ });
57
+ vi.mock("node:fs/promises", async (importOriginal) => {
58
+ const actual = await importOriginal<typeof import("node:fs/promises")>();
59
+ return { ...actual, readFile: vi.fn(async () => "seed-bytes") };
60
+ });
61
+ vi.mock("#app/agents/index", () => ({
62
+ agents: {
63
+ claude: { name: "claude", install: vi.fn(async () => "/tmp/claude-cli"), run: vi.fn() },
64
+ opencode: {
65
+ name: "opencode",
66
+ install: vi.fn(async () => "/tmp/opencode-cli"),
67
+ run: vi.fn(),
68
+ },
69
+ },
70
+ }));
71
+ vi.mock("#app/mcp/comment", () => ({ reportProgress: vi.fn(async () => {}) }));
72
+ vi.mock("#app/mcp/dependencies", () => ({ startInstallation: vi.fn() }));
73
+ vi.mock("#app/mcp/server", () => ({
74
+ startMcpHttpServer: vi.fn(async () => ({
75
+ url: "http://127.0.0.1:7777/mcp",
76
+ token: "mcp-token",
77
+ [Symbol.asyncDispose]: vi.fn(async () => {}),
78
+ })),
79
+ }));
80
+ vi.mock("#app/modes", () => ({ computeModes: vi.fn(() => []) }));
81
+ vi.mock("#app/utils/activity", () => ({
82
+ AGENT_ACTIVITY_TIMEOUT_MS: 900_000,
83
+ DEFAULT_ACTIVITY_CHECK_INTERVAL_MS: 5_000,
84
+ createProcessOutputActivityTimeout: vi.fn(() => ({
85
+ promise: new Promise<never>(() => {}),
86
+ stop: vi.fn(),
87
+ forceReject: vi.fn(),
88
+ })),
89
+ }));
90
+ vi.mock("#app/utils/agent", () => ({ resolveAgent: vi.fn(), resolveModel: vi.fn() }));
91
+ vi.mock("#app/utils/apiKeys", () => ({ validateAgentApiKey: vi.fn() }));
92
+ vi.mock("#app/utils/apiUrl", () => ({ isBackendConfigured: vi.fn(() => false) }));
93
+ vi.mock("#app/utils/body", () => ({ resolveBody: vi.fn(async () => null) }));
94
+ vi.mock("#app/utils/byokFallback", () => ({
95
+ buildUnavailableModelError: vi.fn(() => "unavailable-error"),
96
+ hasProviderKeyForModel: vi.fn(() => false),
97
+ selectFallbackModelIfNeeded: vi.fn(() => ({ kind: "use-resolved" })),
98
+ }));
99
+ vi.mock("#app/utils/cli", () => ({
100
+ log: {
101
+ info: vi.fn(),
102
+ warning: vi.fn(),
103
+ error: vi.fn(),
104
+ debug: vi.fn(),
105
+ box: vi.fn(),
106
+ group: vi.fn((_title: string, fn?: () => void) => fn?.()),
107
+ },
108
+ }));
109
+ vi.mock("#app/utils/codexHome", () => ({
110
+ installCodexAuth: vi.fn(),
111
+ TERRAMEND_DATA_DIR: "/var/lib/terramend",
112
+ }));
113
+ vi.mock("#app/utils/diffCoverage", () => ({ recordDiffReadFromToolUse: vi.fn(() => false) }));
114
+ vi.mock("#app/utils/exitHandler", () => ({ onExitSignal: vi.fn(() => () => {}) }));
115
+ vi.mock("#app/utils/gitAuth", () => ({ resolveGit: vi.fn(), setGitAuthServer: vi.fn() }));
116
+ vi.mock("#app/utils/gitAuthServer", () => ({
117
+ startGitAuthServer: vi.fn(async () => ({
118
+ port: 1,
119
+ register: vi.fn(() => "code"),
120
+ revoke: vi.fn(),
121
+ writeAskpassScript: vi.fn(() => "/tmp/askpass"),
122
+ close: vi.fn(async () => {}),
123
+ [Symbol.asyncDispose]: vi.fn(async () => {}),
124
+ })),
125
+ }));
126
+ vi.mock("#app/utils/github", () => ({
127
+ createOctokit: vi.fn(() => ({})),
128
+ writeGitHubUsageSummaryToFile: vi.fn(async () => {}),
129
+ }));
130
+ vi.mock("#app/utils/instructions", () => ({
131
+ resolveInstructions: vi.fn(() => ({
132
+ full: "FULL PROMPT",
133
+ system: "",
134
+ user: "USER REQUEST BODY",
135
+ eventInstructions: "",
136
+ event: "EVENT INSTRUCTIONS",
137
+ runtime: "",
138
+ })),
139
+ }));
140
+ vi.mock("#app/utils/learnings", () => ({
141
+ persistLearnings: vi.fn(async () => {}),
142
+ seedLearningsFile: vi.fn(async () => "/tmp/learnings.md"),
143
+ }));
144
+ vi.mock("#app/utils/lifecycle", () => ({
145
+ describeSetupFailure: vi.fn(() => ""),
146
+ executeLifecycleHook: vi.fn(async () => ({})),
147
+ }));
148
+ vi.mock("#app/utils/normalizeEnv", () => ({ normalizeEnv: vi.fn() }));
149
+ vi.mock("#app/utils/openCodeModels", () => ({
150
+ captureAuthorizedModels: vi.fn(),
151
+ captureBaselineModels: vi.fn(),
152
+ getAuthorizedModels: vi.fn(() => new Set<string>()),
153
+ }));
154
+ vi.mock("#app/utils/overrides", () => ({
155
+ applyOverrides: vi.fn(() => ({ applied: [], denied: [] })),
156
+ }));
157
+ vi.mock("#app/utils/packageManager", () => ({
158
+ ensurePackageManager: vi.fn(async () => true),
159
+ packageManagerBinDir: vi.fn((tmpdir: string) => `${tmpdir}/pm-bin`),
160
+ resolvePackageManagerSpec: vi.fn(async () => null),
161
+ }));
162
+ vi.mock("#app/utils/patchWorkflowRunFields", () => ({
163
+ aggregateUsage: vi.fn(() => ({})),
164
+ patchWorkflowRunFields: vi.fn(async () => {}),
165
+ }));
166
+ vi.mock("#app/utils/payload", () => ({
167
+ Inputs: {},
168
+ resolveOutputSchema: vi.fn(() => undefined),
169
+ resolvePayload: vi.fn(),
170
+ resolvePromptInput: vi.fn(() => "plain text prompt"),
171
+ }));
172
+ vi.mock("#app/utils/prSummary", () => ({
173
+ fetchPreviousSnapshot: vi.fn(async () => null),
174
+ persistSummary: vi.fn(async () => {}),
175
+ seedSummaryFile: vi.fn(async () => "/tmp/summary.md"),
176
+ }));
177
+ vi.mock("#app/utils/run", () => ({
178
+ handleAgentResult: vi.fn(async () => ({ success: true })),
179
+ }));
180
+ vi.mock("#app/utils/runContextData", () => ({ resolveRunContextData: vi.fn() }));
181
+ vi.mock("#app/utils/runErrorRenderer", () => ({
182
+ renderRunError: vi.fn(() => ({ summary: "rendered-summary", comment: "rendered-comment" })),
183
+ }));
184
+ vi.mock("#app/utils/runLifecycle", () => ({
185
+ finalizeSuccessRun: vi.fn(async () => {}),
186
+ persistRunArtifacts: vi.fn(async () => {}),
187
+ writeRunErrorOutputs: vi.fn(async () => {}),
188
+ }));
189
+ vi.mock("#app/utils/runStartupLog", () => ({ logRunStartup: vi.fn() }));
190
+ vi.mock("#app/utils/secrets", () => ({ setEnvAllowlist: vi.fn() }));
191
+ vi.mock("#app/utils/setup", () => ({
192
+ createTempDirectory: vi.fn(() => "/tmp/terramend-test"),
193
+ setupGit: vi.fn(async () => {}),
194
+ wipeRunnerLeakSurface: vi.fn(),
195
+ }));
196
+ vi.mock("#app/utils/subprocess", () => ({ killTrackedChildren: vi.fn() }));
197
+ vi.mock("#app/utils/todoTracking", () => ({
198
+ createTodoTracker: vi.fn(() => ({
199
+ update: vi.fn(),
200
+ flush: vi.fn(async () => {}),
201
+ cancel: vi.fn(),
202
+ settled: vi.fn(async () => {}),
203
+ completeInProgress: vi.fn(),
204
+ renderCollapsible: vi.fn(() => ""),
205
+ enabled: true,
206
+ hasPublished: false,
207
+ })),
208
+ }));
209
+ vi.mock("#app/utils/token", () => ({
210
+ getJobToken: vi.fn(() => "job-token"),
211
+ resolveTokens: vi.fn(async () => ({
212
+ gitToken: "git-token",
213
+ mcpToken: "mcp-token",
214
+ [Symbol.asyncDispose]: vi.fn(async () => {}),
215
+ })),
216
+ }));
217
+ vi.mock("#app/utils/vertex", () => ({
218
+ cleanupVertexCredentials: vi.fn(),
219
+ materializeVertexCredentials: vi.fn(() => undefined),
220
+ }));
221
+ vi.mock("#app/utils/workflow", () => ({ resolveRun: vi.fn(async () => ({ runId: 42 })) }));
222
+
223
+ function makePayload(overrides: Record<string, unknown> = {}): ResolvedPayload {
224
+ return {
225
+ "~terramend": true,
226
+ version: "0.0.0",
227
+ model: undefined,
228
+ mode: undefined,
229
+ prompt: "do the thing",
230
+ triggerer: "octocat",
231
+ event: { trigger: "workflow_dispatch" },
232
+ timeout: undefined,
233
+ cwd: undefined,
234
+ progressComment: undefined,
235
+ generateSummary: undefined,
236
+ push: "restricted",
237
+ shell: "restricted",
238
+ ...overrides,
239
+ } as unknown as ResolvedPayload;
240
+ }
241
+
242
+ function makeRunContext(
243
+ settings: Record<string, unknown> = {},
244
+ overrides: Record<string, unknown> = {},
245
+ ): RunContextData {
246
+ return {
247
+ repo: { owner: "octo", name: "repo", data: {} },
248
+ repoSettings: {
249
+ model: null,
250
+ modes: [],
251
+ setupScript: null,
252
+ postCheckoutScript: null,
253
+ prepushScript: null,
254
+ stopScript: null,
255
+ push: "restricted",
256
+ shell: "restricted",
257
+ prApproveEnabled: false,
258
+ modeInstructions: {},
259
+ learnings: null,
260
+ learningsHeadings: [],
261
+ envAllowlist: null,
262
+ ...settings,
263
+ },
264
+ apiToken: "api-token",
265
+ oss: false,
266
+ plan: "none",
267
+ ...overrides,
268
+ } as unknown as RunContextData;
269
+ }
270
+
271
+ function makeAgent(
272
+ name: "claude" | "opencode",
273
+ run?: (params: AgentRunContext) => Promise<AgentResult>,
274
+ ): Agent {
275
+ return {
276
+ name,
277
+ install: vi.fn(async () => "/tmp/agent-cli"),
278
+ run: vi.fn(run ?? (async () => ({ success: true }))),
279
+ } as unknown as Agent;
280
+ }
281
+
282
+ function getToolContext(): ToolContext {
283
+ const call = vi.mocked(startMcpHttpServer).mock.calls[0];
284
+ if (!call) throw new Error("startMcpHttpServer was not called");
285
+ return call[0];
286
+ }
287
+
288
+ function getToolState(): ToolState {
289
+ return getToolContext().toolState;
290
+ }
291
+
292
+ function lastRunParams(agent: Agent): AgentRunContext {
293
+ const call = vi.mocked(agent.run).mock.calls[0];
294
+ if (!call) throw new Error("agent.run was not called");
295
+ return call[0];
296
+ }
297
+
298
+ async function fireExitHandlers(): Promise<void> {
299
+ for (const call of vi.mocked(onExitSignal).mock.calls) {
300
+ await call[0]("SIGTERM");
301
+ }
302
+ }
303
+
304
+ let agent: Agent;
305
+
306
+ beforeEach(() => {
307
+ vi.spyOn(process, "chdir").mockImplementation(() => {});
308
+ agent = makeAgent("opencode");
309
+ vi.mocked(resolveAgent).mockReturnValue(agent);
310
+ vi.mocked(resolveModel).mockImplementation(({ slug }) => slug);
311
+ vi.mocked(resolvePayload).mockReturnValue(makePayload());
312
+ vi.mocked(resolveRunContextData).mockResolvedValue(makeRunContext());
313
+ });
314
+
315
+ afterEach(() => {
316
+ vi.unstubAllEnvs();
317
+ vi.restoreAllMocks();
318
+ vi.clearAllMocks();
319
+ });
320
+
321
+ describe("main – happy path", () => {
322
+ it("orchestrates a successful run end to end", async () => {
323
+ const result = await main();
324
+
325
+ expect(result).toEqual({ success: true });
326
+ expect(normalizeEnv).toHaveBeenCalledOnce();
327
+ expect(resolveGit).toHaveBeenCalledOnce();
328
+ expect(agents.opencode.install).toHaveBeenCalledOnce();
329
+ expect(captureBaselineModels).toHaveBeenCalledWith("/tmp/opencode-cli");
330
+ expect(installCodexAuth).toHaveBeenCalledOnce();
331
+ expect(captureAuthorizedModels).toHaveBeenCalledWith("/tmp/opencode-cli");
332
+ expect(wipeRunnerLeakSurface).toHaveBeenCalledOnce();
333
+ expect(setupGit).toHaveBeenCalledOnce();
334
+ expect(executeLifecycleHook).toHaveBeenCalledWith({
335
+ event: "setup",
336
+ script: null,
337
+ normalizeWorkingTreeAfter: true,
338
+ });
339
+ expect(startInstallation).toHaveBeenCalledWith(getToolContext());
340
+ expect(finalizeSuccessRun).toHaveBeenCalledOnce();
341
+ expect(handleAgentResult).toHaveBeenCalledOnce();
342
+ expect(killTrackedChildren).toHaveBeenCalled();
343
+ expect(cleanupVertexCredentials).toHaveBeenCalledWith(undefined);
344
+ // model undefined → provider-key probe is skipped, gate sees false
345
+ expect(hasProviderKeyForModel).not.toHaveBeenCalled();
346
+ // empty usage patch → no workflow-run PATCH
347
+ expect(patchWorkflowRunFields).not.toHaveBeenCalled();
348
+ // mcp url is written back onto the shared tool context
349
+ expect(getToolContext().mcpServerUrl).toBe("http://127.0.0.1:7777/mcp");
350
+ expect(log.info).toHaveBeenCalledWith(expect.stringContaining("MCP server started at"));
351
+ });
352
+
353
+ it("applies env overrides when UNSAFE_OVERRIDES is set", async () => {
354
+ vi.stubEnv("UNSAFE_OVERRIDES", '{"FOO":"1"}');
355
+ vi.mocked(applyOverrides).mockReturnValueOnce({ applied: ["FOO"], denied: ["GITHUB_TOKEN"] });
356
+
357
+ await main();
358
+
359
+ expect(applyOverrides).toHaveBeenCalledWith({ raw: '{"FOO":"1"}', env: process.env });
360
+ expect(log.info).toHaveBeenCalledWith(
361
+ expect.stringContaining("applied 1 env override(s): FOO"),
362
+ );
363
+ expect(log.warning).toHaveBeenCalledWith(
364
+ expect.stringContaining("refused to override 1 protected env var(s): GITHUB_TOKEN"),
365
+ );
366
+ });
367
+
368
+ it("registers a usage-summary exit handler and writes the summary in finally", async () => {
369
+ vi.stubEnv("TERRAMEND_USAGE_SUMMARY_PATH", "/tmp/usage.json");
370
+
371
+ const result = await main();
372
+
373
+ expect(result.success).toBe(true);
374
+ expect(onExitSignal).toHaveBeenCalled();
375
+ expect(writeGitHubUsageSummaryToFile).toHaveBeenCalledWith("/tmp/usage.json");
376
+ await fireExitHandlers();
377
+ expect(vi.mocked(writeGitHubUsageSummaryToFile).mock.calls.length).toBeGreaterThanOrEqual(2);
378
+ });
379
+
380
+ it("logs instead of failing when the finally usage-summary write rejects", async () => {
381
+ vi.stubEnv("TERRAMEND_USAGE_SUMMARY_PATH", "/tmp/usage.json");
382
+ vi.mocked(writeGitHubUsageSummaryToFile).mockRejectedValueOnce(new Error("ENOSPC"));
383
+
384
+ const result = await main();
385
+
386
+ expect(result.success).toBe(true);
387
+ expect(log.debug).toHaveBeenCalledWith(
388
+ expect.stringContaining("failed to write usage summary to /tmp/usage.json: ENOSPC"),
389
+ );
390
+ });
391
+
392
+ it("configures the env allowlist from repo settings", async () => {
393
+ vi.mocked(resolveRunContextData).mockResolvedValueOnce(
394
+ makeRunContext({ envAllowlist: "FOO,BAR" }),
395
+ );
396
+
397
+ await main();
398
+
399
+ expect(setEnvAllowlist).toHaveBeenCalledWith("FOO,BAR");
400
+ });
401
+
402
+ it("records before_sha on pull_request_synchronize triggers", async () => {
403
+ vi.mocked(resolvePayload).mockReturnValue(
404
+ makePayload({
405
+ event: { trigger: "pull_request_synchronize", is_pr: true, before_sha: "abc123" },
406
+ }),
407
+ );
408
+
409
+ await main();
410
+
411
+ expect(getToolState().beforeSha).toBe("abc123");
412
+ });
413
+
414
+ it("chdirs into payload.cwd when it differs from the current directory", async () => {
415
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ cwd: "/somewhere/else" }));
416
+
417
+ await main();
418
+
419
+ expect(process.chdir).toHaveBeenCalledWith("/somewhere/else");
420
+ });
421
+
422
+ it("substitutes the resolved body into the event and prompt", async () => {
423
+ const payload = makePayload({
424
+ prompt: "Request: original body",
425
+ event: { trigger: "issues_opened", issue_number: 3, body: "original body" },
426
+ });
427
+ vi.mocked(resolvePayload).mockReturnValue(payload);
428
+ vi.mocked(resolveBody).mockResolvedValueOnce("resolved body");
429
+
430
+ await main();
431
+
432
+ expect(payload.event.body).toBe("resolved body");
433
+ expect(payload.prompt).toBe("Request: resolved body");
434
+ });
435
+
436
+ it("clears OIDC env vars when shell is not enabled", async () => {
437
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc");
438
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "tok");
439
+
440
+ await main();
441
+
442
+ expect(process.env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined();
443
+ expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined();
444
+ });
445
+
446
+ it("keeps OIDC env vars when shell is enabled", async () => {
447
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://oidc");
448
+ vi.stubEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "tok");
449
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ shell: "enabled" }));
450
+
451
+ await main();
452
+
453
+ expect(process.env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe("https://oidc");
454
+ expect(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe("tok");
455
+ });
456
+ });
457
+
458
+ describe("main – BYOK model gate", () => {
459
+ it("passes resolveAgent({model: initialResolvedModel}).name into the gate", async () => {
460
+ const claudeAgent = makeAgent("claude");
461
+ vi.mocked(resolveAgent).mockReturnValue(claudeAgent);
462
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ model: "anthropic/claude-opus" }));
463
+
464
+ await main();
465
+
466
+ expect(vi.mocked(resolveAgent).mock.calls[0]).toEqual([{ model: "anthropic/claude-opus" }]);
467
+ expect(selectFallbackModelIfNeeded).toHaveBeenCalledWith({
468
+ resolvedModel: "anthropic/claude-opus",
469
+ authorized: expect.any(Set),
470
+ providerKeyPresent: false,
471
+ agentName: "claude",
472
+ });
473
+ });
474
+
475
+ it("probes the provider key for the resolved model", async () => {
476
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ model: "openai/gpt-6" }));
477
+ vi.mocked(hasProviderKeyForModel).mockReturnValueOnce(true);
478
+
479
+ await main();
480
+
481
+ expect(hasProviderKeyForModel).toHaveBeenCalledWith("openai/gpt-6");
482
+ expect(selectFallbackModelIfNeeded).toHaveBeenCalledWith(
483
+ expect.objectContaining({ providerKeyPresent: true }),
484
+ );
485
+ });
486
+
487
+ it("validates the agent api key on the use-resolved path", async () => {
488
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ model: "openai/gpt-6" }));
489
+
490
+ await main();
491
+
492
+ expect(validateAgentApiKey).toHaveBeenCalledWith({
493
+ agent,
494
+ model: "openai/gpt-6",
495
+ authorized: expect.any(Set),
496
+ owner: "octo",
497
+ name: "repo",
498
+ });
499
+ });
500
+
501
+ it("falls back to the free model with a warning and records modelFallback", async () => {
502
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ model: "google/gemini-3" }));
503
+ vi.mocked(selectFallbackModelIfNeeded).mockReturnValueOnce({
504
+ kind: "fallback",
505
+ from: "google/gemini-3",
506
+ to: "opencode/big-pickle",
507
+ });
508
+
509
+ const result = await main();
510
+
511
+ expect(result.success).toBe(true);
512
+ expect(log.warning).toHaveBeenCalledWith(
513
+ expect.stringContaining("fell back from google/gemini-3 to opencode/big-pickle"),
514
+ );
515
+ expect(getToolState().modelFallback).toEqual({ from: "google/gemini-3" });
516
+ expect(getToolState().model).toBe("opencode/big-pickle");
517
+ // fallback slug must NOT be re-resolved (TERRAMEND_MODEL would override it)
518
+ expect(resolveModel).toHaveBeenCalledTimes(1);
519
+ expect(resolveModel).toHaveBeenCalledWith({ slug: "google/gemini-3" });
520
+ // validation is skipped — the gate already authorized the fallback model
521
+ expect(validateAgentApiKey).not.toHaveBeenCalled();
522
+ // the agent runs with the fallback model
523
+ expect(lastRunParams(agent).resolvedModel).toBe("opencode/big-pickle");
524
+ expect(vi.mocked(resolveAgent).mock.calls[1]).toEqual([{ model: "opencode/big-pickle" }]);
525
+ });
526
+
527
+ it("fails loudly when the model is unavailable to the present key", async () => {
528
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ model: "google/wrong-id" }));
529
+ vi.mocked(selectFallbackModelIfNeeded).mockReturnValueOnce({
530
+ kind: "unavailable",
531
+ model: "google/wrong-id",
532
+ });
533
+
534
+ const result = await main();
535
+
536
+ expect(result).toEqual({ success: false, error: "unavailable-error" });
537
+ expect(buildUnavailableModelError).toHaveBeenCalledWith({
538
+ model: "google/wrong-id",
539
+ authorized: expect.any(Set),
540
+ });
541
+ expect(agent.run).not.toHaveBeenCalled();
542
+ // toolContext was never built → no artifact persistence on this path
543
+ expect(persistRunArtifacts).not.toHaveBeenCalled();
544
+ expect(renderRunError).toHaveBeenCalledWith(
545
+ expect.objectContaining({ errorMessage: "unavailable-error" }),
546
+ );
547
+ expect(writeRunErrorOutputs).toHaveBeenCalledOnce();
548
+ });
549
+
550
+ it("materializes vertex credentials and denies their dir to the agent", async () => {
551
+ const creds = { secretDir: "/tmp/vertex-secret" } as unknown as VertexCredentials;
552
+ vi.mocked(materializeVertexCredentials).mockReturnValueOnce(creds);
553
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ model: "google/gemini-3" }));
554
+
555
+ await main();
556
+
557
+ expect(materializeVertexCredentials).toHaveBeenCalledWith({ model: "google/gemini-3" });
558
+ expect(lastRunParams(agent).secretDenyPaths).toEqual([
559
+ TERRAMEND_DATA_DIR,
560
+ "/tmp/vertex-secret",
561
+ ]);
562
+ expect(cleanupVertexCredentials).toHaveBeenCalledWith(creds);
563
+ });
564
+ });
565
+
566
+ describe("main – setup hook and package manager", () => {
567
+ it("warns when the setup hook fails but the run continues", async () => {
568
+ vi.mocked(executeLifecycleHook).mockResolvedValueOnce({
569
+ warning: "setup hook failed: boom",
570
+ failure: { kind: "exit", exitCode: 1, output: "boom" },
571
+ } as unknown as Awaited<ReturnType<typeof executeLifecycleHook>>);
572
+
573
+ const result = await main();
574
+
575
+ expect(result.success).toBe(true);
576
+ expect(log.warning).toHaveBeenCalledWith("setup hook failed: boom");
577
+ });
578
+
579
+ it("pins the resolved package manager into the private bin dir", async () => {
580
+ const spec = { name: "pnpm", version: "11.0.0" } as unknown as Awaited<
581
+ ReturnType<typeof resolvePackageManagerSpec>
582
+ >;
583
+ vi.mocked(resolvePackageManagerSpec).mockResolvedValueOnce(spec);
584
+
585
+ await main();
586
+
587
+ expect(ensurePackageManager).toHaveBeenCalledWith({
588
+ spec,
589
+ binDir: "/tmp/terramend-test/pm-bin",
590
+ });
591
+ });
592
+ });
593
+
594
+ describe("main – learnings and summary seeding", () => {
595
+ it("seeds the learnings file when a backend is configured", async () => {
596
+ vi.mocked(isBackendConfigured).mockReturnValueOnce(true);
597
+ vi.mocked(resolveRunContextData).mockResolvedValueOnce(
598
+ makeRunContext({ learnings: " existing learnings " }),
599
+ );
600
+
601
+ await main();
602
+
603
+ expect(seedLearningsFile).toHaveBeenCalledWith({
604
+ tmpdir: "/tmp/terramend-test",
605
+ current: " existing learnings ",
606
+ });
607
+ expect(getToolState().learningsFilePath).toBe("/tmp/learnings.md");
608
+ expect(getToolState().learningsSeed).toBe("existing learnings");
609
+ await fireExitHandlers();
610
+ expect(persistLearnings).toHaveBeenCalledWith(getToolContext());
611
+ });
612
+
613
+ it("continues with a warning when learnings seeding fails", async () => {
614
+ vi.mocked(isBackendConfigured).mockReturnValueOnce(true);
615
+ vi.mocked(seedLearningsFile).mockRejectedValueOnce(new Error("ENOSPC"));
616
+
617
+ const result = await main();
618
+
619
+ expect(result.success).toBe(true);
620
+ expect(log.warning).toHaveBeenCalledWith(
621
+ expect.stringContaining("learnings seed failed: ENOSPC"),
622
+ );
623
+ expect(getToolState().learningsFilePath).toBeUndefined();
624
+ });
625
+
626
+ it("skips learnings seeding without a configured backend", async () => {
627
+ await main();
628
+
629
+ expect(seedLearningsFile).not.toHaveBeenCalled();
630
+ expect(log.debug).toHaveBeenCalledWith(expect.stringContaining("no backend configured"));
631
+ });
632
+
633
+ it("seeds the PR summary file when generateSummary is requested", async () => {
634
+ vi.mocked(resolvePayload).mockReturnValue(
635
+ makePayload({
636
+ generateSummary: true,
637
+ event: { trigger: "pull_request_opened", is_pr: true, issue_number: 7 },
638
+ }),
639
+ );
640
+ vi.mocked(fetchPreviousSnapshot).mockResolvedValueOnce("previous snapshot");
641
+
642
+ await main();
643
+
644
+ expect(fetchPreviousSnapshot).toHaveBeenCalledWith(getToolContext(), 7);
645
+ expect(seedSummaryFile).toHaveBeenCalledWith({
646
+ tmpdir: "/tmp/terramend-test",
647
+ previousSnapshot: "previous snapshot",
648
+ });
649
+ expect(getToolState().summaryFilePath).toBe("/tmp/summary.md");
650
+ expect(getToolState().summarySeed).toBe("seed-bytes");
651
+ await fireExitHandlers();
652
+ expect(persistSummary).toHaveBeenCalledWith(getToolContext());
653
+ });
654
+
655
+ it("leaves summarySeed undefined when the seed read-back fails", async () => {
656
+ vi.mocked(resolvePayload).mockReturnValue(
657
+ makePayload({
658
+ generateSummary: true,
659
+ event: { trigger: "pull_request_opened", is_pr: true, issue_number: 7 },
660
+ }),
661
+ );
662
+ vi.mocked(readFile).mockRejectedValueOnce(new Error("EACCES"));
663
+
664
+ await main();
665
+
666
+ expect(getToolState().summaryFilePath).toBe("/tmp/summary.md");
667
+ expect(getToolState().summarySeed).toBeUndefined();
668
+ });
669
+ });
670
+
671
+ describe("main – agent run plumbing", () => {
672
+ it("awaits dependency installation when .opencode/plugin files exist", async () => {
673
+ vi.mocked(existsSync).mockReturnValue(true);
674
+ vi.mocked(readdirSync).mockReturnValue(["plugin.ts"] as unknown as ReturnType<
675
+ typeof readdirSync
676
+ >);
677
+ vi.mocked(startInstallation).mockImplementationOnce((ctx: ToolContext) => {
678
+ ctx.toolState.dependencyInstallation = {
679
+ status: "in_progress",
680
+ promise: Promise.resolve([]),
681
+ results: undefined,
682
+ };
683
+ });
684
+
685
+ await main();
686
+
687
+ expect(log.info).toHaveBeenCalledWith(
688
+ expect.stringContaining(".opencode/plugin/ detected — awaiting dependency installation"),
689
+ );
690
+ });
691
+
692
+ it("tracks diff coverage from agent tool-use events", async () => {
693
+ agent = makeAgent("opencode", async (params) => {
694
+ params.onToolUse?.({ toolName: "Read", input: { file_path: "/x" } });
695
+ params.onToolUse?.({ toolName: "Bash", input: {} });
696
+ return { success: true };
697
+ });
698
+ vi.mocked(resolveAgent).mockReturnValue(agent);
699
+ vi.mocked(recordDiffReadFromToolUse).mockReturnValueOnce(true);
700
+
701
+ await main();
702
+
703
+ expect(recordDiffReadFromToolUse).toHaveBeenCalledTimes(2);
704
+ expect(log.debug).toHaveBeenCalledWith(
705
+ expect.stringContaining("diff coverage tracked from tool Read"),
706
+ );
707
+ });
708
+
709
+ it("reports todo-tracker progress and survives report failures", async () => {
710
+ await main();
711
+
712
+ const call = vi.mocked(createTodoTracker).mock.calls[0];
713
+ if (!call) throw new Error("createTodoTracker was not called");
714
+ const onUpdate = call[0];
715
+
716
+ await onUpdate("- [ ] item");
717
+ expect(reportProgress).toHaveBeenCalledWith(getToolContext(), {
718
+ body: "- [ ] item",
719
+ liveProgress: true,
720
+ });
721
+
722
+ vi.mocked(reportProgress).mockRejectedValueOnce(new Error("github down"));
723
+ await onUpdate("- [x] item");
724
+ expect(log.debug).toHaveBeenCalledWith(expect.stringContaining("progress update failed"));
725
+ });
726
+
727
+ it("stops the MCP server once when the inner activity timeout fires", async () => {
728
+ const dispose = vi.fn(async () => {});
729
+ vi.mocked(startMcpHttpServer).mockResolvedValueOnce({
730
+ url: "http://127.0.0.1:7777/mcp",
731
+ token: "mcp-token",
732
+ [Symbol.asyncDispose]: dispose,
733
+ });
734
+ agent = makeAgent("opencode", async (params) => {
735
+ params.onActivityTimeout?.();
736
+ params.onActivityTimeout?.(); // second call must be a no-op
737
+ return { success: true };
738
+ });
739
+ vi.mocked(resolveAgent).mockReturnValue(agent);
740
+
741
+ const result = await main();
742
+
743
+ expect(result.success).toBe(true);
744
+ // once from the inner-timeout handler + once from the `await using` cleanup
745
+ expect(dispose).toHaveBeenCalledTimes(2);
746
+ expect(log.info).toHaveBeenCalledWith(expect.stringContaining("inner activity timeout fired"));
747
+ });
748
+
749
+ it("logs when the fire-and-forget MCP stop after the inner kill fails", async () => {
750
+ const dispose = vi.fn(async () => {});
751
+ dispose.mockRejectedValueOnce(new Error("already closed"));
752
+ vi.mocked(startMcpHttpServer).mockResolvedValueOnce({
753
+ url: "http://127.0.0.1:7777/mcp",
754
+ token: "mcp-token",
755
+ [Symbol.asyncDispose]: dispose,
756
+ });
757
+ agent = makeAgent("opencode", async (params) => {
758
+ params.onActivityTimeout?.();
759
+ return { success: true };
760
+ });
761
+ vi.mocked(resolveAgent).mockReturnValue(agent);
762
+
763
+ await main();
764
+
765
+ expect(log.debug).toHaveBeenCalledWith(
766
+ expect.stringContaining("mcp server stop after inner kill failed: already closed"),
767
+ );
768
+ });
769
+
770
+ it("races only the activity timeout when --notimeout is set", async () => {
771
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ timeout: "none" }));
772
+
773
+ const result = await main();
774
+
775
+ expect(result.success).toBe(true);
776
+ expect(agent.run).toHaveBeenCalledOnce();
777
+ });
778
+
779
+ it("warns and falls back to 1h on an unparseable timeout", async () => {
780
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ timeout: "bogus" }));
781
+
782
+ const result = await main();
783
+
784
+ expect(result.success).toBe(true);
785
+ expect(log.warning).toHaveBeenCalledWith(expect.stringContaining('invalid timeout "bogus"'));
786
+ });
787
+
788
+ it("fails the run when the agent exceeds the configured timeout", async () => {
789
+ vi.useFakeTimers();
790
+ try {
791
+ vi.mocked(resolvePayload).mockReturnValue(makePayload({ timeout: "1s" }));
792
+ agent = makeAgent("opencode", () => new Promise<never>(() => {}));
793
+ vi.mocked(resolveAgent).mockReturnValue(agent);
794
+
795
+ const resultPromise = main();
796
+ await vi.advanceTimersByTimeAsync(1_001);
797
+ const result = await resultPromise;
798
+
799
+ expect(result).toEqual({ success: false, error: "agent run timed out after 1s" });
800
+ expect(persistRunArtifacts).toHaveBeenCalledOnce();
801
+ } finally {
802
+ vi.useRealTimers();
803
+ }
804
+ });
805
+
806
+ it("pushes agent usage and patches the workflow run in finally", async () => {
807
+ const usage = { agent: "opencode", inputTokens: 10, outputTokens: 4 };
808
+ agent = makeAgent("opencode", async () => ({ success: true, usage }));
809
+ vi.mocked(resolveAgent).mockReturnValue(agent);
810
+ const patch = { inputTokens: "10" } as unknown as ReturnType<typeof aggregateUsage>;
811
+ vi.mocked(aggregateUsage).mockReturnValueOnce(patch);
812
+
813
+ await main();
814
+
815
+ expect(aggregateUsage).toHaveBeenCalledWith([usage]);
816
+ expect(patchWorkflowRunFields).toHaveBeenCalledWith(getToolContext(), patch);
817
+ });
818
+
819
+ it("throws when output_schema is set but the agent never called set_output", async () => {
820
+ vi.mocked(resolveOutputSchema).mockReturnValueOnce({ type: "object" });
821
+
822
+ const result = await main();
823
+
824
+ expect(result.success).toBe(false);
825
+ expect(result.error).toContain("output_schema was provided but agent did not call set_output");
826
+ expect(finalizeSuccessRun).not.toHaveBeenCalled();
827
+ });
828
+ });
829
+
830
+ describe("main – error path", () => {
831
+ it("renders and persists when the agent run rejects after toolContext exists", async () => {
832
+ agent = makeAgent("opencode", async () => {
833
+ throw new Error("agent exploded");
834
+ });
835
+ vi.mocked(resolveAgent).mockReturnValue(agent);
836
+
837
+ const result = await main();
838
+
839
+ expect(result).toEqual({ success: false, error: "agent exploded" });
840
+ expect(log.error).toHaveBeenCalledWith("agent exploded");
841
+ expect(renderRunError).toHaveBeenCalledWith({
842
+ errorMessage: "agent exploded",
843
+ repo: { owner: "octo", name: "repo", data: {} },
844
+ agentDiagnostic: undefined,
845
+ });
846
+ expect(writeRunErrorOutputs).toHaveBeenCalledWith({
847
+ rendered: { summary: "rendered-summary", comment: "rendered-comment" },
848
+ toolState: getToolState(),
849
+ });
850
+ expect(persistRunArtifacts).toHaveBeenCalledWith(getToolContext());
851
+ const tracker = vi.mocked(createTodoTracker).mock.results[0]?.value as TodoTracker;
852
+ expect(tracker.cancel).toHaveBeenCalled();
853
+ expect(killTrackedChildren).toHaveBeenCalled();
854
+ });
855
+
856
+ it("skips artifact persistence when the failure precedes toolContext", async () => {
857
+ vi.mocked(setupGit).mockRejectedValueOnce(new Error("git setup failed"));
858
+
859
+ const result = await main();
860
+
861
+ expect(result).toEqual({ success: false, error: "git setup failed" });
862
+ expect(persistRunArtifacts).not.toHaveBeenCalled();
863
+ expect(agent.run).not.toHaveBeenCalled();
864
+ });
865
+
866
+ it("uses a generic message for non-Error throws", async () => {
867
+ vi.mocked(setupGit).mockRejectedValueOnce("string failure");
868
+
869
+ const result = await main();
870
+
871
+ expect(result).toEqual({ success: false, error: "unknown error occurred" });
872
+ });
873
+ });