lastlight 0.6.1 → 0.7.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 (326) hide show
  1. package/dist/admin/routes.d.ts +1 -1
  2. package/dist/admin/routes.js +1 -1
  3. package/dist/admin/routes.js.map +1 -1
  4. package/dist/cli/cli-config.js.map +1 -0
  5. package/dist/cli/cli-format.js.map +1 -0
  6. package/dist/{cli-server.d.ts → cli/cli-server.d.ts} +1 -1
  7. package/dist/{cli-server.js → cli/cli-server.js} +2 -2
  8. package/dist/cli/cli-server.js.map +1 -0
  9. package/dist/cli/cli-timeline.js.map +1 -0
  10. package/dist/{cli.js → cli/cli.js} +2 -0
  11. package/dist/cli/cli.js.map +1 -0
  12. package/dist/{fork-cli.d.ts → cli/fork-cli.d.ts} +14 -5
  13. package/dist/{fork-cli.js → cli/fork-cli.js} +86 -16
  14. package/dist/cli/fork-cli.js.map +1 -0
  15. package/dist/{setup.js → cli/setup.js} +1 -1
  16. package/dist/cli/setup.js.map +1 -0
  17. package/dist/{skills-install.js → cli/skills-install.js} +1 -1
  18. package/dist/cli/skills-install.js.map +1 -0
  19. package/dist/config/config-resolve.js.map +1 -0
  20. package/dist/{config.js → config/config.js} +2 -2
  21. package/dist/config/config.js.map +1 -0
  22. package/dist/config/overlay-assets.js.map +1 -0
  23. package/dist/config/overlay-bootstrap.js.map +1 -0
  24. package/dist/engine/agent-executor.d.ts +1 -1
  25. package/dist/engine/agent-executor.js +2 -2
  26. package/dist/engine/agent-executor.js.map +1 -1
  27. package/dist/engine/{chat-runner.d.ts → chat/chat-runner.d.ts} +2 -2
  28. package/dist/engine/{chat-runner.js → chat/chat-runner.js} +1 -1
  29. package/dist/engine/chat/chat-runner.js.map +1 -0
  30. package/dist/engine/{chat-skills.js → chat/chat-skills.js} +1 -1
  31. package/dist/engine/chat/chat-skills.js.map +1 -0
  32. package/dist/engine/{chat.d.ts → chat/chat.d.ts} +2 -2
  33. package/dist/engine/{chat.js → chat/chat.js} +7 -7
  34. package/dist/engine/chat/chat.js.map +1 -0
  35. package/dist/engine/{message-batcher.d.ts → chat/message-batcher.d.ts} +1 -1
  36. package/dist/engine/chat/message-batcher.js.map +1 -0
  37. package/dist/engine/dispatcher.d.ts +2 -2
  38. package/dist/engine/executors/backends.d.ts +2 -2
  39. package/dist/engine/executors/backends.js +1 -1
  40. package/dist/engine/executors/backends.js.map +1 -1
  41. package/dist/engine/executors/shared.d.ts +1 -1
  42. package/dist/engine/github/git-auth.js.map +1 -0
  43. package/dist/engine/github/github-app-client.js.map +1 -0
  44. package/dist/engine/github/github-tools.js.map +1 -0
  45. package/dist/engine/github/github.js.map +1 -0
  46. package/dist/engine/{profiles.d.ts → github/profiles.d.ts} +1 -1
  47. package/dist/engine/{profiles.js → github/profiles.js} +1 -1
  48. package/dist/engine/github/profiles.js.map +1 -0
  49. package/dist/engine/router.js +3 -3
  50. package/dist/engine/router.js.map +1 -1
  51. package/dist/engine/{classifier.d.ts → screen/classifier.d.ts} +1 -1
  52. package/dist/engine/{classifier.js → screen/classifier.js} +1 -1
  53. package/dist/engine/screen/classifier.js.map +1 -0
  54. package/dist/engine/{screen.d.ts → screen/screen.d.ts} +1 -1
  55. package/dist/engine/{screen.js → screen/screen.js} +1 -1
  56. package/dist/engine/screen/screen.js.map +1 -0
  57. package/dist/evals-api.d.ts +3 -3
  58. package/dist/evals-api.js +1 -1
  59. package/dist/evals-api.js.map +1 -1
  60. package/dist/index.js +8 -8
  61. package/dist/index.js.map +1 -1
  62. package/dist/managed-repos.js +1 -1
  63. package/dist/managed-repos.js.map +1 -1
  64. package/dist/notify/transports/github.d.ts +1 -1
  65. package/dist/telemetry/index.d.ts +1 -1
  66. package/dist/workflows/loader.d.ts +1 -1
  67. package/dist/workflows/phase-executor.d.ts +1 -1
  68. package/dist/workflows/resume.d.ts +3 -3
  69. package/dist/workflows/runner.d.ts +2 -2
  70. package/dist/workflows/runner.js +1 -1
  71. package/dist/workflows/runner.js.map +1 -1
  72. package/dist/workflows/schema.d.ts +2 -2
  73. package/dist/workflows/simple.d.ts +2 -2
  74. package/dist/workflows/simple.js +1 -1
  75. package/dist/workflows/simple.js.map +1 -1
  76. package/dist/workflows/triggers.js +1 -1
  77. package/dist/workflows/triggers.js.map +1 -1
  78. package/package.json +4 -3
  79. package/plugins/lastlight/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lastlight/skills/lastlight-evals/SKILL.md +52 -19
  81. package/plugins/lastlight/skills/lastlight-overlay/SKILL.md +7 -1
  82. package/plugins/lastlight/skills/lastlight-overlay/references/forking.md +23 -4
  83. package/dist/admin/auth.test.d.ts +0 -1
  84. package/dist/admin/auth.test.js +0 -60
  85. package/dist/admin/auth.test.js.map +0 -1
  86. package/dist/admin/log-search.test.d.ts +0 -1
  87. package/dist/admin/log-search.test.js +0 -78
  88. package/dist/admin/log-search.test.js.map +0 -1
  89. package/dist/admin/routes.test.d.ts +0 -1
  90. package/dist/admin/routes.test.js +0 -877
  91. package/dist/admin/routes.test.js.map +0 -1
  92. package/dist/admin/server-logs.test.d.ts +0 -1
  93. package/dist/admin/server-logs.test.js +0 -58
  94. package/dist/admin/server-logs.test.js.map +0 -1
  95. package/dist/admin/sessions.test.d.ts +0 -1
  96. package/dist/admin/sessions.test.js +0 -78
  97. package/dist/admin/sessions.test.js.map +0 -1
  98. package/dist/admin/version.test.d.ts +0 -1
  99. package/dist/admin/version.test.js +0 -71
  100. package/dist/admin/version.test.js.map +0 -1
  101. package/dist/cli-config.js.map +0 -1
  102. package/dist/cli-config.test.d.ts +0 -1
  103. package/dist/cli-config.test.js +0 -92
  104. package/dist/cli-config.test.js.map +0 -1
  105. package/dist/cli-format.js.map +0 -1
  106. package/dist/cli-server.js.map +0 -1
  107. package/dist/cli-server.test.d.ts +0 -1
  108. package/dist/cli-server.test.js +0 -41
  109. package/dist/cli-server.test.js.map +0 -1
  110. package/dist/cli-timeline.js.map +0 -1
  111. package/dist/cli-timeline.test.d.ts +0 -1
  112. package/dist/cli-timeline.test.js +0 -104
  113. package/dist/cli-timeline.test.js.map +0 -1
  114. package/dist/cli.js.map +0 -1
  115. package/dist/config-overlay.test.d.ts +0 -1
  116. package/dist/config-overlay.test.js +0 -112
  117. package/dist/config-overlay.test.js.map +0 -1
  118. package/dist/config-resolve.js.map +0 -1
  119. package/dist/config-resolve.test.d.ts +0 -1
  120. package/dist/config-resolve.test.js +0 -68
  121. package/dist/config-resolve.test.js.map +0 -1
  122. package/dist/config.js.map +0 -1
  123. package/dist/config.test.d.ts +0 -1
  124. package/dist/config.test.js +0 -288
  125. package/dist/config.test.js.map +0 -1
  126. package/dist/connectors/github-webhook.test.d.ts +0 -1
  127. package/dist/connectors/github-webhook.test.js +0 -156
  128. package/dist/connectors/github-webhook.test.js.map +0 -1
  129. package/dist/connectors/messaging/session-manager.test.d.ts +0 -1
  130. package/dist/connectors/messaging/session-manager.test.js +0 -144
  131. package/dist/connectors/messaging/session-manager.test.js.map +0 -1
  132. package/dist/connectors/slack/connector.test.d.ts +0 -1
  133. package/dist/connectors/slack/connector.test.js +0 -135
  134. package/dist/connectors/slack/connector.test.js.map +0 -1
  135. package/dist/connectors/slack/mrkdwn.test.d.ts +0 -1
  136. package/dist/connectors/slack/mrkdwn.test.js +0 -114
  137. package/dist/connectors/slack/mrkdwn.test.js.map +0 -1
  138. package/dist/cron/fanout.test.d.ts +0 -1
  139. package/dist/cron/fanout.test.js +0 -73
  140. package/dist/cron/fanout.test.js.map +0 -1
  141. package/dist/engine/agent-executor.seam.test.d.ts +0 -1
  142. package/dist/engine/agent-executor.seam.test.js +0 -57
  143. package/dist/engine/agent-executor.seam.test.js.map +0 -1
  144. package/dist/engine/agent-executor.test.d.ts +0 -1
  145. package/dist/engine/agent-executor.test.js +0 -395
  146. package/dist/engine/agent-executor.test.js.map +0 -1
  147. package/dist/engine/chat-runner.js.map +0 -1
  148. package/dist/engine/chat-runner.test.d.ts +0 -1
  149. package/dist/engine/chat-runner.test.js +0 -104
  150. package/dist/engine/chat-runner.test.js.map +0 -1
  151. package/dist/engine/chat-skills.js.map +0 -1
  152. package/dist/engine/chat-skills.test.d.ts +0 -1
  153. package/dist/engine/chat-skills.test.js +0 -20
  154. package/dist/engine/chat-skills.test.js.map +0 -1
  155. package/dist/engine/chat.js.map +0 -1
  156. package/dist/engine/chat.test.d.ts +0 -1
  157. package/dist/engine/chat.test.js +0 -42
  158. package/dist/engine/chat.test.js.map +0 -1
  159. package/dist/engine/classifier.js.map +0 -1
  160. package/dist/engine/classifier.test.d.ts +0 -1
  161. package/dist/engine/classifier.test.js +0 -115
  162. package/dist/engine/classifier.test.js.map +0 -1
  163. package/dist/engine/dispatcher.test.d.ts +0 -1
  164. package/dist/engine/dispatcher.test.js +0 -511
  165. package/dist/engine/dispatcher.test.js.map +0 -1
  166. package/dist/engine/event-shim.test.d.ts +0 -1
  167. package/dist/engine/event-shim.test.js +0 -194
  168. package/dist/engine/event-shim.test.js.map +0 -1
  169. package/dist/engine/git-auth.js.map +0 -1
  170. package/dist/engine/git-auth.test.d.ts +0 -1
  171. package/dist/engine/git-auth.test.js +0 -117
  172. package/dist/engine/git-auth.test.js.map +0 -1
  173. package/dist/engine/github-app-client.js.map +0 -1
  174. package/dist/engine/github-app-client.test.d.ts +0 -1
  175. package/dist/engine/github-app-client.test.js +0 -44
  176. package/dist/engine/github-app-client.test.js.map +0 -1
  177. package/dist/engine/github-tools.js.map +0 -1
  178. package/dist/engine/github.js.map +0 -1
  179. package/dist/engine/llm.test.d.ts +0 -1
  180. package/dist/engine/llm.test.js +0 -189
  181. package/dist/engine/llm.test.js.map +0 -1
  182. package/dist/engine/message-batcher.js.map +0 -1
  183. package/dist/engine/message-batcher.test.d.ts +0 -1
  184. package/dist/engine/message-batcher.test.js +0 -109
  185. package/dist/engine/message-batcher.test.js.map +0 -1
  186. package/dist/engine/profiles.js.map +0 -1
  187. package/dist/engine/router.test.d.ts +0 -1
  188. package/dist/engine/router.test.js +0 -695
  189. package/dist/engine/router.test.js.map +0 -1
  190. package/dist/engine/screen.js.map +0 -1
  191. package/dist/engine/screen.test.d.ts +0 -1
  192. package/dist/engine/screen.test.js +0 -82
  193. package/dist/engine/screen.test.js.map +0 -1
  194. package/dist/fork-cli.js.map +0 -1
  195. package/dist/fork-cli.test.d.ts +0 -1
  196. package/dist/fork-cli.test.js +0 -109
  197. package/dist/fork-cli.test.js.map +0 -1
  198. package/dist/managed-repos.test.d.ts +0 -1
  199. package/dist/managed-repos.test.js +0 -39
  200. package/dist/managed-repos.test.js.map +0 -1
  201. package/dist/notify/model.test.d.ts +0 -1
  202. package/dist/notify/model.test.js +0 -109
  203. package/dist/notify/model.test.js.map +0 -1
  204. package/dist/notify/notifier.test.d.ts +0 -1
  205. package/dist/notify/notifier.test.js +0 -100
  206. package/dist/notify/notifier.test.js.map +0 -1
  207. package/dist/notify/render.test.d.ts +0 -1
  208. package/dist/notify/render.test.js +0 -65
  209. package/dist/notify/render.test.js.map +0 -1
  210. package/dist/notify/transports.test.d.ts +0 -1
  211. package/dist/notify/transports.test.js +0 -71
  212. package/dist/notify/transports.test.js.map +0 -1
  213. package/dist/overlay-assets.js.map +0 -1
  214. package/dist/overlay-assets.test.d.ts +0 -1
  215. package/dist/overlay-assets.test.js +0 -75
  216. package/dist/overlay-assets.test.js.map +0 -1
  217. package/dist/overlay-bootstrap.js.map +0 -1
  218. package/dist/overlay-bootstrap.test.d.ts +0 -1
  219. package/dist/overlay-bootstrap.test.js +0 -61
  220. package/dist/overlay-bootstrap.test.js.map +0 -1
  221. package/dist/sandbox/command-exec.integration.test.d.ts +0 -1
  222. package/dist/sandbox/command-exec.integration.test.js +0 -183
  223. package/dist/sandbox/command-exec.integration.test.js.map +0 -1
  224. package/dist/sandbox/docker-compose.test.d.ts +0 -1
  225. package/dist/sandbox/docker-compose.test.js +0 -128
  226. package/dist/sandbox/docker-compose.test.js.map +0 -1
  227. package/dist/sandbox/docker.test.d.ts +0 -1
  228. package/dist/sandbox/docker.test.js +0 -137
  229. package/dist/sandbox/docker.test.js.map +0 -1
  230. package/dist/sandbox/egress-allowlist.test.d.ts +0 -1
  231. package/dist/sandbox/egress-allowlist.test.js +0 -65
  232. package/dist/sandbox/egress-allowlist.test.js.map +0 -1
  233. package/dist/sandbox/egress-firewall-config.test.d.ts +0 -1
  234. package/dist/sandbox/egress-firewall-config.test.js +0 -244
  235. package/dist/sandbox/egress-firewall-config.test.js.map +0 -1
  236. package/dist/sandbox/index.test.d.ts +0 -1
  237. package/dist/sandbox/index.test.js +0 -157
  238. package/dist/sandbox/index.test.js.map +0 -1
  239. package/dist/sandbox/smol.integration.test.d.ts +0 -1
  240. package/dist/sandbox/smol.integration.test.js +0 -130
  241. package/dist/sandbox/smol.integration.test.js.map +0 -1
  242. package/dist/session-log.test.d.ts +0 -1
  243. package/dist/session-log.test.js +0 -85
  244. package/dist/session-log.test.js.map +0 -1
  245. package/dist/setup.js.map +0 -1
  246. package/dist/setup.test.d.ts +0 -1
  247. package/dist/setup.test.js +0 -281
  248. package/dist/setup.test.js.map +0 -1
  249. package/dist/skills-install.js.map +0 -1
  250. package/dist/state/build-assets.test.d.ts +0 -1
  251. package/dist/state/build-assets.test.js +0 -106
  252. package/dist/state/build-assets.test.js.map +0 -1
  253. package/dist/state/db.test.d.ts +0 -1
  254. package/dist/state/db.test.js +0 -484
  255. package/dist/state/db.test.js.map +0 -1
  256. package/dist/state/workflow-run-store.test.d.ts +0 -1
  257. package/dist/state/workflow-run-store.test.js +0 -264
  258. package/dist/state/workflow-run-store.test.js.map +0 -1
  259. package/dist/telemetry/index.test.d.ts +0 -1
  260. package/dist/telemetry/index.test.js +0 -73
  261. package/dist/telemetry/index.test.js.map +0 -1
  262. package/dist/telemetry/pi-events.test.d.ts +0 -1
  263. package/dist/telemetry/pi-events.test.js +0 -69
  264. package/dist/telemetry/pi-events.test.js.map +0 -1
  265. package/dist/workflows/dag.test.d.ts +0 -1
  266. package/dist/workflows/dag.test.js +0 -197
  267. package/dist/workflows/dag.test.js.map +0 -1
  268. package/dist/workflows/golden-build.test.d.ts +0 -1
  269. package/dist/workflows/golden-build.test.js +0 -45
  270. package/dist/workflows/golden-build.test.js.map +0 -1
  271. package/dist/workflows/loader-overlay.test.d.ts +0 -1
  272. package/dist/workflows/loader-overlay.test.js +0 -101
  273. package/dist/workflows/loader-overlay.test.js.map +0 -1
  274. package/dist/workflows/loader.test.d.ts +0 -1
  275. package/dist/workflows/loader.test.js +0 -375
  276. package/dist/workflows/loader.test.js.map +0 -1
  277. package/dist/workflows/loop-eval.test.d.ts +0 -1
  278. package/dist/workflows/loop-eval.test.js +0 -89
  279. package/dist/workflows/loop-eval.test.js.map +0 -1
  280. package/dist/workflows/phase-executor.test.d.ts +0 -1
  281. package/dist/workflows/phase-executor.test.js +0 -497
  282. package/dist/workflows/phase-executor.test.js.map +0 -1
  283. package/dist/workflows/phase-ref.test.d.ts +0 -1
  284. package/dist/workflows/phase-ref.test.js +0 -24
  285. package/dist/workflows/phase-ref.test.js.map +0 -1
  286. package/dist/workflows/runner.test.d.ts +0 -1
  287. package/dist/workflows/runner.test.js +0 -1174
  288. package/dist/workflows/runner.test.js.map +0 -1
  289. package/dist/workflows/simple.test.d.ts +0 -1
  290. package/dist/workflows/simple.test.js +0 -107
  291. package/dist/workflows/simple.test.js.map +0 -1
  292. package/dist/workflows/templates.test.d.ts +0 -1
  293. package/dist/workflows/templates.test.js +0 -258
  294. package/dist/workflows/templates.test.js.map +0 -1
  295. package/dist/workflows/verdict.test.d.ts +0 -1
  296. package/dist/workflows/verdict.test.js +0 -44
  297. package/dist/workflows/verdict.test.js.map +0 -1
  298. package/dist/worktree/manager.test.d.ts +0 -1
  299. package/dist/worktree/manager.test.js +0 -87
  300. package/dist/worktree/manager.test.js.map +0 -1
  301. /package/dist/{cli-config.d.ts → cli/cli-config.d.ts} +0 -0
  302. /package/dist/{cli-config.js → cli/cli-config.js} +0 -0
  303. /package/dist/{cli-format.d.ts → cli/cli-format.d.ts} +0 -0
  304. /package/dist/{cli-format.js → cli/cli-format.js} +0 -0
  305. /package/dist/{cli-timeline.d.ts → cli/cli-timeline.d.ts} +0 -0
  306. /package/dist/{cli-timeline.js → cli/cli-timeline.js} +0 -0
  307. /package/dist/{cli.d.ts → cli/cli.d.ts} +0 -0
  308. /package/dist/{setup.d.ts → cli/setup.d.ts} +0 -0
  309. /package/dist/{skills-install.d.ts → cli/skills-install.d.ts} +0 -0
  310. /package/dist/{config-resolve.d.ts → config/config-resolve.d.ts} +0 -0
  311. /package/dist/{config-resolve.js → config/config-resolve.js} +0 -0
  312. /package/dist/{config.d.ts → config/config.d.ts} +0 -0
  313. /package/dist/{overlay-assets.d.ts → config/overlay-assets.d.ts} +0 -0
  314. /package/dist/{overlay-assets.js → config/overlay-assets.js} +0 -0
  315. /package/dist/{overlay-bootstrap.d.ts → config/overlay-bootstrap.d.ts} +0 -0
  316. /package/dist/{overlay-bootstrap.js → config/overlay-bootstrap.js} +0 -0
  317. /package/dist/engine/{chat-skills.d.ts → chat/chat-skills.d.ts} +0 -0
  318. /package/dist/engine/{message-batcher.js → chat/message-batcher.js} +0 -0
  319. /package/dist/engine/{git-auth.d.ts → github/git-auth.d.ts} +0 -0
  320. /package/dist/engine/{git-auth.js → github/git-auth.js} +0 -0
  321. /package/dist/engine/{github-app-client.d.ts → github/github-app-client.d.ts} +0 -0
  322. /package/dist/engine/{github-app-client.js → github/github-app-client.js} +0 -0
  323. /package/dist/engine/{github-tools.d.ts → github/github-tools.d.ts} +0 -0
  324. /package/dist/engine/{github-tools.js → github/github-tools.js} +0 -0
  325. /package/dist/engine/{github.d.ts → github/github.d.ts} +0 -0
  326. /package/dist/engine/{github.js → github/github.js} +0 -0
@@ -1,1174 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- // Mock the executor so we don't make real agent calls. `executeCommand` backs
3
- // both `type: bash`/`script` phases and the in-sandbox `until_bash` check.
4
- vi.mock("../engine/agent-executor.js", () => ({
5
- executeAgent: vi.fn(),
6
- executeCommand: vi.fn(),
7
- }));
8
- // Mock the docker module
9
- vi.mock("../admin/docker.js", () => ({
10
- listRunningContainers: vi.fn(async () => []),
11
- }));
12
- // Mock the loader so templates come from strings, not files
13
- vi.mock("./loader.js", () => ({
14
- loadPromptTemplate: vi.fn((path) => `TEMPLATE:${path}`),
15
- }));
16
- // Keep child_process mocked so `qaImageAvailable()` (images.ts) sees no
17
- // `execFileSync` and reports the QA image as unavailable in tests. until_bash no
18
- // longer uses execSync (it runs via the mocked executeCommand), but this mock
19
- // must stay for the sandbox_image:qa skip test.
20
- vi.mock("child_process", () => ({ execSync: vi.fn() }));
21
- import { executeAgent, executeCommand } from "../engine/agent-executor.js";
22
- import { loadPromptTemplate } from "./loader.js";
23
- import { runWorkflow, gitAccessProfileForWorkflow, gitSandboxAccessForWorkflow } from "./runner.js";
24
- const mockExecuteAgent = vi.mocked(executeAgent);
25
- const mockExecuteCommand = vi.mocked(executeCommand);
26
- const mockLoadPromptTemplate = vi.mocked(loadPromptTemplate);
27
- const BASE_CTX = {
28
- owner: "acme",
29
- repo: "widget",
30
- issueNumber: 42,
31
- issueTitle: "Add Rate Limiter",
32
- issueBody: "We need a rate limiter",
33
- issueLabels: [],
34
- commentBody: "",
35
- sender: "alice",
36
- branch: "lastlight/42-add-rate-limiter",
37
- taskId: "widget-42",
38
- issueDir: ".lastlight/issue-42",
39
- bootstrapLabel: "lastlight:bootstrap",
40
- };
41
- function makeSuccessResult(output = "success output") {
42
- return {
43
- success: true,
44
- output,
45
- error: undefined,
46
- turns: 5,
47
- durationMs: 1000,
48
- };
49
- }
50
- function makeFailResult(error = "something went wrong") {
51
- return {
52
- success: false,
53
- output: "",
54
- error,
55
- turns: 2,
56
- durationMs: 500,
57
- };
58
- }
59
- const SIMPLE_WORKFLOW = {
60
- kind: "agent",
61
- name: "simple",
62
- phases: [
63
- { name: "phase_0", type: "context" },
64
- { name: "architect", type: "agent", prompt: "prompts/architect.md" },
65
- { name: "executor", type: "agent", prompt: "prompts/executor.md" },
66
- ],
67
- };
68
- const WORKFLOW_WITH_GUARDRAILS = {
69
- kind: "agent",
70
- name: "guarded",
71
- phases: [
72
- { name: "phase_0", type: "context" },
73
- {
74
- name: "guardrails",
75
- type: "agent",
76
- prompt: "prompts/guardrails.md",
77
- on_output: {
78
- contains_BLOCKED: {
79
- action: "fail",
80
- message: "Guardrails check: BLOCKED",
81
- unless_label: "lastlight:bootstrap",
82
- },
83
- contains_READY: { action: "continue" },
84
- },
85
- },
86
- { name: "architect", type: "agent", prompt: "prompts/architect.md" },
87
- ],
88
- };
89
- const WORKFLOW_WITH_REVIEWER_LOOP = {
90
- kind: "agent",
91
- name: "full",
92
- phases: [
93
- { name: "phase_0", type: "context" },
94
- { name: "executor", type: "agent", prompt: "prompts/executor.md" },
95
- {
96
- name: "reviewer",
97
- type: "agent",
98
- prompt: "prompts/reviewer.md",
99
- loop: {
100
- max_cycles: 2,
101
- on_request_changes: {
102
- fix_prompt: "prompts/fix.md",
103
- re_review_prompt: "prompts/re-reviewer.md",
104
- },
105
- },
106
- },
107
- { name: "pr", type: "agent", prompt: "prompts/pr.md", on_success: { set_phase: "complete" } },
108
- ],
109
- };
110
- describe("runWorkflow — basic phase execution", () => {
111
- beforeEach(() => {
112
- vi.clearAllMocks();
113
- });
114
- it("executes context phase without calling executeAgent", async () => {
115
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
116
- const result = await runWorkflow({
117
- kind: "agent",
118
- name: "ctx-only",
119
- phases: [{ name: "phase_0", type: "context" }],
120
- }, BASE_CTX, {}, {});
121
- expect(mockExecuteAgent).not.toHaveBeenCalled();
122
- expect(result.phases).toHaveLength(1);
123
- expect(result.phases[0].phase).toBe("phase_0");
124
- expect(result.phases[0].success).toBe(true);
125
- });
126
- // Regression: a fresh workflow row has currentPhase initialized to
127
- // phases[0].name. The runner's resume logic must not interpret that as
128
- // "phase_0 already completed" and skip it — phase_history is empty on
129
- // a fresh run. Symptom in prod: explore workflow's context phase was
130
- // silently skipped, so phase_history stayed [] and downstream failures
131
- // surfaced with current_phase=phase_0 even though phase_0 never ran.
132
- it("runs the first phase on a fresh run despite currentPhase=phase_0 in DB", async () => {
133
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
134
- const db = makeMockDb("phase_0");
135
- const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {}, db, undefined, undefined, "wf-fresh-1");
136
- expect(result.success).toBe(true);
137
- // Context phase ran (persistPhase fired) and both agent phases ran.
138
- expect(result.phases.map((p) => p.phase)).toEqual(["phase_0", "architect", "executor"]);
139
- expect(db.runs.appendPhase).toHaveBeenCalledWith("wf-fresh-1", "phase_0", expect.objectContaining({ phase: "phase_0", success: true }));
140
- });
141
- it("executes agent phases in order", async () => {
142
- const calls = [];
143
- mockExecuteAgent.mockImplementation(async (prompt) => {
144
- calls.push(prompt);
145
- return makeSuccessResult();
146
- });
147
- await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
148
- // Two agent phases: architect and executor
149
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
150
- // The prompts contain the template path (mocked as TEMPLATE:path)
151
- expect(calls[0]).toContain("prompts/architect.md");
152
- expect(calls[1]).toContain("prompts/executor.md");
153
- });
154
- it("returns success=true when all phases pass", async () => {
155
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
156
- const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
157
- expect(result.success).toBe(true);
158
- });
159
- it("posts a phase's own output to postComment via on_success", async () => {
160
- // Regression: a phase referencing its OWN output_var in on_success (e.g.
161
- // the answer workflow's `on_success: "{{answerResult}}"`, which delivers the
162
- // answer to a Slack thread / issue comment) must render to the real output,
163
- // not an empty string — outputVars are now injected into the done-step
164
- // render context rather than only after execute() returns.
165
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("the full answer text"));
166
- const comments = [];
167
- const def = {
168
- kind: "agent",
169
- name: "answer",
170
- phases: [
171
- { name: "phase_0", type: "context" },
172
- {
173
- name: "answer",
174
- type: "agent",
175
- prompt: "prompts/answer.md",
176
- output_var: "answerResult",
177
- messages: { on_success: "{{answerResult}}" },
178
- },
179
- ],
180
- };
181
- await runWorkflow(def, BASE_CTX, {}, {
182
- postComment: async (msg) => { comments.push(msg); },
183
- });
184
- expect(comments).toContain("the full answer text");
185
- });
186
- it("returns success=false and stops on phase failure", async () => {
187
- mockExecuteAgent
188
- .mockResolvedValueOnce(makeSuccessResult("architect done"))
189
- .mockResolvedValueOnce(makeFailResult("executor exploded"));
190
- const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
191
- expect(result.success).toBe(false);
192
- // Only two phases executed (phase_0 + architect + executor)
193
- // phase_0 = context, architect = success, executor = fail → stops
194
- const names = result.phases.map((p) => p.phase);
195
- expect(names).toContain("architect");
196
- expect(names).toContain("executor");
197
- expect(names).not.toContain("pr");
198
- });
199
- });
200
- describe("runWorkflow — guardrails on_output rules", () => {
201
- beforeEach(() => {
202
- vi.clearAllMocks();
203
- });
204
- it("fails workflow when guardrails output contains BLOCKED", async () => {
205
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("BLOCKED — no test framework"));
206
- const comments = [];
207
- const result = await runWorkflow(WORKFLOW_WITH_GUARDRAILS, BASE_CTX, {}, { postComment: async (msg) => { comments.push(msg); } });
208
- expect(result.success).toBe(false);
209
- expect(comments.some((c) => c.includes("BLOCKED"))).toBe(true);
210
- // architect's agent must not have run — only guardrails was dispatched.
211
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
212
- // Under the unified scheduler a failed phase cascades downstream as a skip,
213
- // recorded in phases[] rather than omitted.
214
- const architect = result.phases.find((p) => p.phase === "architect");
215
- expect(architect?.output).toContain("Skipped");
216
- });
217
- it("bypasses BLOCKED for bootstrap tasks (by label)", async () => {
218
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("BLOCKED — no test framework"));
219
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("READY"));
220
- const ctx = { ...BASE_CTX, issueLabels: ["lastlight:bootstrap"] };
221
- // First call = guardrails returns BLOCKED, second = architect succeeds
222
- mockExecuteAgent
223
- .mockResolvedValueOnce(makeSuccessResult("BLOCKED"))
224
- .mockResolvedValueOnce(makeSuccessResult("architect plan"));
225
- const result = await runWorkflow(WORKFLOW_WITH_GUARDRAILS, ctx, {}, {});
226
- // Even though guardrails returned BLOCKED, we bypass it because of the label
227
- expect(result.phases.map((p) => p.phase)).toContain("architect");
228
- });
229
- it("bypasses BLOCKED when unless_title_matches regex hits", async () => {
230
- const workflow = {
231
- kind: "agent",
232
- name: "title-bypass",
233
- phases: [
234
- { name: "phase_0", type: "context" },
235
- {
236
- name: "guardrails",
237
- type: "agent",
238
- prompt: "prompts/guardrails.md",
239
- on_output: {
240
- contains_BLOCKED: {
241
- action: "fail",
242
- message: "blocked",
243
- unless_title_matches: "^guardrails:",
244
- },
245
- },
246
- },
247
- { name: "architect", type: "agent", prompt: "prompts/architect.md" },
248
- ],
249
- };
250
- const ctx = { ...BASE_CTX, issueTitle: "guardrails: add test framework" };
251
- mockExecuteAgent
252
- .mockResolvedValueOnce(makeSuccessResult("BLOCKED"))
253
- .mockResolvedValueOnce(makeSuccessResult("architect plan"));
254
- const result = await runWorkflow(workflow, ctx, {}, {});
255
- expect(result.phases.map((p) => p.phase)).toContain("architect");
256
- });
257
- it("continues normally when guardrails returns READY", async () => {
258
- mockExecuteAgent
259
- .mockResolvedValueOnce(makeSuccessResult("READY — all guardrails pass"))
260
- .mockResolvedValueOnce(makeSuccessResult("architect plan"));
261
- const result = await runWorkflow(WORKFLOW_WITH_GUARDRAILS, BASE_CTX, {}, {});
262
- expect(result.success).toBe(true);
263
- expect(result.phases.map((p) => p.phase)).toContain("architect");
264
- });
265
- });
266
- describe("runWorkflow — reviewer loop", () => {
267
- beforeEach(() => {
268
- vi.clearAllMocks();
269
- });
270
- it("approves on first review — no fix loop", async () => {
271
- mockExecuteAgent
272
- .mockResolvedValueOnce(makeSuccessResult("executor done")) // executor
273
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: APPROVED\nLooks great!")) // reviewer
274
- .mockResolvedValueOnce(makeSuccessResult("PR #7 created")); // pr
275
- const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {});
276
- expect(result.success).toBe(true);
277
- // executor + reviewer + pr = 3 agent calls
278
- expect(mockExecuteAgent).toHaveBeenCalledTimes(3);
279
- expect(result.prNumber).toBe(7);
280
- });
281
- it("runs one fix cycle on REQUEST_CHANGES then APPROVED", async () => {
282
- mockExecuteAgent
283
- .mockResolvedValueOnce(makeSuccessResult("executor done")) // executor
284
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES\nFix the bug")) // reviewer cycle 1
285
- .mockResolvedValueOnce(makeSuccessResult("fixed")) // fix_loop_1
286
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: APPROVED\nAll fixed")) // re-review
287
- .mockResolvedValueOnce(makeSuccessResult("PR #8 created")); // pr
288
- const phases = [];
289
- const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {
290
- onPhaseStart: async (p) => { phases.push(p); },
291
- });
292
- expect(result.success).toBe(true);
293
- expect(phases).toContain("reviewer_fix_1");
294
- expect(result.prNumber).toBe(8);
295
- });
296
- it("stops after max_cycles when reviewer keeps requesting changes", async () => {
297
- mockExecuteAgent
298
- .mockResolvedValueOnce(makeSuccessResult("executor done")) // executor
299
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES")) // reviewer cycle 1
300
- .mockResolvedValueOnce(makeSuccessResult("fixed 1")) // fix_loop_1
301
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES")) // re-review cycle 2
302
- .mockResolvedValueOnce(makeSuccessResult("fixed 2")) // fix_loop_2
303
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: REQUEST_CHANGES")) // re-review cycle 3 (max hit)
304
- .mockResolvedValueOnce(makeSuccessResult("PR #9 created")); // pr
305
- const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {});
306
- // Should proceed to PR after max cycles
307
- expect(result.prNumber).toBe(9);
308
- });
309
- it("uses fallback verdict detection when VERDICT: marker is missing", async () => {
310
- mockExecuteAgent
311
- .mockResolvedValueOnce(makeSuccessResult("executor done"))
312
- .mockResolvedValueOnce(makeSuccessResult("APPROVED — code looks fine")) // no marker
313
- .mockResolvedValueOnce(makeSuccessResult("PR #10 created"));
314
- const result = await runWorkflow(WORKFLOW_WITH_REVIEWER_LOOP, BASE_CTX, {}, {});
315
- expect(result.success).toBe(true);
316
- expect(result.prNumber).toBe(10);
317
- });
318
- });
319
- const WORKFLOW_WITH_APPROVAL_GATE = {
320
- kind: "agent",
321
- name: "gated",
322
- phases: [
323
- { name: "phase_0", type: "context" },
324
- { name: "architect", type: "agent", prompt: "prompts/architect.md", approval_gate: "post_architect" },
325
- { name: "executor", type: "agent", prompt: "prompts/executor.md" },
326
- { name: "pr", type: "agent", prompt: "prompts/pr.md", on_success: { set_phase: "complete" } },
327
- ],
328
- };
329
- /**
330
- * Minimal StateDb mock providing the methods used by runWorkflow.
331
- * currentPhase controls what getWorkflowRun returns (simulating DB state after
332
- * the orchestrator updates it prior to calling runWorkflow).
333
- */
334
- function makeMockDb(currentPhase = "phase_0") {
335
- let phase = currentPhase;
336
- // Mirror prod: every `updateWorkflowPhase` call appends to phase_history.
337
- // Tests that pass a non-"phase_0" `currentPhase` are simulating a resumed
338
- // run, so seed history with one entry so the runner's resume detection
339
- // kicks in (it requires phaseHistory.length > 0).
340
- const phaseHistory = currentPhase !== "phase_0"
341
- ? [{ phase: currentPhase, timestamp: new Date().toISOString(), success: true }]
342
- : [];
343
- const appendPhase = vi.fn((_id, newPhase, entry) => {
344
- phase = newPhase;
345
- if (entry)
346
- phaseHistory.push(entry);
347
- });
348
- return {
349
- runs: {
350
- getRun: vi.fn(() => ({ currentPhase: phase, phaseHistory, status: "running" })),
351
- appendPhase,
352
- pauseForApproval: vi.fn(),
353
- mergeScratch: vi.fn(),
354
- finishRun: vi.fn(),
355
- setRunning: vi.fn(),
356
- setPaused: vi.fn(),
357
- },
358
- approvals: {
359
- create: vi.fn(),
360
- getPendingForWorkflow: vi.fn(() => null),
361
- },
362
- executions: {
363
- shouldRunPhase: vi.fn(() => "run"),
364
- recordStart: vi.fn(),
365
- recordFinish: vi.fn(),
366
- recordSkippedPhase: vi.fn(),
367
- recordOutputText: vi.fn(),
368
- getExecutionOutput: vi.fn(() => null),
369
- getPhaseOutput: vi.fn(() => null),
370
- recordSessionId: vi.fn(),
371
- markStaleAsFailed: vi.fn(),
372
- markLatestAsFailed: vi.fn(),
373
- },
374
- };
375
- }
376
- describe("runWorkflow — requires_sandbox gate", () => {
377
- const GATED_WORKFLOW = {
378
- kind: "agent",
379
- name: "gated",
380
- phases: [
381
- { name: "phase_0", type: "context" },
382
- {
383
- name: "demo",
384
- type: "agent",
385
- prompt: "prompts/demo.md",
386
- requires_sandbox: "docker",
387
- messages: { on_skipped_done: "Demo skipped — no docker sandbox here." },
388
- },
389
- ],
390
- };
391
- beforeEach(() => {
392
- vi.clearAllMocks();
393
- });
394
- it("skips a docker-gated phase as a non-failing skip on a non-docker backend", async () => {
395
- const db = makeMockDb();
396
- // No `sandbox` on the config → active backend resolves to gondolin.
397
- const result = await runWorkflow(GATED_WORKFLOW, BASE_CTX, {}, {}, db);
398
- // The gated phase never ran...
399
- expect(mockExecuteAgent).not.toHaveBeenCalled();
400
- // ...the run did not fail...
401
- expect(result.success).toBe(true);
402
- // ...the phase was recorded as a non-failing skip...
403
- const demo = result.phases.find((p) => p.phase === "demo");
404
- expect(demo?.success).toBe(true);
405
- // ...and it landed in the executions ledger as a skip.
406
- expect(db.executions.recordSkippedPhase).toHaveBeenCalledWith("gated:demo", expect.any(String), undefined, expect.any(String));
407
- });
408
- it("runs a docker-gated phase when the active backend is docker", async () => {
409
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
410
- const result = await runWorkflow(GATED_WORKFLOW, BASE_CTX, { sandbox: "docker" }, {});
411
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
412
- expect(result.success).toBe(true);
413
- });
414
- // A `sandbox_image: qa` phase needs the browser-QA image built on the host.
415
- // The mocked child_process has no `execFileSync`, so `qaImageAvailable()`
416
- // throws → false: the phase must skip even on the docker backend rather than
417
- // try (and fail) to spawn a sandbox from a non-existent image.
418
- const QA_WORKFLOW = {
419
- kind: "agent",
420
- name: "qa",
421
- phases: [
422
- { name: "phase_0", type: "context" },
423
- {
424
- name: "browser",
425
- type: "agent",
426
- prompt: "prompts/browser.md",
427
- requires_sandbox: "docker",
428
- sandbox_image: "qa",
429
- messages: { on_skipped_done: "Browser QA skipped — image not built." },
430
- },
431
- ],
432
- };
433
- it("skips a sandbox_image:qa phase on docker when the QA image isn't built", async () => {
434
- const db = makeMockDb();
435
- const result = await runWorkflow(QA_WORKFLOW, BASE_CTX, { sandbox: "docker" }, {}, db);
436
- expect(mockExecuteAgent).not.toHaveBeenCalled();
437
- expect(result.success).toBe(true);
438
- const browser = result.phases.find((p) => p.phase === "browser");
439
- expect(browser?.success).toBe(true);
440
- expect(browser?.output).toMatch(/lastlight-sandbox-qa:latest/);
441
- expect(db.executions.recordSkippedPhase).toHaveBeenCalledWith("qa:browser", expect.any(String), undefined, expect.any(String));
442
- });
443
- });
444
- describe("runWorkflow — final_message + synthesize (verify/qa-test shape)", () => {
445
- beforeEach(() => {
446
- vi.clearAllMocks();
447
- });
448
- // Mirrors verify.yaml: a gated browser phase that skips off-docker, and a
449
- // `synthesize` phase that depends ONLY on the text phase so it still runs
450
- // (after the gated phase, by declaration order) and folds the outputs into
451
- // `final_message` → the single comment's footer.
452
- const VERIFY_LIKE = {
453
- kind: "verify",
454
- name: "verify",
455
- status_checklist: true,
456
- final_message: "{{synthesisResult}}",
457
- phases: [
458
- { name: "phase_0", type: "context", depends_on: [] },
459
- {
460
- name: "verify",
461
- type: "agent",
462
- prompt: "prompts/verify.md",
463
- depends_on: ["phase_0"],
464
- output_var: "verifyResult",
465
- messages: { on_success: "Text verification complete." },
466
- },
467
- {
468
- name: "verify_browser",
469
- type: "agent",
470
- prompt: "prompts/verify-browser.md",
471
- depends_on: ["verify"],
472
- requires_sandbox: "docker",
473
- sandbox_image: "qa",
474
- output_var: "verifyBrowserResult",
475
- messages: { on_skipped_done: "Browser QA unavailable on this host." },
476
- },
477
- {
478
- name: "synthesize",
479
- type: "agent",
480
- prompt: "prompts/verify-synth.md",
481
- depends_on: ["verify"],
482
- output_var: "synthesisResult",
483
- messages: { on_success: "Verdict posted below." },
484
- },
485
- ],
486
- };
487
- function fakeReporter(sink) {
488
- return {
489
- start: async (_m) => { },
490
- step: async (k, s) => { sink.steps.push([k, s]); },
491
- insertStep: async (_st) => { },
492
- note: async () => { },
493
- footer: async (m) => { sink.footer = m; },
494
- noteTerminal: async () => { },
495
- };
496
- }
497
- it("synthesizes into the checklist footer (single comment) when the browser phase is gated out", async () => {
498
- // gondolin backend (no `sandbox`) → verify_browser skips. Only verify +
499
- // synthesize call the agent.
500
- mockExecuteAgent
501
- .mockResolvedValueOnce(makeSuccessResult("text verdict body")) // verify
502
- .mockResolvedValueOnce(makeSuccessResult("## CONFIRMED\nfinal verdict")); // synthesize
503
- const sink = { steps: [] };
504
- const comments = [];
505
- const result = await runWorkflow(VERIFY_LIKE, BASE_CTX, {}, {
506
- reporter: fakeReporter(sink),
507
- postComment: async (m) => { comments.push(m); },
508
- }, makeMockDb());
509
- expect(result.success).toBe(true);
510
- // verify + synthesize ran; verify_browser was skipped (no docker).
511
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
512
- expect(result.phases.map((p) => p.phase)).toContain("synthesize");
513
- // The synthesized verdict lands in the footer — NOT as a standalone comment.
514
- expect(sink.footer).toBe("## CONFIRMED\nfinal verdict");
515
- expect(comments).toHaveLength(0);
516
- // Per-phase progress went to checklist steps, not comments.
517
- expect(sink.steps).toContainEqual(["verify", "done"]);
518
- expect(sink.steps).toContainEqual(["synthesize", "done"]);
519
- });
520
- it("renders final_message to a single postComment in the legacy (no-reporter) path", async () => {
521
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("the synthesized verdict"));
522
- const def = {
523
- kind: "agent",
524
- name: "fin",
525
- final_message: "{{r}}",
526
- phases: [
527
- { name: "phase_0", type: "context" },
528
- { name: "a", type: "agent", prompt: "prompts/a.md", output_var: "r" },
529
- ],
530
- };
531
- const comments = [];
532
- const result = await runWorkflow(def, BASE_CTX, {}, {
533
- postComment: async (m) => { comments.push(m); },
534
- });
535
- expect(result.success).toBe(true);
536
- expect(comments).toContain("the synthesized verdict");
537
- });
538
- });
539
- describe("runWorkflow — approval gate", () => {
540
- beforeEach(() => {
541
- vi.clearAllMocks();
542
- });
543
- it("pauses at post_architect gate and does not run executor", async () => {
544
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("architect plan done"));
545
- const db = makeMockDb("phase_0");
546
- const approvalConfig = { post_architect: true };
547
- const result = await runWorkflow(WORKFLOW_WITH_APPROVAL_GATE, BASE_CTX, {}, {}, db, undefined, approvalConfig, "wf-gate-1");
548
- expect(result.paused).toBe(true);
549
- expect(result.success).toBe(true);
550
- // Only architect ran — executor and pr were not reached
551
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
552
- expect(result.phases.map((p) => p.phase)).not.toContain("executor");
553
- });
554
- it("resumes past an approved gate via the executions ledger (architect already done)", async () => {
555
- // Ledger-driven resume: architect's execution row is success=1 ("done"),
556
- // so runPhase skips it and the gate isn't re-hit — executor + pr run.
557
- const db = makeMockDb("architect");
558
- vi.mocked(db.executions.shouldRunPhase).mockImplementation((skill) => skill === "gated:architect" ? "done" : "run");
559
- const approvalConfig = { post_architect: true };
560
- mockExecuteAgent
561
- .mockResolvedValueOnce(makeSuccessResult("executor done"))
562
- .mockResolvedValueOnce(makeSuccessResult("PR #5 created"));
563
- const result = await runWorkflow(WORKFLOW_WITH_APPROVAL_GATE, BASE_CTX, {}, {}, db, undefined, approvalConfig, "wf-gate-1");
564
- // Not paused — architect was deduped (done), executor and pr ran.
565
- expect(result.paused).toBeUndefined();
566
- expect(result.success).toBe(true);
567
- expect(result.prNumber).toBe(5);
568
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
569
- });
570
- it("pauses at a custom-named gate when that gate is enabled", async () => {
571
- const workflow = {
572
- kind: "agent",
573
- name: "custom-gate",
574
- phases: [
575
- { name: "phase_0", type: "context" },
576
- { name: "plan", type: "agent", prompt: "prompts/plan.md", approval_gate: "post_plan" },
577
- { name: "build", type: "agent", prompt: "prompts/build.md" },
578
- ],
579
- };
580
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("plan done"));
581
- const db = makeMockDb("phase_0");
582
- const result = await runWorkflow(workflow, BASE_CTX, {}, {}, db, undefined, { post_plan: true }, "wf-custom-1");
583
- expect(result.paused).toBe(true);
584
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
585
- });
586
- });
587
- describe("runWorkflow — callbacks", () => {
588
- beforeEach(() => {
589
- vi.clearAllMocks();
590
- });
591
- it("calls onPhaseStart and onPhaseEnd for each phase", async () => {
592
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
593
- const started = [];
594
- const ended = [];
595
- const callbacks = {
596
- onPhaseStart: async (p) => { started.push(p); },
597
- onPhaseEnd: async (p) => { ended.push(p); },
598
- };
599
- await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, callbacks);
600
- expect(started).toContain("phase_0");
601
- expect(started).toContain("architect");
602
- expect(started).toContain("executor");
603
- expect(ended).toContain("architect");
604
- expect(ended).toContain("executor");
605
- });
606
- it("renders phase.messages.on_failure through the template engine", async () => {
607
- const workflow = {
608
- kind: "agent",
609
- name: "fail-msg",
610
- phases: [
611
- { name: "phase_0", type: "context" },
612
- { name: "architect", type: "agent", prompt: "prompts/architect.md" },
613
- {
614
- name: "executor",
615
- type: "agent",
616
- prompt: "prompts/executor.md",
617
- messages: { on_failure: "**{{issueTitle}}** failed — aborting" },
618
- },
619
- ],
620
- };
621
- mockExecuteAgent
622
- .mockResolvedValueOnce(makeSuccessResult())
623
- .mockResolvedValueOnce(makeFailResult("connection timeout"));
624
- const comments = [];
625
- await runWorkflow(workflow, BASE_CTX, {}, {
626
- postComment: async (msg) => { comments.push(msg); },
627
- });
628
- expect(comments).toContain("**Add Rate Limiter** failed — aborting");
629
- });
630
- it("silently skips notification when no on_failure template is set", async () => {
631
- mockExecuteAgent
632
- .mockResolvedValueOnce(makeSuccessResult())
633
- .mockResolvedValueOnce(makeFailResult("connection timeout"));
634
- const comments = [];
635
- await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {
636
- postComment: async (msg) => { comments.push(msg); },
637
- });
638
- expect(comments).toEqual([]);
639
- });
640
- });
641
- // until_bash now runs in the sandbox via executeCommand; exit code maps to
642
- // ExecutionResult.success (exit 0 → success: true).
643
- const cmdOk = () => ({ success: true, output: "", turns: 0, durationMs: 1 });
644
- const cmdFail = () => ({ success: false, output: "", error: "command exited 1", turns: 0, durationMs: 1 });
645
- const WORKFLOW_WITH_GENERIC_LOOP = {
646
- kind: "agent",
647
- name: "loop-test",
648
- phases: [
649
- { name: "phase_0", type: "context" },
650
- {
651
- name: "implement",
652
- type: "agent",
653
- prompt: "prompts/implement.md",
654
- generic_loop: {
655
- max_iterations: 5,
656
- until_bash: "npm test",
657
- interactive: false,
658
- fresh_context: false,
659
- },
660
- },
661
- ],
662
- };
663
- describe("runWorkflow — generic loop node", () => {
664
- beforeEach(() => {
665
- vi.clearAllMocks();
666
- });
667
- it("completes on first iteration when until_bash exits 0", async () => {
668
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("tests pass"));
669
- mockExecuteCommand.mockResolvedValueOnce(cmdOk()); // exit 0
670
- const started = [];
671
- const result = await runWorkflow(WORKFLOW_WITH_GENERIC_LOOP, BASE_CTX, {}, {
672
- onPhaseStart: async (p) => { started.push(p); },
673
- });
674
- expect(result.success).toBe(true);
675
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
676
- expect(started).toContain("implement_iter_1");
677
- expect(started).not.toContain("implement_iter_2");
678
- });
679
- it("iterates when until_bash exits non-zero then completes on exit 0", async () => {
680
- mockExecuteAgent
681
- .mockResolvedValueOnce(makeSuccessResult("attempt 1"))
682
- .mockResolvedValueOnce(makeSuccessResult("attempt 2"));
683
- mockExecuteCommand
684
- .mockResolvedValueOnce(cmdFail()) // iteration 1 fails
685
- .mockResolvedValueOnce(cmdOk()); // iteration 2 passes
686
- const started = [];
687
- const result = await runWorkflow(WORKFLOW_WITH_GENERIC_LOOP, BASE_CTX, {}, {
688
- onPhaseStart: async (p) => { started.push(p); },
689
- });
690
- expect(result.success).toBe(true);
691
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
692
- expect(started).toContain("implement_iter_1");
693
- expect(started).toContain("implement_iter_2");
694
- expect(started).not.toContain("implement_iter_3");
695
- });
696
- it("stops at max_iterations when condition is never met", async () => {
697
- const workflow = {
698
- kind: "agent",
699
- name: "max-iter-msg",
700
- phases: [
701
- { name: "phase_0", type: "context" },
702
- {
703
- name: "implement",
704
- type: "agent",
705
- prompt: "prompts/implement.md",
706
- messages: {
707
- on_failure: "{{phase}} stopped at {{maxIterations}} max iterations",
708
- },
709
- generic_loop: {
710
- max_iterations: 5,
711
- until_bash: "npm test",
712
- interactive: false,
713
- fresh_context: false,
714
- },
715
- },
716
- ],
717
- };
718
- mockExecuteCommand.mockResolvedValue(cmdFail());
719
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("still failing"));
720
- const comments = [];
721
- const result = await runWorkflow(workflow, BASE_CTX, {}, {
722
- postComment: async (msg) => { comments.push(msg); },
723
- });
724
- expect(result.success).toBe(true); // workflow itself doesn't fail — just loop exhausted
725
- expect(mockExecuteAgent).toHaveBeenCalledTimes(5); // max_iterations = 5
726
- expect(comments.some((c) => c.includes("max iterations"))).toBe(true);
727
- });
728
- it("completes immediately when until expression is true on first output", async () => {
729
- const workflow = {
730
- kind: "agent",
731
- name: "expr-loop",
732
- phases: [
733
- { name: "phase_0", type: "context" },
734
- {
735
- name: "check",
736
- type: "agent",
737
- prompt: "prompts/check.md",
738
- generic_loop: {
739
- max_iterations: 3,
740
- until: "output.contains('PASS')",
741
- interactive: false,
742
- fresh_context: false,
743
- },
744
- },
745
- ],
746
- };
747
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("All tests PASS"));
748
- const result = await runWorkflow(workflow, BASE_CTX, {}, {});
749
- expect(result.success).toBe(true);
750
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
751
- });
752
- it("iterates when until expression is false then stops when true", async () => {
753
- const workflow = {
754
- kind: "agent",
755
- name: "expr-loop-2",
756
- phases: [
757
- { name: "phase_0", type: "context" },
758
- {
759
- name: "refine",
760
- type: "agent",
761
- prompt: "prompts/refine.md",
762
- generic_loop: {
763
- max_iterations: 4,
764
- until: "output.contains('DONE')",
765
- interactive: false,
766
- fresh_context: false,
767
- },
768
- },
769
- ],
770
- };
771
- mockExecuteAgent
772
- .mockResolvedValueOnce(makeSuccessResult("still working"))
773
- .mockResolvedValueOnce(makeSuccessResult("DONE"));
774
- const result = await runWorkflow(workflow, BASE_CTX, {}, {});
775
- expect(result.success).toBe(true);
776
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
777
- });
778
- it("fresh_context: true does not pass previousOutput on subsequent iterations", async () => {
779
- const workflow = {
780
- kind: "agent",
781
- name: "fresh-ctx",
782
- phases: [
783
- { name: "phase_0", type: "context" },
784
- {
785
- name: "task",
786
- type: "agent",
787
- prompt: "prompts/task.md",
788
- generic_loop: {
789
- max_iterations: 3,
790
- until_bash: "npm test",
791
- interactive: false,
792
- fresh_context: true,
793
- },
794
- },
795
- ],
796
- };
797
- const capturedPrompts = [];
798
- mockExecuteAgent.mockImplementation(async (prompt) => {
799
- capturedPrompts.push(prompt);
800
- return makeSuccessResult("iteration output");
801
- });
802
- mockExecuteCommand
803
- .mockResolvedValueOnce(cmdFail())
804
- .mockResolvedValueOnce(cmdOk());
805
- await runWorkflow(workflow, BASE_CTX, {}, {});
806
- // Both prompts should NOT contain the first iteration's output
807
- // (fresh_context resets previousOutput each time)
808
- expect(capturedPrompts[1]).not.toContain("iteration output");
809
- });
810
- it("fresh_context: false passes previousOutput to subsequent iterations", async () => {
811
- const workflow = {
812
- kind: "agent",
813
- name: "accum-ctx",
814
- phases: [
815
- { name: "phase_0", type: "context" },
816
- {
817
- name: "build",
818
- type: "agent",
819
- prompt: "prompts/build.md",
820
- generic_loop: {
821
- max_iterations: 3,
822
- until_bash: "npm test",
823
- interactive: false,
824
- fresh_context: false,
825
- },
826
- },
827
- ],
828
- };
829
- // Make the loader return a template that uses {{previousOutput}} so we can
830
- // verify the context variable is passed through to the rendered prompt
831
- mockLoadPromptTemplate.mockImplementation(() => "Previous: {{previousOutput}}");
832
- const capturedPrompts = [];
833
- mockExecuteAgent.mockImplementation(async (prompt) => {
834
- capturedPrompts.push(prompt);
835
- return makeSuccessResult("iteration output ABC");
836
- });
837
- mockExecuteCommand
838
- .mockResolvedValueOnce(cmdFail())
839
- .mockResolvedValueOnce(cmdOk());
840
- await runWorkflow(workflow, BASE_CTX, {}, {});
841
- // Second prompt should include the previous iteration's output
842
- expect(capturedPrompts[1]).toContain("iteration output ABC");
843
- });
844
- it("interactive mode pauses workflow after first iteration", async () => {
845
- const workflow = {
846
- kind: "agent",
847
- name: "interactive-loop",
848
- phases: [
849
- { name: "phase_0", type: "context" },
850
- {
851
- name: "explore",
852
- type: "agent",
853
- prompt: "prompts/explore.md",
854
- generic_loop: {
855
- max_iterations: 3,
856
- until: "output.contains('FINAL')",
857
- interactive: true,
858
- gate_message: "Review iteration output before continuing",
859
- fresh_context: false,
860
- },
861
- },
862
- ],
863
- };
864
- // First iteration does not satisfy the until condition
865
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("progress so far"));
866
- const db = makeMockDb();
867
- const comments = [];
868
- const result = await runWorkflow(workflow, BASE_CTX, {}, { postComment: async (msg) => { comments.push(msg); } }, db, undefined, undefined, "wf-loop-1");
869
- expect(result.paused).toBe(true);
870
- expect(result.success).toBe(true);
871
- expect(db.runs.pauseForApproval).toHaveBeenCalled();
872
- expect(db.runs.pauseForApproval).toHaveBeenCalled();
873
- expect(comments.some((c) => c.includes("approval required"))).toBe(true);
874
- });
875
- it("fails workflow if an iteration agent call fails", async () => {
876
- mockExecuteCommand.mockResolvedValue(cmdFail());
877
- mockExecuteAgent.mockResolvedValueOnce(makeFailResult("oom killed"));
878
- const comments = [];
879
- const result = await runWorkflow(WORKFLOW_WITH_GENERIC_LOOP, BASE_CTX, {}, {
880
- postComment: async (msg) => { comments.push(msg); },
881
- });
882
- expect(result.success).toBe(false);
883
- expect(mockExecuteAgent).toHaveBeenCalledTimes(1);
884
- });
885
- it("treats until_bash containing {{ as non-zero exit (template injection guard)", async () => {
886
- const workflow = {
887
- kind: "agent",
888
- name: "injection-guard",
889
- phases: [
890
- { name: "phase_0", type: "context" },
891
- {
892
- name: "run",
893
- type: "agent",
894
- prompt: "prompts/run.md",
895
- generic_loop: {
896
- max_iterations: 1,
897
- until_bash: "echo {{secret}}",
898
- interactive: false,
899
- fresh_context: false,
900
- },
901
- },
902
- ],
903
- };
904
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("output"));
905
- // executeCommand should NOT be reached — validateShellCommand throws first.
906
- mockExecuteCommand.mockResolvedValue(cmdOk());
907
- const result = await runWorkflow(workflow, BASE_CTX, {}, {});
908
- // validateShellCommand throws, caught by runUntilBash, conditionMet stays false;
909
- // loop exhausts max_iterations=1 and the workflow completes (loop exhausted is not failure).
910
- expect(result.success).toBe(true);
911
- // executeCommand must NOT have been called with the mustache-containing command.
912
- expect(mockExecuteCommand).not.toHaveBeenCalled();
913
- });
914
- });
915
- // ── DAG workflow tests ────────────────────────────────────────────────────────
916
- const PARALLEL_WORKFLOW = {
917
- kind: "agent",
918
- name: "parallel-test",
919
- phases: [
920
- { name: "phase_0", type: "context" },
921
- { name: "architect", type: "agent", prompt: "prompts/architect.md", depends_on: ["phase_0"] },
922
- { name: "executor_a", type: "agent", prompt: "prompts/executor_a.md", depends_on: ["architect"] },
923
- { name: "executor_b", type: "agent", prompt: "prompts/executor_b.md", depends_on: ["architect"] },
924
- { name: "merge", type: "agent", prompt: "prompts/merge.md", depends_on: ["executor_a", "executor_b"] },
925
- ],
926
- };
927
- describe("runWorkflow — DAG parallel execution", () => {
928
- beforeEach(() => {
929
- vi.clearAllMocks();
930
- });
931
- it("routes to DAG execution when any phase has depends_on", async () => {
932
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
933
- const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
934
- expect(result.success).toBe(true);
935
- // architect + executor_a + executor_b + merge = 4 agent calls
936
- expect(mockExecuteAgent).toHaveBeenCalledTimes(4);
937
- });
938
- it("runs every ready node sequentially in declaration order", async () => {
939
- mockExecuteAgent.mockResolvedValue(makeSuccessResult("done"));
940
- const started = [];
941
- const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {
942
- onPhaseStart: async (p) => { started.push(p); },
943
- });
944
- expect(result.success).toBe(true);
945
- // Sequential, declaration order — the two independent executors no longer
946
- // run in parallel; the earliest-declared ready node goes first.
947
- expect(started).toEqual(["phase_0", "architect", "executor_a", "executor_b", "merge"]);
948
- });
949
- it("skips downstream nodes when upstream fails (all_success rule)", async () => {
950
- mockExecuteAgent
951
- .mockResolvedValueOnce(makeSuccessResult("architect done")) // architect
952
- .mockResolvedValueOnce(makeFailResult("executor_a exploded")) // executor_a
953
- .mockResolvedValue(makeSuccessResult("done")); // executor_b (and any others)
954
- const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
955
- // merge depends on both executor_a AND executor_b — executor_a failed, so merge is skipped
956
- const mergePhase = result.phases.find((p) => p.phase === "merge");
957
- // merge should be skipped (not executed by agent), indicated by success=true with skip output
958
- expect(mergePhase).toBeDefined();
959
- expect(mergePhase?.output).toContain("Skipped");
960
- // total agent calls: architect + executor_a + executor_b = 3 (merge agent not called)
961
- expect(mockExecuteAgent).toHaveBeenCalledTimes(3);
962
- });
963
- it("returns overall success=false if any phase failed", async () => {
964
- mockExecuteAgent
965
- .mockResolvedValueOnce(makeSuccessResult("architect done"))
966
- .mockResolvedValueOnce(makeFailResult("exploded"))
967
- .mockResolvedValue(makeSuccessResult("done"));
968
- const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
969
- expect(result.success).toBe(false);
970
- });
971
- it("all_done trigger rule: downstream runs even when upstream failed", async () => {
972
- const workflow = {
973
- kind: "agent",
974
- name: "all-done-test",
975
- phases: [
976
- { name: "step_a", type: "agent", prompt: "prompts/a.md", depends_on: [] },
977
- {
978
- name: "cleanup",
979
- type: "agent",
980
- prompt: "prompts/cleanup.md",
981
- depends_on: ["step_a"],
982
- trigger_rule: "all_done",
983
- },
984
- ],
985
- };
986
- mockExecuteAgent
987
- .mockResolvedValueOnce(makeFailResult("step_a failed"))
988
- .mockResolvedValueOnce(makeSuccessResult("cleanup done"));
989
- const result = await runWorkflow(workflow, BASE_CTX, {}, {});
990
- const phaseNames = result.phases.map((p) => p.phase);
991
- expect(phaseNames).toContain("cleanup");
992
- });
993
- it("sequential path is unchanged when no phase has depends_on", async () => {
994
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
995
- const result = await runWorkflow(SIMPLE_WORKFLOW, BASE_CTX, {}, {});
996
- expect(result.success).toBe(true);
997
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2); // architect + executor
998
- });
999
- it("uses ONE shared workspace (taskId) for every phase of a DAG run", async () => {
1000
- mockExecuteAgent.mockResolvedValue(makeSuccessResult());
1001
- await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
1002
- // Every executeAgent call carries the same taskId — the per-phase
1003
- // `${taskId}-${phaseName}` clones of the old DAG runner are gone.
1004
- const taskIds = mockExecuteAgent.mock.calls.map((c) => c[2].taskId);
1005
- expect(taskIds.length).toBe(4);
1006
- expect(new Set(taskIds)).toEqual(new Set([BASE_CTX.taskId]));
1007
- });
1008
- it("records skipped phases in the executions ledger", async () => {
1009
- mockExecuteAgent
1010
- .mockResolvedValueOnce(makeSuccessResult("architect done"))
1011
- .mockResolvedValueOnce(makeFailResult("executor_a exploded"))
1012
- .mockResolvedValue(makeSuccessResult("done"));
1013
- const db = makeMockDb("phase_0");
1014
- const recordSkipped = vi.fn();
1015
- db.executions.recordSkippedPhase = recordSkipped;
1016
- await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {}, db, undefined, undefined, "wf-skip-1");
1017
- // merge is skipped (executor_a failed) → one skip row in the ledger.
1018
- const skippedSkills = recordSkipped.mock.calls.map((c) => c[0]);
1019
- expect(skippedSkills).toContain("parallel-test:merge");
1020
- });
1021
- });
1022
- describe("runWorkflow — definition-driven resume + YAML messages", () => {
1023
- beforeEach(() => {
1024
- vi.clearAllMocks();
1025
- });
1026
- it("resumes a workflow whose phases have nothing to do with the build cycle", async () => {
1027
- const workflow = {
1028
- kind: "agent",
1029
- name: "custom",
1030
- phases: [
1031
- { name: "phase_0", type: "context" },
1032
- { name: "discover", type: "agent", prompt: "prompts/discover.md" },
1033
- { name: "plan", type: "agent", prompt: "prompts/plan.md" },
1034
- { name: "implement", type: "agent", prompt: "prompts/implement.md" },
1035
- { name: "ship", type: "agent", prompt: "prompts/ship.md" },
1036
- ],
1037
- };
1038
- // Ledger-driven resume: discover + plan are already done; implement + ship
1039
- // still need to run. The runner re-runs from the top and dedups the
1040
- // completed phases via shouldRunPhase.
1041
- const db = makeMockDb("plan");
1042
- const done = new Set(["custom:discover", "custom:plan"]);
1043
- vi.mocked(db.executions.shouldRunPhase).mockImplementation((skill) => done.has(skill) ? "done" : "run");
1044
- mockExecuteAgent
1045
- .mockResolvedValueOnce(makeSuccessResult("implement done"))
1046
- .mockResolvedValueOnce(makeSuccessResult("ship done"));
1047
- const result = await runWorkflow(workflow, BASE_CTX, {}, {}, db, undefined, undefined, "wf-custom-resume");
1048
- expect(result.success).toBe(true);
1049
- // Only implement + ship actually dispatched an agent — discover + plan deduped.
1050
- expect(mockExecuteAgent).toHaveBeenCalledTimes(2);
1051
- const startedSkills = vi.mocked(db.executions.recordStart).mock.calls.map((c) => c[0].skill);
1052
- expect(startedSkills).toContain("custom:implement");
1053
- expect(startedSkills).toContain("custom:ship");
1054
- expect(startedSkills).not.toContain("custom:discover");
1055
- expect(startedSkills).not.toContain("custom:plan");
1056
- });
1057
- it("renders phase.messages.on_start through the template engine", async () => {
1058
- const workflow = {
1059
- kind: "agent",
1060
- name: "msg-test",
1061
- phases: [
1062
- { name: "phase_0", type: "context" },
1063
- {
1064
- name: "step",
1065
- type: "agent",
1066
- prompt: "prompts/step.md",
1067
- messages: {
1068
- on_start: "starting {{step}} for #{{issueNumber}}",
1069
- on_success: "finished",
1070
- },
1071
- },
1072
- ],
1073
- };
1074
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("ok"));
1075
- const comments = [];
1076
- const ctx = { ...BASE_CTX, step: "step" };
1077
- await runWorkflow(workflow, ctx, {}, {
1078
- postComment: async (m) => { comments.push(m); },
1079
- });
1080
- expect(comments).toContain("starting step for #42");
1081
- expect(comments).toContain("finished");
1082
- });
1083
- it("uses the definition name as the dedup key prefix", async () => {
1084
- const workflow = {
1085
- kind: "agent",
1086
- name: "my-custom-workflow",
1087
- phases: [
1088
- { name: "phase_0", type: "context" },
1089
- { name: "solo", type: "agent", prompt: "prompts/solo.md" },
1090
- ],
1091
- };
1092
- mockExecuteAgent.mockResolvedValueOnce(makeSuccessResult("done"));
1093
- const db = makeMockDb("phase_0");
1094
- const shouldRunPhase = vi.mocked(db.executions.shouldRunPhase);
1095
- await runWorkflow(workflow, BASE_CTX, {}, {}, db, undefined, undefined, "wf-dedup-1");
1096
- expect(shouldRunPhase).toHaveBeenCalledWith("my-custom-workflow:solo", expect.any(String), "wf-dedup-1");
1097
- });
1098
- it("exposes reviewer loop output via output_var for downstream templates", async () => {
1099
- const workflow = {
1100
- kind: "agent",
1101
- name: "review-then-summarize",
1102
- phases: [
1103
- { name: "phase_0", type: "context" },
1104
- { name: "executor", type: "agent", prompt: "prompts/executor.md" },
1105
- {
1106
- name: "reviewer",
1107
- type: "agent",
1108
- prompt: "prompts/reviewer.md",
1109
- output_var: "review",
1110
- loop: {
1111
- max_cycles: 1,
1112
- on_request_changes: {
1113
- fix_prompt: "prompts/fix.md",
1114
- re_review_prompt: "prompts/re-reviewer.md",
1115
- },
1116
- },
1117
- },
1118
- { name: "summary", type: "agent", prompt: "prompts/summary.md" },
1119
- ],
1120
- };
1121
- mockLoadPromptTemplate.mockImplementation((p) => p === "prompts/summary.md" ? "approved={{review.approved}} cycles={{review.cycles}}" : `TEMPLATE:${p}`);
1122
- mockExecuteAgent
1123
- .mockResolvedValueOnce(makeSuccessResult("executor"))
1124
- .mockResolvedValueOnce(makeSuccessResult("VERDICT: APPROVED"))
1125
- .mockResolvedValueOnce(makeSuccessResult("summary ok"));
1126
- await runWorkflow(workflow, BASE_CTX, {}, {});
1127
- const summaryCall = mockExecuteAgent.mock.calls[2][0];
1128
- expect(summaryCall).toContain("approved=true");
1129
- expect(summaryCall).toContain("cycles=0");
1130
- });
1131
- });
1132
- describe("runWorkflow — DAG unexpected throws", () => {
1133
- beforeEach(() => {
1134
- vi.clearAllMocks();
1135
- });
1136
- it("unexpected throw in executeAgent marks phase failed and overall success=false", async () => {
1137
- // architect succeeds, executor_a throws (unexpected exception, not a normal fail result)
1138
- mockExecuteAgent
1139
- .mockResolvedValueOnce(makeSuccessResult("architect done")) // architect
1140
- .mockRejectedValueOnce(new Error("OOM: process killed")) // executor_a throws
1141
- .mockResolvedValue(makeSuccessResult("done")); // executor_b and any others
1142
- const result = await runWorkflow(PARALLEL_WORKFLOW, BASE_CTX, {}, {});
1143
- // Overall workflow must report failure — not silently succeed
1144
- expect(result.success).toBe(false);
1145
- // The failed phase must appear in phases[] with success=false
1146
- const failedPhase = result.phases.find((p) => p.phase === "executor_a");
1147
- expect(failedPhase).toBeDefined();
1148
- expect(failedPhase?.success).toBe(false);
1149
- // merge depends on both executor_a and executor_b — executor_a failed, so merge is skipped
1150
- const mergePhase = result.phases.find((p) => p.phase === "merge");
1151
- expect(mergePhase?.output).toContain("Skipped");
1152
- });
1153
- });
1154
- describe("gitAccessProfileForWorkflow — security workflows", () => {
1155
- it("returns issues-write for security-review", () => {
1156
- expect(gitAccessProfileForWorkflow("security-review")).toBe("issues-write");
1157
- });
1158
- it("returns repo-write for security-feedback", () => {
1159
- expect(gitAccessProfileForWorkflow("security-feedback")).toBe("repo-write");
1160
- });
1161
- it("returns read for unknown workflow", () => {
1162
- expect(gitAccessProfileForWorkflow("unknown-workflow")).toBe("read");
1163
- });
1164
- });
1165
- describe("gitSandboxAccessForWorkflow — App PEM never enters the sandbox", () => {
1166
- // The github extension skips (pem-unreadable) instead of falling back to
1167
- // GITHUB_TOKEN when App creds are present but the PEM is unreadable in the
1168
- // sandbox. The harness mints a scoped token, so we never forward App creds.
1169
- it.each(["build", "pr-fix", "security-feedback", "pr-review", "issue-triage"])("sets allowMcpAppAuth=false for %s (even repo-write)", (wf) => {
1170
- const access = gitSandboxAccessForWorkflow(wf, "owner", "repo");
1171
- expect(access.allowMcpAppAuth).toBe(false);
1172
- });
1173
- });
1174
- //# sourceMappingURL=runner.test.js.map