@selesai/code 0.2.1 → 0.3.1

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 (284) hide show
  1. package/dist/config.d.ts +4 -15
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +5 -45
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/agent-session.d.ts +1 -0
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +8 -0
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/diagnostics.d.ts +1 -0
  10. package/dist/core/diagnostics.d.ts.map +1 -1
  11. package/dist/core/diagnostics.js.map +1 -1
  12. package/dist/core/model-registry.d.ts +4 -1
  13. package/dist/core/model-registry.d.ts.map +1 -1
  14. package/dist/core/model-registry.js +101 -51
  15. package/dist/core/model-registry.js.map +1 -1
  16. package/dist/core/package-manager.d.ts +10 -0
  17. package/dist/core/package-manager.d.ts.map +1 -1
  18. package/dist/core/package-manager.js +93 -8
  19. package/dist/core/package-manager.js.map +1 -1
  20. package/dist/core/package-manager.test.d.ts +2 -0
  21. package/dist/core/package-manager.test.d.ts.map +1 -0
  22. package/dist/core/package-manager.test.js +20 -0
  23. package/dist/core/package-manager.test.js.map +1 -0
  24. package/dist/core/resource-loader.d.ts +3 -0
  25. package/dist/core/resource-loader.d.ts.map +1 -1
  26. package/dist/core/resource-loader.js +10 -0
  27. package/dist/core/resource-loader.js.map +1 -1
  28. package/dist/core/settings-manager.d.ts +1 -0
  29. package/dist/core/settings-manager.d.ts.map +1 -1
  30. package/dist/core/settings-manager.js.map +1 -1
  31. package/dist/defaults/models.json +64 -113
  32. package/dist/extensions/caveman/caveman-instructions.cjs +39 -0
  33. package/dist/extensions/caveman/index.js +116 -0
  34. package/dist/extensions/caveman/package.json +8 -0
  35. package/dist/extensions/caveman/test/extension.test.js +185 -0
  36. package/dist/extensions/caveman/test/helpers.test.js +67 -0
  37. package/dist/extensions/copy-turn.ts +2 -2
  38. package/dist/extensions/pi-intercom/broker/broker.ts +2 -3
  39. package/dist/extensions/pi-intercom/broker/paths.ts +10 -5
  40. package/dist/extensions/pi-intercom/broker/spawn.ts +2 -3
  41. package/dist/extensions/pi-intercom/config.ts +2 -2
  42. package/dist/extensions/pi-intercom/index.ts +2 -2
  43. package/dist/extensions/pi-intercom/package.json +2 -2
  44. package/dist/extensions/pi-intercom/ui/compose.ts +3 -3
  45. package/dist/extensions/pi-intercom/ui/inline-message.ts +3 -3
  46. package/dist/extensions/pi-intercom/ui/session-list.ts +3 -3
  47. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +2 -2
  48. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +2 -1
  49. package/dist/extensions/pi-powerline-footer/index.ts +40 -47
  50. package/dist/extensions/pi-powerline-footer/package.json +2 -2
  51. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +10 -10
  52. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +1 -1
  53. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +2 -2
  54. package/dist/extensions/pi-powerline-footer/theme.ts +6 -6
  55. package/dist/extensions/pi-powerline-footer/tps.ts +1 -1
  56. package/dist/extensions/pi-powerline-footer/types.ts +7 -7
  57. package/dist/extensions/pi-powerline-footer/welcome.ts +23 -16
  58. package/dist/extensions/pi-powerline-footer/working-vibes.ts +52 -59
  59. package/dist/extensions/pi-rewind-hook/index.ts +1 -1
  60. package/dist/extensions/pi-rewind-hook/package.json +1 -1
  61. package/dist/extensions/pi-subagents/README.md +224 -52
  62. package/dist/extensions/pi-subagents/agents/architect.md +188 -0
  63. package/dist/extensions/pi-subagents/agents/builder.md +120 -0
  64. package/dist/extensions/pi-subagents/agents/commentator.md +131 -0
  65. package/dist/extensions/pi-subagents/agents/explorer.md +51 -0
  66. package/dist/extensions/pi-subagents/agents/recapper.md +21 -0
  67. package/dist/extensions/pi-subagents/install.mjs +93 -0
  68. package/dist/extensions/pi-subagents/package.json +4 -5
  69. package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
  70. package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
  71. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +76 -13
  72. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +179 -2
  73. package/dist/extensions/pi-subagents/src/agents/agent-memory.ts +254 -0
  74. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +11 -0
  75. package/dist/extensions/pi-subagents/src/agents/agents.ts +193 -19
  76. package/dist/extensions/pi-subagents/src/agents/chain-serializer.ts +27 -2
  77. package/dist/extensions/pi-subagents/src/extension/config.ts +27 -4
  78. package/dist/extensions/pi-subagents/src/extension/doctor.ts +1 -7
  79. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +3 -2
  80. package/dist/extensions/pi-subagents/src/extension/index.ts +73 -71
  81. package/dist/extensions/pi-subagents/src/extension/rpc.ts +369 -0
  82. package/dist/extensions/pi-subagents/src/extension/schemas.ts +54 -10
  83. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +200 -0
  84. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +21 -253
  85. package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +519 -0
  86. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +1 -1
  87. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +195 -105
  88. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +88 -2
  89. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +67 -10
  90. package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +34 -4
  91. package/dist/extensions/pi-subagents/src/runs/background/completion-batcher.ts +166 -0
  92. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +156 -1
  93. package/dist/extensions/pi-subagents/src/runs/background/fleet-view.ts +515 -0
  94. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +161 -44
  95. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +1 -2
  96. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +3 -2
  97. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +167 -6
  98. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +514 -0
  99. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +28 -1
  100. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +873 -204
  101. package/dist/extensions/pi-subagents/src/runs/background/wait.ts +353 -0
  102. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +123 -27
  103. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +174 -70
  104. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +569 -81
  105. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +45 -22
  106. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +3 -1
  107. package/dist/extensions/pi-subagents/src/runs/shared/dynamic-fanout.ts +2 -2
  108. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +172 -21
  109. package/dist/extensions/pi-subagents/src/runs/shared/model-scope.ts +128 -0
  110. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +89 -0
  111. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +50 -1
  112. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +35 -4
  113. package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +63 -45
  114. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +2 -0
  115. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +125 -5
  116. package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +74 -0
  117. package/dist/extensions/pi-subagents/src/runs/shared/turn-budget.ts +52 -0
  118. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +28 -5
  119. package/dist/extensions/pi-subagents/src/shared/artifacts.ts +16 -1
  120. package/dist/extensions/pi-subagents/src/shared/atomic-json.ts +15 -2
  121. package/dist/extensions/pi-subagents/src/shared/child-transcript.ts +212 -0
  122. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +133 -22
  123. package/dist/extensions/pi-subagents/src/shared/settings.ts +3 -1
  124. package/dist/extensions/pi-subagents/src/shared/types.ts +197 -4
  125. package/dist/extensions/pi-subagents/src/shared/utils.ts +108 -17
  126. package/dist/extensions/pi-subagents/src/slash/prompt-workflows.ts +330 -0
  127. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +135 -4
  128. package/dist/extensions/pi-subagents/src/tui/render.ts +32 -12
  129. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +94 -0
  130. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +2772 -0
  131. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +1107 -0
  132. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +463 -0
  133. package/dist/extensions/pi-subagents/test/integration/chain-clarify.test.ts +274 -0
  134. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +1535 -0
  135. package/dist/extensions/pi-subagents/test/integration/detect-error.test.ts +242 -0
  136. package/dist/extensions/pi-subagents/test/integration/doctor-executor.test.ts +128 -0
  137. package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +252 -0
  138. package/dist/extensions/pi-subagents/test/integration/foreground-result-size.test.ts +188 -0
  139. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +1306 -0
  140. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +1033 -0
  141. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +397 -0
  142. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +583 -0
  143. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +691 -0
  144. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +892 -0
  145. package/dist/extensions/pi-subagents/test/integration/session-tokens.test.ts +62 -0
  146. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +1824 -0
  147. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1284 -0
  148. package/dist/extensions/pi-subagents/test/integration/slash-live-state.test.ts +127 -0
  149. package/dist/extensions/pi-subagents/test/integration/template-resolution.test.ts +299 -0
  150. package/dist/extensions/pi-subagents/test/integration/top-level-async.test.ts +37 -0
  151. package/dist/extensions/pi-subagents/test/support/helpers.ts +189 -0
  152. package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +335 -0
  153. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +129 -0
  154. package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +184 -0
  155. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +285 -0
  156. package/dist/extensions/pi-subagents/test/support/register-loader.mjs +15 -0
  157. package/dist/extensions/pi-subagents/test/support/ts-loader.mjs +108 -0
  158. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +421 -0
  159. package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +213 -0
  160. package/dist/extensions/pi-subagents/test/unit/agent-eject-disable.test.ts +343 -0
  161. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +1199 -0
  162. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +556 -0
  163. package/dist/extensions/pi-subagents/test/unit/agent-memory.test.ts +420 -0
  164. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +579 -0
  165. package/dist/extensions/pi-subagents/test/unit/agent-scope.test.ts +20 -0
  166. package/dist/extensions/pi-subagents/test/unit/agent-selection.test.ts +88 -0
  167. package/dist/extensions/pi-subagents/test/unit/artifacts.test.ts +24 -0
  168. package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +86 -0
  169. package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +221 -0
  170. package/dist/extensions/pi-subagents/test/unit/async-permission-session.test.ts +98 -0
  171. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +285 -0
  172. package/dist/extensions/pi-subagents/test/unit/atomic-json.test.ts +112 -0
  173. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +206 -0
  174. package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +159 -0
  175. package/dist/extensions/pi-subagents/test/unit/chain-serializer.test.ts +229 -0
  176. package/dist/extensions/pi-subagents/test/unit/child-transcript.test.ts +225 -0
  177. package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +101 -0
  178. package/dist/extensions/pi-subagents/test/unit/completion-batcher.test.ts +228 -0
  179. package/dist/extensions/pi-subagents/test/unit/completion-dedupe.test.ts +36 -0
  180. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +152 -0
  181. package/dist/extensions/pi-subagents/test/unit/config-dir-runtime.test.ts +72 -0
  182. package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +327 -0
  183. package/dist/extensions/pi-subagents/test/unit/control-notices.test.ts +161 -0
  184. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +159 -0
  185. package/dist/extensions/pi-subagents/test/unit/dynamic-fanout.test.ts +217 -0
  186. package/dist/extensions/pi-subagents/test/unit/extra-agent-dirs.test.ts +88 -0
  187. package/dist/extensions/pi-subagents/test/unit/file-coalescer.test.ts +68 -0
  188. package/dist/extensions/pi-subagents/test/unit/foreground-tool-call-compaction.test.ts +78 -0
  189. package/dist/extensions/pi-subagents/test/unit/fork-context.test.ts +415 -0
  190. package/dist/extensions/pi-subagents/test/unit/get-final-output.test.ts +126 -0
  191. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +271 -0
  192. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +173 -0
  193. package/dist/extensions/pi-subagents/test/unit/jsonl-writer.test.ts +119 -0
  194. package/dist/extensions/pi-subagents/test/unit/live-async-resume.test.ts +170 -0
  195. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +346 -0
  196. package/dist/extensions/pi-subagents/test/unit/model-info.test.ts +62 -0
  197. package/dist/extensions/pi-subagents/test/unit/model-scope-settings.test.ts +87 -0
  198. package/dist/extensions/pi-subagents/test/unit/model-scope.test.ts +145 -0
  199. package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +162 -0
  200. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +470 -0
  201. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +329 -0
  202. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +319 -0
  203. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +70 -0
  204. package/dist/extensions/pi-subagents/test/unit/parallel-utils.test.ts +247 -0
  205. package/dist/extensions/pi-subagents/test/unit/path-handling.test.ts +113 -0
  206. package/dist/extensions/pi-subagents/test/unit/path-resolution.test.ts +94 -0
  207. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +803 -0
  208. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +209 -0
  209. package/dist/extensions/pi-subagents/test/unit/pi-spawn.test.ts +238 -0
  210. package/dist/extensions/pi-subagents/test/unit/proactive-skills.test.ts +118 -0
  211. package/dist/extensions/pi-subagents/test/unit/profiles.test.ts +299 -0
  212. package/dist/extensions/pi-subagents/test/unit/prompt-template-bridge.test.ts +408 -0
  213. package/dist/extensions/pi-subagents/test/unit/prompt-workflows.test.ts +134 -0
  214. package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +257 -0
  215. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +209 -0
  216. package/dist/extensions/pi-subagents/test/unit/result-intercom.test.ts +226 -0
  217. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +294 -0
  218. package/dist/extensions/pi-subagents/test/unit/run-id-resolver.test.ts +147 -0
  219. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +997 -0
  220. package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +478 -0
  221. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +562 -0
  222. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +213 -0
  223. package/dist/extensions/pi-subagents/test/unit/skills-fallback.test.ts +438 -0
  224. package/dist/extensions/pi-subagents/test/unit/slash-bridge.test.ts +54 -0
  225. package/dist/extensions/pi-subagents/test/unit/slash-chain-groups.test.ts +464 -0
  226. package/dist/extensions/pi-subagents/test/unit/stale-run-reconciler.test.ts +255 -0
  227. package/dist/extensions/pi-subagents/test/unit/status-format.test.ts +19 -0
  228. package/dist/extensions/pi-subagents/test/unit/subagent-control.test.ts +245 -0
  229. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +517 -0
  230. package/dist/extensions/pi-subagents/test/unit/temp-paths.test.ts +72 -0
  231. package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +52 -0
  232. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +237 -0
  233. package/dist/extensions/pi-subagents/test/unit/total-cost.test.ts +60 -0
  234. package/dist/extensions/pi-subagents/test/unit/ts-loader.test.ts +33 -0
  235. package/dist/extensions/pi-subagents/test/unit/turn-budget.test.ts +202 -0
  236. package/dist/extensions/pi-subagents/test/unit/types-fork-preamble.test.ts +23 -0
  237. package/dist/extensions/pi-subagents/test/unit/wait.test.ts +398 -0
  238. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +88 -0
  239. package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +26 -0
  240. package/dist/extensions/pi-subagents/test/unit/workflow-graph.test.ts +134 -0
  241. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +479 -0
  242. package/dist/extensions/pi-web-agent/package.json +2 -2
  243. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +2 -2
  244. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +5 -2
  245. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +1 -1
  246. package/dist/extensions/pi-web-agent/src/extension.ts +1 -1
  247. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +6 -4
  248. package/dist/extensions/question/helpers.ts +3 -3
  249. package/dist/extensions/question/index.ts +2 -2
  250. package/dist/extensions/question/navigation.ts +2 -2
  251. package/dist/extensions/question/package.json +2 -2
  252. package/dist/extensions/question/question-list.ts +2 -2
  253. package/dist/extensions/question/tests/question-list.test.ts +2 -2
  254. package/dist/extensions/question/tui-adapter.ts +2 -2
  255. package/dist/extensions/question/types.ts +2 -2
  256. package/dist/extensions/question/ui-protocol.ts +2 -2
  257. package/dist/extensions/rtk.ts +2 -2
  258. package/dist/extensions/tokenin-onboarding.ts +2 -2
  259. package/dist/extensions/undo.ts +1 -1
  260. package/dist/extensions/web-agent-onboarding.ts +3 -3
  261. package/dist/extensions/workflow/adapter.ts +16 -8
  262. package/dist/extensions/workflow/extension.ts +1 -1
  263. package/dist/extensions/workflow/package.json +1 -1
  264. package/dist/main.d.ts.map +1 -1
  265. package/dist/main.js +4 -0
  266. package/dist/main.js.map +1 -1
  267. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  268. package/dist/modes/interactive/components/assistant-message.js +2 -0
  269. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  270. package/dist/modes/interactive/components/first-time-setup.d.ts +1 -1
  271. package/dist/modes/interactive/components/first-time-setup.d.ts.map +1 -1
  272. package/dist/modes/interactive/components/first-time-setup.js +19 -5
  273. package/dist/modes/interactive/components/first-time-setup.js.map +1 -1
  274. package/dist/skills/caveman/SKILL.md +49 -0
  275. package/dist/skills/handoff-text/SKILL.md +13 -0
  276. package/dist/utils/thinking-tags.d.ts +7 -0
  277. package/dist/utils/thinking-tags.d.ts.map +1 -0
  278. package/dist/utils/thinking-tags.js +63 -0
  279. package/dist/utils/thinking-tags.js.map +1 -0
  280. package/docs/docs.json +4 -0
  281. package/docs/extensions.md +2 -0
  282. package/docs/settings.md +15 -0
  283. package/docs/shared-host-extensions.md +109 -0
  284. package/package.json +2 -2
@@ -0,0 +1,1535 @@
1
+ /**
2
+ * Integration tests for chain execution (sequential and parallel steps).
3
+ *
4
+ * Uses the local createMockPi() harness to simulate subagent processes.
5
+ * Tests the full chain pipeline: template resolution → spawn → output capture
6
+ * → {previous} passing.
7
+ *
8
+ * Requires pi packages to be importable. Skips gracefully if unavailable.
9
+ */
10
+
11
+ import { describe, it, before, after, beforeEach, afterEach } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import type { MockPi } from "../support/helpers.ts";
16
+ import {
17
+ createMockPi,
18
+ createTempDir,
19
+ createEventBus,
20
+ removeTempDir,
21
+ makeAgent,
22
+ makeMinimalCtx,
23
+ tryImport,
24
+ events,
25
+ } from "../support/helpers.ts";
26
+ import { INTERCOM_DETACH_REQUEST_EVENT } from "../../src/shared/types.ts";
27
+
28
+ interface TestSequentialStep {
29
+ agent: string;
30
+ task?: string;
31
+ phase?: string;
32
+ label?: string;
33
+ as?: string;
34
+ outputSchema?: Record<string, unknown>;
35
+ model?: string;
36
+ output?: string | false;
37
+ outputMode?: "inline" | "file-only";
38
+ reads?: string[] | false;
39
+ skill?: string | string[] | false;
40
+ progress?: boolean;
41
+ cwd?: string;
42
+ acceptance?: unknown;
43
+ }
44
+
45
+ interface TestParallelTask {
46
+ agent: string;
47
+ task?: string;
48
+ phase?: string;
49
+ label?: string;
50
+ as?: string;
51
+ outputSchema?: Record<string, unknown>;
52
+ model?: string;
53
+ output?: string | false;
54
+ outputMode?: "inline" | "file-only";
55
+ reads?: string[] | false;
56
+ skill?: string | string[] | false;
57
+ progress?: boolean;
58
+ cwd?: string;
59
+ acceptance?: unknown;
60
+ }
61
+
62
+ type TestChainStep = TestSequentialStep | {
63
+ parallel: TestParallelTask[];
64
+ concurrency?: number;
65
+ failFast?: boolean;
66
+ worktree?: boolean;
67
+ cwd?: string;
68
+ } | {
69
+ expand: {
70
+ from: { output: string; path: string };
71
+ item?: string;
72
+ key?: string;
73
+ maxItems?: number;
74
+ onEmpty?: "skip" | "fail";
75
+ };
76
+ parallel: TestParallelTask;
77
+ collect: { as: string; outputSchema?: Record<string, unknown> };
78
+ concurrency?: number;
79
+ failFast?: boolean;
80
+ label?: string;
81
+ acceptance?: unknown;
82
+ };
83
+
84
+ interface ChainResultItem {
85
+ agent: string;
86
+ exitCode: number;
87
+ finalOutput?: string;
88
+ structuredOutput?: unknown;
89
+ task?: string;
90
+ detached?: boolean;
91
+ timedOut?: boolean;
92
+ error?: string;
93
+ attemptedModels?: string[];
94
+ skills?: string[];
95
+ acceptance?: { status?: string; verifyRuns?: Array<{ status?: string }>; childReport?: unknown; runtimeChecks?: Array<{ status?: string; id?: string }> };
96
+ }
97
+
98
+ interface ChainExecutionResult {
99
+ isError?: boolean;
100
+ content: Array<{ text: string }>;
101
+ details: {
102
+ results: ChainResultItem[];
103
+ chainAgents?: string[];
104
+ totalSteps?: number;
105
+ totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
106
+ workflowGraph?: {
107
+ nodes: Array<{ kind?: string; agent?: string; flatIndex?: number; outputName?: string; status?: string; error?: string; acceptanceStatus?: string; children?: Array<{ itemKey?: string; label?: string; status?: string; acceptanceStatus?: string }> }>;
108
+ };
109
+ currentStepIndex?: number;
110
+ outputs?: Record<string, { text: string; structured?: unknown }>;
111
+ };
112
+ }
113
+
114
+ interface ChainExecutionModule {
115
+ executeChain(params: Record<string, unknown>): Promise<ChainExecutionResult>;
116
+ }
117
+
118
+ const chainMod = await tryImport<ChainExecutionModule>("./src/runs/foreground/chain-execution.ts");
119
+ const available = !!chainMod;
120
+ const executeChain = chainMod?.executeChain;
121
+
122
+ describe("chain execution — sequential", { skip: !available ? "pi packages not available" : undefined }, () => {
123
+ let tempDir: string;
124
+ let artifactsDir: string;
125
+ let mockPi: MockPi;
126
+
127
+ before(() => {
128
+ mockPi = createMockPi();
129
+ mockPi.install();
130
+ });
131
+
132
+ after(() => {
133
+ mockPi.uninstall();
134
+ });
135
+
136
+ beforeEach(() => {
137
+ tempDir = createTempDir();
138
+ artifactsDir = path.join(tempDir, "artifacts");
139
+ mockPi.reset();
140
+ });
141
+
142
+ afterEach(() => {
143
+ removeTempDir(tempDir);
144
+ });
145
+
146
+ function makeChainParams(
147
+ chain: TestChainStep[],
148
+ agents: ReturnType<typeof makeAgent>[],
149
+ overrides: Record<string, unknown> = {},
150
+ ) {
151
+ return {
152
+ chain,
153
+ agents,
154
+ ctx: makeMinimalCtx(tempDir),
155
+ runId: `test-${Date.now().toString(36)}`,
156
+ shareEnabled: false,
157
+ sessionDirForIndex: () => undefined,
158
+ artifactsDir,
159
+ artifactConfig: { enabled: false },
160
+ clarify: false,
161
+ ...overrides,
162
+ };
163
+ }
164
+
165
+ function readCallArgs(index: number): string[] {
166
+ const callFiles = fs.readdirSync(mockPi.dir)
167
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
168
+ .sort();
169
+ const callFile = callFiles[index];
170
+ assert.ok(callFile, `expected call ${index}`);
171
+ return JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[];
172
+ }
173
+
174
+ function acceptanceReport(overrides: Record<string, unknown> = {}): string {
175
+ return [
176
+ "done",
177
+ "```acceptance-report",
178
+ JSON.stringify({
179
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "integration test evidence" }],
180
+ changedFiles: ["src/a.ts"],
181
+ testsAddedOrUpdated: ["test/a.test.ts"],
182
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
183
+ validationOutput: ["validation passed"],
184
+ residualRisks: [],
185
+ noStagedFiles: true,
186
+ notes: "complete",
187
+ ...overrides,
188
+ }),
189
+ "```",
190
+ ].join("\n");
191
+ }
192
+
193
+ function writePackageSkill(packageRoot: string, skillName: string): void {
194
+ const skillDir = path.join(packageRoot, "skills", skillName);
195
+ fs.mkdirSync(skillDir, { recursive: true });
196
+ fs.writeFileSync(
197
+ path.join(packageRoot, "package.json"),
198
+ JSON.stringify({ name: `${skillName}-pkg`, version: "1.0.0", pi: { skills: [`./skills/${skillName}`] } }, null, 2),
199
+ "utf-8",
200
+ );
201
+ fs.writeFileSync(
202
+ path.join(skillDir, "SKILL.md"),
203
+ `---\nname: ${skillName}\ndescription: test skill\n---\nbody\n`,
204
+ "utf-8",
205
+ );
206
+ }
207
+
208
+ it("runs a 2-step chain", async () => {
209
+ mockPi.onCall({ output: "Analysis complete: found 3 issues" });
210
+ const agents = [makeAgent("analyst"), makeAgent("reporter")];
211
+
212
+ const result = await executeChain(
213
+ makeChainParams(
214
+ [{ agent: "analyst", task: "Analyze the code" }, { agent: "reporter" }],
215
+ agents,
216
+ ),
217
+ );
218
+
219
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
220
+ assert.equal(result.details.results.length, 2);
221
+ assert.equal(result.details.results[0].agent, "analyst");
222
+ assert.equal(result.details.results[1].agent, "reporter");
223
+ assert.deepEqual(result.details.totalCost, { inputTokens: 200, outputTokens: 100, costUsd: 0.002 });
224
+ });
225
+
226
+ it("runs a foreground sequential chain without clarify UI when clarify is omitted", async () => {
227
+ mockPi.onCall({ output: "Analysis complete" });
228
+ const agents = [makeAgent("analyst"), makeAgent("reporter")];
229
+ let customCalls = 0;
230
+ const ctx = {
231
+ ...makeMinimalCtx(tempDir),
232
+ hasUI: true,
233
+ ui: {
234
+ custom: async () => {
235
+ customCalls += 1;
236
+ return undefined;
237
+ },
238
+ },
239
+ };
240
+
241
+ const result = await executeChain(
242
+ makeChainParams(
243
+ [{ agent: "analyst", task: "Analyze the code" }, { agent: "reporter" }],
244
+ agents,
245
+ { ctx, clarify: undefined },
246
+ ),
247
+ );
248
+
249
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
250
+ assert.doesNotMatch(result.content[0]?.text ?? "", /Chain cancelled/);
251
+ assert.equal(result.details.results.length, 2);
252
+ assert.equal(mockPi.callCount(), 2);
253
+ assert.equal(customCalls, 0);
254
+ });
255
+
256
+ it("uses clarify UI for a foreground sequential chain when clarify is true", async () => {
257
+ mockPi.onCall({ output: "Analysis complete" });
258
+ const agents = [makeAgent("analyst"), makeAgent("reporter")];
259
+ let customCalls = 0;
260
+ const ctx = {
261
+ ...makeMinimalCtx(tempDir),
262
+ hasUI: true,
263
+ ui: {
264
+ custom: async () => {
265
+ customCalls += 1;
266
+ return {
267
+ confirmed: true,
268
+ templates: ["Clarified analysis", "Report on {previous}"],
269
+ behaviorOverrides: [],
270
+ };
271
+ },
272
+ },
273
+ };
274
+
275
+ const result = await executeChain(
276
+ makeChainParams(
277
+ [{ agent: "analyst", task: "Analyze the code" }, { agent: "reporter" }],
278
+ agents,
279
+ { ctx, clarify: true },
280
+ ),
281
+ );
282
+
283
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
284
+ assert.equal(customCalls, 1);
285
+ assert.equal(mockPi.callCount(), 2);
286
+ assert.match(readCallArgs(0).at(-1) ?? "", /Clarified analysis/);
287
+ });
288
+
289
+ it("preserves completed chain results and marks the timed-out current step", async () => {
290
+ mockPi.onCall({ matchArgIncludes: "Quick first step", output: "first done" });
291
+ mockPi.onCall({ matchArgIncludes: "Slow second step", delay: 10000 });
292
+ const agents = [makeAgent("analyst"), makeAgent("reporter")];
293
+
294
+ const start = Date.now();
295
+ const result = await executeChain(
296
+ makeChainParams(
297
+ [{ agent: "analyst", task: "Quick first step" }, { agent: "reporter", task: "Slow second step" }],
298
+ agents,
299
+ { timeoutMs: 300 },
300
+ ),
301
+ );
302
+ const elapsed = Date.now() - start;
303
+
304
+ assert.ok(elapsed < 5000, `should time out early, took ${elapsed}ms`);
305
+ assert.equal(result.isError, true);
306
+ assert.equal(result.details.results.length, 2);
307
+ assert.equal(result.details.results[0]?.exitCode, 0);
308
+ assert.equal(result.details.results[0]?.finalOutput, "first done");
309
+ assert.equal(result.details.results[1]?.timedOut, true);
310
+ assert.equal(result.details.results[1]?.error, "Subagent timed out after 300ms.");
311
+ assert.match(result.content[0]?.text ?? "", /Subagent timed out after 300ms\./);
312
+ });
313
+
314
+ it("passes file-only saved-output references through {previous}", async () => {
315
+ mockPi.onCall({ output: "full chain output\nwith details" });
316
+ const agents = [makeAgent("analyst"), makeAgent("reporter")];
317
+
318
+ const result = await executeChain(
319
+ makeChainParams(
320
+ [
321
+ { agent: "analyst", task: "Analyze", output: "analysis.md", outputMode: "file-only" },
322
+ { agent: "reporter" },
323
+ ],
324
+ agents,
325
+ { chainDir: tempDir },
326
+ ),
327
+ );
328
+
329
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
330
+ assert.match(result.details.results[0]?.finalOutput ?? "", /Output saved to:/);
331
+ assert.doesNotMatch(result.details.results[0]?.finalOutput ?? "", /full chain output/);
332
+ const secondTaskArg = readCallArgs(1).at(-1) ?? "";
333
+ assert.match(secondTaskArg, /Output saved to:/);
334
+ assert.match(secondTaskArg, /2 lines/);
335
+ assert.doesNotMatch(secondTaskArg, /full chain output/);
336
+ });
337
+
338
+ it("persists explicit checked acceptance and rejects missing evidence", async () => {
339
+ mockPi.onCall({
340
+ output: [
341
+ "implemented",
342
+ "```acceptance-report",
343
+ JSON.stringify({
344
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patched" }],
345
+ changedFiles: ["src/file.ts"],
346
+ testsAddedOrUpdated: ["test/file.test.ts"],
347
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
348
+ validationOutput: ["passed"],
349
+ residualRisks: [],
350
+ noStagedFiles: true,
351
+ notes: "done",
352
+ }),
353
+ "```",
354
+ ].join("\n"),
355
+ });
356
+ const agents = [makeAgent("worker", { completionGuard: false })];
357
+
358
+ const result = await executeChain(
359
+ makeChainParams(
360
+ [{ agent: "worker", task: "Implement fix", output: "accepted.md", outputMode: "file-only", acceptance: { level: "checked", criteria: ["Patch bug"] } }],
361
+ agents,
362
+ { chainDir: tempDir },
363
+ ),
364
+ );
365
+
366
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
367
+ assert.match(result.details.results[0]?.finalOutput ?? "", /Output saved to:/);
368
+ assert.equal(result.details.results[0]?.acceptance?.status, "checked");
369
+ assert.ok(result.details.results[0]?.acceptance?.childReport);
370
+
371
+ mockPi.onCall({
372
+ output: [
373
+ "implemented",
374
+ "```acceptance-report",
375
+ JSON.stringify({
376
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patched" }],
377
+ changedFiles: ["src/file.ts"],
378
+ testsAddedOrUpdated: [],
379
+ commandsRun: [{ command: "npm test", result: "passed", summary: "passed" }],
380
+ residualRisks: [],
381
+ noStagedFiles: true,
382
+ }),
383
+ "```",
384
+ ].join("\n"),
385
+ });
386
+
387
+ const failed = await executeChain(
388
+ makeChainParams(
389
+ [{ agent: "worker", task: "Implement fix", acceptance: { level: "checked" } }],
390
+ agents,
391
+ ),
392
+ );
393
+ assert.equal(failed.isError, true);
394
+ assert.equal(failed.details.results[0]?.acceptance?.status, "rejected");
395
+ assert.match(failed.details.results[0]?.error ?? "", /tests-added evidence missing/);
396
+ });
397
+
398
+ it("runs explicit verified acceptance commands and does not trust child command claims as verification", async () => {
399
+ const acceptanceReport = [
400
+ "implemented",
401
+ "```acceptance-report",
402
+ JSON.stringify({
403
+ criteriaSatisfied: [{ id: "criterion-1", status: "satisfied", evidence: "patched" }],
404
+ changedFiles: ["src/file.ts"],
405
+ testsAddedOrUpdated: ["test/file.test.ts"],
406
+ commandsRun: [{ command: "npm test", result: "passed", summary: "child claimed pass" }],
407
+ validationOutput: ["child output"],
408
+ residualRisks: [],
409
+ noStagedFiles: true,
410
+ }),
411
+ "```",
412
+ ].join("\n");
413
+ mockPi.onCall({ output: acceptanceReport });
414
+ const agents = [makeAgent("worker", { completionGuard: false })];
415
+
416
+ const result = await executeChain(
417
+ makeChainParams(
418
+ [{ agent: "worker", task: "Implement fix", acceptance: { level: "verified", verify: [{ id: "runtime-pass", command: "node -e \"process.exit(0)\"" }] } }],
419
+ agents,
420
+ ),
421
+ );
422
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
423
+ assert.equal(result.details.results[0]?.acceptance?.status, "verified");
424
+ assert.equal(result.details.results[0]?.acceptance?.verifyRuns?.[0]?.status, "passed");
425
+
426
+ mockPi.onCall({ output: acceptanceReport });
427
+ const failed = await executeChain(
428
+ makeChainParams(
429
+ [{ agent: "worker", task: "Implement fix", acceptance: { level: "verified", verify: [{ id: "runtime-fail", command: "node -e \"process.exit(5)\"" }] } }],
430
+ agents,
431
+ ),
432
+ );
433
+ assert.equal(failed.isError, true);
434
+ assert.equal(failed.details.results[0]?.acceptance?.status, "rejected");
435
+ assert.equal(failed.details.results[0]?.acceptance?.verifyRuns?.[0]?.status, "failed");
436
+ assert.match(failed.details.results[0]?.error ?? "", /runtime-fail/);
437
+ });
438
+
439
+ it("retries chain steps with fallback models on retryable provider failures", async () => {
440
+ mockPi.onCall({
441
+ jsonl: [{
442
+ type: "message_end",
443
+ message: {
444
+ role: "assistant",
445
+ content: [{ type: "text", text: "primary failed" }],
446
+ model: "openai/gpt-5-mini",
447
+ errorMessage: "provider unavailable",
448
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } },
449
+ },
450
+ }],
451
+ exitCode: 1,
452
+ });
453
+ mockPi.onCall({ output: "Step 1 recovered" });
454
+ mockPi.onCall({ output: "Step 2 ran" });
455
+ const agents = [
456
+ makeAgent("step1", { model: "openai/gpt-5-mini", fallbackModels: ["anthropic/claude-sonnet-4"] }),
457
+ makeAgent("step2"),
458
+ ];
459
+
460
+ const result = await executeChain(
461
+ makeChainParams(
462
+ [{ agent: "step1", task: "Do step 1" }, { agent: "step2" }],
463
+ agents,
464
+ ),
465
+ );
466
+
467
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
468
+ assert.equal(result.details.results.length, 2);
469
+ assert.deepEqual(result.details.results[0].attemptedModels, ["openai/gpt-5-mini", "anthropic/claude-sonnet-4"]);
470
+ assert.equal(mockPi.callCount(), 3);
471
+ });
472
+
473
+ it("prefers the parent session provider for ambiguous bare chain step models", async () => {
474
+ mockPi.onCall({ output: "Step 1 ran" });
475
+ mockPi.onCall({ output: "Step 2 ran" });
476
+ const agents = [makeAgent("step1", { model: "gpt-5-mini" }), makeAgent("step2")];
477
+
478
+ const result = await executeChain(
479
+ makeChainParams(
480
+ [{ agent: "step1", task: "Do step 1" }, { agent: "step2" }],
481
+ agents,
482
+ {
483
+ ctx: {
484
+ ...makeMinimalCtx(tempDir),
485
+ model: { provider: "github-copilot" },
486
+ modelRegistry: {
487
+ getAvailable: () => [
488
+ { provider: "openai", id: "gpt-5-mini" },
489
+ { provider: "github-copilot", id: "gpt-5-mini" },
490
+ ],
491
+ },
492
+ },
493
+ },
494
+ ),
495
+ );
496
+
497
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
498
+ assert.equal(result.details.results[0].model, "github-copilot/gpt-5-mini");
499
+ assert.deepEqual(result.details.results[0].attemptedModels, ["github-copilot/gpt-5-mini"]);
500
+ });
501
+
502
+ it("foreground chains inherit the parent session model when no step or agent model is set", async () => {
503
+ mockPi.onCall({ output: "Step ran" });
504
+
505
+ const result = await executeChain(
506
+ makeChainParams(
507
+ [{ agent: "worker", task: "Do work" }],
508
+ [makeAgent("worker")],
509
+ {
510
+ ctx: {
511
+ ...makeMinimalCtx(tempDir),
512
+ model: { provider: "deepseek", id: "deepseek-v4-flash" },
513
+ },
514
+ },
515
+ ),
516
+ );
517
+
518
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
519
+ const args = readCallArgs(0);
520
+ assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
521
+ assert.equal(result.details.results[0].model, "deepseek/deepseek-v4-flash");
522
+ });
523
+
524
+ it("foreground chains treat the inherit model sentinel as the parent session model", async () => {
525
+ mockPi.onCall({ output: "Step ran" });
526
+
527
+ const result = await executeChain(
528
+ makeChainParams(
529
+ [{ agent: "worker", task: "Do work", model: "inherit" }],
530
+ [makeAgent("worker")],
531
+ {
532
+ ctx: {
533
+ ...makeMinimalCtx(tempDir),
534
+ model: { provider: "deepseek", id: "deepseek-v4-flash" },
535
+ },
536
+ },
537
+ ),
538
+ );
539
+
540
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
541
+ const args = readCallArgs(0);
542
+ assert.equal(args[args.indexOf("--model") + 1], "deepseek/deepseek-v4-flash");
543
+ assert.equal(result.details.results[0].model, "deepseek/deepseek-v4-flash");
544
+ });
545
+
546
+ it("suppresses progress for {task} chain templates when the top-level task is review-only", async () => {
547
+ mockPi.onCall({ output: "Review done" });
548
+ const agents = [makeAgent("reviewer", { defaultProgress: true })];
549
+
550
+ await executeChain(
551
+ makeChainParams(
552
+ [{ agent: "reviewer" }],
553
+ agents,
554
+ { task: "Review-only. Do not edit files. Return findings." },
555
+ ),
556
+ );
557
+
558
+ const taskArg = readCallArgs(0).at(-1) ?? "";
559
+ assert.doesNotMatch(taskArg, /progress\.md/);
560
+ assert.equal(fs.existsSync(path.join(tempDir, "progress.md")), false);
561
+ });
562
+
563
+ it("foreground chains still resolve defaultProgress inside the chain directory", async () => {
564
+ mockPi.onCall({ output: "Progress done" });
565
+ const agents = [makeAgent("reviewer", { defaultProgress: true })];
566
+ const chainDir = path.join(tempDir, "chain-progress");
567
+ const runId = "chain-progress-run";
568
+
569
+ await executeChain(
570
+ makeChainParams(
571
+ [{ agent: "reviewer", task: "Track chain work" }],
572
+ agents,
573
+ { chainDir, runId },
574
+ ),
575
+ );
576
+
577
+ const taskArg = readCallArgs(0).at(-1) ?? "";
578
+ assert.ok(taskArg.includes(`Create and maintain progress at: ${path.join(chainDir, runId, "progress.md")}`), taskArg);
579
+ });
580
+
581
+ it("passes {previous} between steps (step 2 receives step 1 output)", async () => {
582
+ mockPi.onCall({ output: "Step 1 unique output: MARKER_ABC_123" });
583
+ const agents = [makeAgent("step1"), makeAgent("step2")];
584
+
585
+ const result = await executeChain(
586
+ makeChainParams(
587
+ [{ agent: "step1", task: "Produce output" }, { agent: "step2" }],
588
+ agents,
589
+ ),
590
+ );
591
+
592
+ assert.ok(!result.isError);
593
+ const step2Task = result.details.results[1].task;
594
+ assert.ok(
595
+ step2Task.includes("MARKER_ABC_123"),
596
+ `step 2 task should contain step 1 output via {previous}: ${step2Task.slice(0, 200)}`,
597
+ );
598
+ });
599
+
600
+ it("passes named sequential outputs through {outputs.name}", async () => {
601
+ mockPi.onCall({ output: "Context marker: CTX_123" });
602
+ mockPi.onCall({ output: "Done" });
603
+ const agents = [makeAgent("context"), makeAgent("writer")];
604
+
605
+ const result = await executeChain(
606
+ makeChainParams(
607
+ [
608
+ { agent: "context", task: "Gather context", as: "contextOutput" },
609
+ { agent: "writer", task: "Use {outputs.contextOutput}" },
610
+ ],
611
+ agents,
612
+ ),
613
+ );
614
+
615
+ assert.ok(!result.isError);
616
+ assert.match(readCallArgs(1).at(-1) ?? "", /CTX_123/);
617
+ assert.equal(result.details.workflowGraph?.nodes[0]?.outputName, "contextOutput");
618
+ });
619
+
620
+ it("expands structured named output into dynamic parallel children and collects results", async () => {
621
+ mockPi.onCall({
622
+ output: "targets",
623
+ structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] },
624
+ });
625
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
626
+ mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
627
+ mockPi.onCall({ output: "synthesized" });
628
+ const agents = [makeAgent("scout"), makeAgent("reviewer"), makeAgent("writer")];
629
+
630
+ const result = await executeChain(
631
+ makeChainParams(
632
+ [
633
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
634
+ {
635
+ expand: { from: { output: "targets", path: "/items" }, item: "target", key: "/path", maxItems: 4 },
636
+ parallel: {
637
+ agent: "reviewer",
638
+ task: "Review {target.path}",
639
+ label: "Review {target.path}",
640
+ outputSchema: { type: "object" },
641
+ },
642
+ collect: { as: "reviews" },
643
+ concurrency: 1,
644
+ },
645
+ { agent: "writer", task: "Use {outputs.reviews}" },
646
+ ],
647
+ agents,
648
+ ),
649
+ );
650
+
651
+ assert.ok(!result.isError);
652
+ assert.equal(mockPi.callCount(), 4);
653
+ assert.match(readCallArgs(1).at(-1) ?? "", /Review src\/a\.ts/);
654
+ assert.match(readCallArgs(2).at(-1) ?? "", /Review src\/b\.ts/);
655
+ assert.match(readCallArgs(3).at(-1) ?? "", /"key":"src\/a\.ts"/);
656
+ const collected = result.details.outputs?.reviews?.structured as Array<{ key: string; structured: unknown }>;
657
+ assert.deepEqual(collected.map((item) => item.key), ["src/a.ts", "src/b.ts"]);
658
+ assert.deepEqual(collected.map((item) => item.structured), [{ ok: "a" }, { ok: "b" }]);
659
+ const dynamicNode = result.details.workflowGraph?.nodes[1];
660
+ assert.equal(dynamicNode?.kind, "dynamic-parallel-group");
661
+ assert.deepEqual(dynamicNode?.children?.map((child) => child.itemKey), ["src/a.ts", "src/b.ts"]);
662
+ });
663
+
664
+ it("persists checked acceptance status for dynamic fanout materialized children and aggregate group", async () => {
665
+ mockPi.onCall({
666
+ output: "targets",
667
+ structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] },
668
+ });
669
+ mockPi.onCall({ output: acceptanceReport({ changedFiles: ["src/a.ts"] }), structuredOutput: { ok: "a" } });
670
+ mockPi.onCall({ output: acceptanceReport({ changedFiles: ["src/b.ts"] }), structuredOutput: { ok: "b" } });
671
+ const agents = [makeAgent("scout"), makeAgent("reviewer", { completionGuard: false })];
672
+
673
+ const result = await executeChain(
674
+ makeChainParams(
675
+ [
676
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
677
+ {
678
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 4 },
679
+ parallel: { agent: "reviewer", task: "Review {item.path}", outputSchema: { type: "object" }, acceptance: { level: "checked" } },
680
+ collect: { as: "reviews" },
681
+ acceptance: { level: "checked" },
682
+ concurrency: 1,
683
+ },
684
+ ],
685
+ agents,
686
+ ),
687
+ );
688
+
689
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
690
+ const dynamicNode = result.details.workflowGraph?.nodes[1];
691
+ assert.equal(dynamicNode?.acceptanceStatus, "checked");
692
+ assert.deepEqual(dynamicNode?.children?.map((child) => child.acceptanceStatus), ["checked", "checked"]);
693
+ });
694
+
695
+ it("does not expose collected dynamic output when a child fails", async () => {
696
+ mockPi.onCall({
697
+ output: "targets",
698
+ structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] },
699
+ });
700
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
701
+ mockPi.onCall({ exitCode: 1, stderr: "review-b failed" });
702
+ const agents = [makeAgent("scout"), makeAgent("reviewer")];
703
+
704
+ const result = await executeChain(
705
+ makeChainParams(
706
+ [
707
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
708
+ {
709
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 4 },
710
+ parallel: { agent: "reviewer", task: "Review {item.path}", outputSchema: { type: "object" } },
711
+ collect: { as: "reviews" },
712
+ concurrency: 1,
713
+ },
714
+ ],
715
+ agents,
716
+ ),
717
+ );
718
+
719
+ assert.equal(result.isError, true);
720
+ assert.equal(mockPi.callCount(), 3);
721
+ assert.equal(result.details.outputs?.reviews, undefined);
722
+ assert.equal(result.details.results.some((entry) => entry.exitCode === 1), true);
723
+ });
724
+
725
+ it("fails dynamic fanout before spawning children for invalid source arrays", async () => {
726
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "a" }, { path: "b" }] } });
727
+ const agents = [makeAgent("scout"), makeAgent("reviewer")];
728
+
729
+ const result = await executeChain(
730
+ makeChainParams(
731
+ [
732
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
733
+ {
734
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 1 },
735
+ parallel: { agent: "reviewer", task: "Review {item.path}" },
736
+ collect: { as: "reviews" },
737
+ },
738
+ ],
739
+ agents,
740
+ ),
741
+ );
742
+
743
+ assert.equal(result.isError, true);
744
+ assert.match(result.content[0]?.text ?? "", /exceeding maxItems 1/);
745
+ assert.equal(mockPi.callCount(), 1);
746
+ assert.equal(result.details.workflowGraph?.nodes[1]?.status, "failed");
747
+ assert.match(result.details.workflowGraph?.nodes[1]?.error ?? "", /exceeding maxItems 1/);
748
+ });
749
+
750
+ it("marks dynamic file-only validation failures as failed graph groups before spawning children", async () => {
751
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
752
+ const agents = [makeAgent("scout"), makeAgent("reviewer")];
753
+
754
+ const result = await executeChain(
755
+ makeChainParams(
756
+ [
757
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
758
+ {
759
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 4 },
760
+ parallel: { agent: "reviewer", task: "Review {item.path}", outputMode: "file-only" },
761
+ collect: { as: "reviews" },
762
+ },
763
+ ],
764
+ agents,
765
+ { chainDir: tempDir },
766
+ ),
767
+ );
768
+
769
+ assert.equal(result.isError, true);
770
+ assert.match(result.content[0]?.text ?? "", /outputMode: "file-only"/);
771
+ assert.equal(mockPi.callCount(), 1);
772
+ assert.equal(result.details.workflowGraph?.nodes[1]?.status, "failed");
773
+ assert.match(result.details.workflowGraph?.nodes[1]?.error ?? "", /outputMode: "file-only"/);
774
+ });
775
+
776
+ it("marks empty dynamic fanout skip as a completed graph group", async () => {
777
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [] } });
778
+ mockPi.onCall({ output: "used empty reviews" });
779
+ const agents = [makeAgent("scout"), makeAgent("reviewer"), makeAgent("writer")];
780
+
781
+ const result = await executeChain(
782
+ makeChainParams(
783
+ [
784
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
785
+ {
786
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 4, onEmpty: "skip" },
787
+ parallel: { agent: "reviewer", task: "Review {item.path}" },
788
+ collect: { as: "reviews" },
789
+ },
790
+ { agent: "writer", task: "Use {outputs.reviews}" },
791
+ ],
792
+ agents,
793
+ ),
794
+ );
795
+
796
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
797
+ assert.equal(mockPi.callCount(), 2);
798
+ assert.deepEqual(result.details.outputs?.reviews?.structured, []);
799
+ assert.equal(result.details.workflowGraph?.nodes[1]?.status, "completed");
800
+ assert.deepEqual(result.details.workflowGraph?.nodes[1]?.children, []);
801
+ });
802
+
803
+ it("marks dynamic collect schema failures as failed graph groups", async () => {
804
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }] } });
805
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
806
+ const agents = [makeAgent("scout"), makeAgent("reviewer")];
807
+
808
+ const result = await executeChain(
809
+ makeChainParams(
810
+ [
811
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
812
+ {
813
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 4 },
814
+ parallel: { agent: "reviewer", task: "Review {item.path}", outputSchema: { type: "object" } },
815
+ collect: { as: "reviews", outputSchema: { type: "object" } },
816
+ },
817
+ ],
818
+ agents,
819
+ ),
820
+ );
821
+
822
+ assert.equal(result.isError, true);
823
+ assert.match(result.content[0]?.text ?? "", /Collected output validation failed/);
824
+ assert.equal(result.details.outputs?.reviews, undefined);
825
+ assert.equal(result.details.workflowGraph?.nodes[1]?.status, "failed");
826
+ assert.match(result.details.workflowGraph?.nodes[1]?.error ?? "", /Collected output validation failed/);
827
+ assert.equal(result.details.workflowGraph?.nodes[1]?.children?.[0]?.status, "completed");
828
+ });
829
+
830
+ it("keeps materialized dynamic children in live graph updates for later sequential steps", async () => {
831
+ mockPi.onCall({ output: "targets", structuredOutput: { items: [{ path: "src/a.ts" }, { path: "src/b.ts" }] } });
832
+ mockPi.onCall({ output: "review-a", structuredOutput: { ok: "a" } });
833
+ mockPi.onCall({ output: "review-b", structuredOutput: { ok: "b" } });
834
+ mockPi.onCall({ steps: [{ jsonl: [events.assistantMessage("writer started")] }] });
835
+ const agents = [makeAgent("scout"), makeAgent("reviewer"), makeAgent("writer")];
836
+ let writerUpdateChildren: Array<{ itemKey?: string; status?: string }> | undefined;
837
+
838
+ const result = await executeChain(
839
+ makeChainParams(
840
+ [
841
+ { agent: "scout", task: "Return targets", as: "targets", outputSchema: { type: "object" } },
842
+ {
843
+ expand: { from: { output: "targets", path: "/items" }, key: "/path", maxItems: 4 },
844
+ parallel: { agent: "reviewer", task: "Review {item.path}", outputSchema: { type: "object" } },
845
+ collect: { as: "reviews" },
846
+ concurrency: 1,
847
+ },
848
+ { agent: "writer", task: "Use {outputs.reviews}" },
849
+ ],
850
+ agents,
851
+ {
852
+ onUpdate(update: { details?: ChainExecutionResult["details"] }) {
853
+ if (update.details?.currentStepIndex !== 2) return;
854
+ writerUpdateChildren = update.details.workflowGraph?.nodes[1]?.children;
855
+ },
856
+ },
857
+ ),
858
+ );
859
+
860
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
861
+ assert.deepEqual(writerUpdateChildren?.map((child) => child.itemKey), ["src/a.ts", "src/b.ts"]);
862
+ });
863
+
864
+ it("fails duplicate and unknown named outputs before spawning children", async () => {
865
+ const agents = [makeAgent("a"), makeAgent("b")];
866
+
867
+ const duplicate = await executeChain(
868
+ makeChainParams(
869
+ [{ agent: "a", task: "A", as: "same" }, { agent: "b", task: "B", as: "same" }],
870
+ agents,
871
+ ),
872
+ );
873
+ assert.equal(duplicate.isError, true);
874
+ assert.match(duplicate.content[0]?.text ?? "", /Duplicate chain output name 'same'/);
875
+ assert.equal(mockPi.callCount(), 0);
876
+
877
+ const unknown = await executeChain(
878
+ makeChainParams(
879
+ [{ agent: "b", task: "Use {outputs.missing}" }],
880
+ agents,
881
+ ),
882
+ );
883
+ assert.equal(unknown.isError, true);
884
+ assert.match(unknown.content[0]?.text ?? "", /Unknown chain output reference/);
885
+ assert.equal(mockPi.callCount(), 0);
886
+
887
+ const malformed = await executeChain(
888
+ makeChainParams(
889
+ [{ agent: "b", task: "Use {outputs.bad-name}" }],
890
+ agents,
891
+ ),
892
+ );
893
+ assert.equal(malformed.isError, true);
894
+ assert.match(malformed.content[0]?.text ?? "", /Invalid chain output reference '\{outputs\.bad-name\}'/);
895
+ assert.equal(mockPi.callCount(), 0);
896
+ });
897
+
898
+ it("requires schema-valid structured_output when outputSchema is set", async () => {
899
+ const schema = {
900
+ type: "object",
901
+ required: ["ok"],
902
+ properties: { ok: { type: "boolean" }, note: { type: "string" } },
903
+ };
904
+ mockPi.onCall({ output: "prose", structuredOutput: { ok: true, note: "captured" } });
905
+ const agents = [makeAgent("worker")];
906
+
907
+ const result = await executeChain(
908
+ makeChainParams([{ agent: "worker", task: "Return structured", outputSchema: schema }], agents),
909
+ );
910
+
911
+ assert.ok(!result.isError);
912
+ assert.deepEqual(result.details.results[0]?.structuredOutput, { ok: true, note: "captured" });
913
+
914
+ mockPi.reset();
915
+ mockPi.onCall({ structuredOutput: { ok: true, note: "tool-only" } });
916
+ const structuredOnly = await executeChain(
917
+ makeChainParams([{ agent: "worker", task: "Return structured", outputSchema: schema }], agents),
918
+ );
919
+ assert.ok(!structuredOnly.isError);
920
+ assert.deepEqual(structuredOnly.details.results[0]?.structuredOutput, { ok: true, note: "tool-only" });
921
+
922
+ mockPi.reset();
923
+ mockPi.onCall({ output: "prose only" });
924
+ const missing = await executeChain(
925
+ makeChainParams([{ agent: "worker", task: "Return structured", outputSchema: schema }], agents),
926
+ );
927
+ assert.equal(missing.isError, true);
928
+ assert.match(missing.details.results[0]?.error ?? "", /Missing structured_output call/);
929
+
930
+ mockPi.reset();
931
+ mockPi.onCall({ output: "invalid", structuredOutput: { ok: "yes" } });
932
+ const invalid = await executeChain(
933
+ makeChainParams([{ agent: "worker", task: "Return structured", outputSchema: schema, phase: "Validate", label: "Structured worker", as: "result" }], agents),
934
+ );
935
+ assert.equal(invalid.isError, true);
936
+ assert.match(invalid.details.results[0]?.error ?? "", /Structured output validation failed/);
937
+ assert.equal(invalid.details.workflowGraph?.nodes[0]?.status, "failed");
938
+ assert.equal(invalid.details.workflowGraph?.nodes[0]?.outputName, "result");
939
+ assert.match(invalid.details.workflowGraph?.nodes[0]?.error ?? "", /Structured output validation failed/);
940
+ });
941
+
942
+ it("substitutes {task} in templates", async () => {
943
+ mockPi.onCall({ output: "Done" });
944
+ const agents = [makeAgent("worker")];
945
+
946
+ const result = await executeChain(
947
+ makeChainParams(
948
+ [{ agent: "worker", task: "Review {task} carefully" }],
949
+ agents,
950
+ { task: "the authentication module" },
951
+ ),
952
+ );
953
+
954
+ assert.ok(!result.isError);
955
+ const workerTask = result.details.results[0].task;
956
+ assert.ok(
957
+ workerTask.includes("the authentication module"),
958
+ `should substitute {task}: ${workerTask.slice(0, 200)}`,
959
+ );
960
+ });
961
+
962
+ it("creates and uses chain_dir", async () => {
963
+ mockPi.onCall({ output: "Done" });
964
+ const agents = [makeAgent("worker")];
965
+
966
+ const result = await executeChain(
967
+ makeChainParams(
968
+ [{ agent: "worker", task: "Write to {chain_dir}" }],
969
+ agents,
970
+ ),
971
+ );
972
+
973
+ assert.ok(!result.isError);
974
+ const summary = result.content[0].text;
975
+ assert.ok(summary.includes("✅ Chain completed:"), `missing completion marker: ${summary}`);
976
+ assert.ok(summary.includes("📁 Artifacts:"), `missing artifacts marker: ${summary}`);
977
+ });
978
+
979
+ it("stops chain on step failure", async () => {
980
+ mockPi.onCall({ exitCode: 1, stderr: "Agent crashed" });
981
+ const agents = [makeAgent("step1"), makeAgent("step2")];
982
+
983
+ const result = await executeChain(
984
+ makeChainParams(
985
+ [{ agent: "step1", task: "Do first thing" }, { agent: "step2" }],
986
+ agents,
987
+ ),
988
+ );
989
+
990
+ assert.ok(result.isError, "chain should fail");
991
+ assert.equal(result.details.results.length, 1, "only step1 should have run");
992
+ assert.equal(result.details.results[0].exitCode, 1);
993
+ });
994
+
995
+ it("runs a 3-step chain end-to-end", async () => {
996
+ mockPi.onCall({ output: "Step output" });
997
+ const agents = [makeAgent("scout"), makeAgent("planner"), makeAgent("executor")];
998
+
999
+ const result = await executeChain(
1000
+ makeChainParams(
1001
+ [
1002
+ { agent: "scout", task: "Survey the codebase" },
1003
+ { agent: "planner" },
1004
+ { agent: "executor" },
1005
+ ],
1006
+ agents,
1007
+ ),
1008
+ );
1009
+
1010
+ assert.ok(!result.isError);
1011
+ assert.equal(result.details.results.length, 3);
1012
+ assert.ok(result.details.results.every((r) => r.exitCode === 0));
1013
+ });
1014
+
1015
+ it("runs a 40-step alternating worker and reviewer chain", async () => {
1016
+ const chainLength = 40;
1017
+ for (let i = 0; i < chainLength; i++) {
1018
+ mockPi.onCall({ output: `step-${i}-output` });
1019
+ }
1020
+ const chain = Array.from({ length: chainLength }, (_, i): TestSequentialStep => ({
1021
+ agent: i % 2 === 0 ? "worker" : "reviewer",
1022
+ ...(i === 0 ? { task: "Start long worker/reviewer chain" } : {}),
1023
+ }));
1024
+ const agents = [makeAgent("worker"), makeAgent("reviewer")];
1025
+
1026
+ const result = await executeChain(makeChainParams(chain, agents));
1027
+
1028
+ assert.ok(!result.isError, `long chain should succeed: ${JSON.stringify(result.content)}`);
1029
+ assert.equal(mockPi.callCount(), chainLength);
1030
+ assert.equal(result.details.results.length, chainLength);
1031
+ assert.equal(result.details.totalSteps, chainLength);
1032
+ assert.equal(result.details.chainAgents?.length, chainLength);
1033
+ assert.equal(result.details.workflowGraph?.nodes.length, chainLength);
1034
+ assert.equal(result.details.workflowGraph?.nodes.at(-1)?.agent, "reviewer");
1035
+ assert.equal(result.details.workflowGraph?.nodes.at(-1)?.flatIndex, chainLength - 1);
1036
+ assert.ok(result.details.results.every((r) => r.exitCode === 0));
1037
+ assert.deepEqual(
1038
+ result.details.results.map((r) => r.agent),
1039
+ chain.map((step) => step.agent),
1040
+ );
1041
+
1042
+ const finalTaskArg = readCallArgs(chainLength - 1).at(-1) ?? "";
1043
+ assert.match(finalTaskArg, /step-38-output/);
1044
+ assert.doesNotMatch(finalTaskArg, /step-37-output/);
1045
+ assert.match(result.content[0]?.text ?? "", /40 steps/);
1046
+ });
1047
+
1048
+ it("returns error for unknown agent in chain", async () => {
1049
+ const agents = [makeAgent("scout")];
1050
+
1051
+ const result = await executeChain(
1052
+ makeChainParams(
1053
+ [{ agent: "scout", task: "Start" }, { agent: "nonexistent" }],
1054
+ agents,
1055
+ ),
1056
+ );
1057
+
1058
+ assert.ok(result.isError);
1059
+ assert.ok(result.content[0].text.includes("Unknown agent"));
1060
+ });
1061
+
1062
+ it("resolves relative step cwd values against the chain cwd for skills", async () => {
1063
+ mockPi.onCall({ output: "ok" });
1064
+ const chainCwd = path.join(tempDir, "worktree");
1065
+ const stepPackageDir = path.join(chainCwd, "packages", "app");
1066
+ writePackageSkill(stepPackageDir, "chain-step-skill");
1067
+ const agents = [makeAgent("analyst", { skills: ["chain-step-skill"] })];
1068
+
1069
+ const result = await executeChain(
1070
+ makeChainParams(
1071
+ [{ agent: "analyst", task: "Analyze", cwd: "packages/app" }],
1072
+ agents,
1073
+ { cwd: chainCwd },
1074
+ ),
1075
+ );
1076
+
1077
+ assert.ok(!result.isError, `chain should succeed: ${JSON.stringify(result.content)}`);
1078
+ assert.deepEqual(result.details.results[0]?.skills, ["chain-step-skill"]);
1079
+ });
1080
+
1081
+ it("tracks chain metadata (chainAgents, totalSteps)", async () => {
1082
+ mockPi.onCall({ output: "Done" });
1083
+ const agents = [makeAgent("a"), makeAgent("b")];
1084
+
1085
+ const result = await executeChain(
1086
+ makeChainParams(
1087
+ [{ agent: "a", task: "Start" }, { agent: "b" }],
1088
+ agents,
1089
+ ),
1090
+ );
1091
+
1092
+ assert.ok(!result.isError);
1093
+ assert.deepEqual(result.details.chainAgents, ["a", "b"]);
1094
+ assert.equal(result.details.totalSteps, 2);
1095
+ });
1096
+
1097
+ it("uses custom chainDir when provided", async () => {
1098
+ mockPi.onCall({ output: "Done" });
1099
+ const agents = [makeAgent("worker")];
1100
+ const customChainDir = path.join(tempDir, "my-chain");
1101
+
1102
+ const result = await executeChain(
1103
+ makeChainParams(
1104
+ [{ agent: "worker", task: "Use {chain_dir}" }],
1105
+ agents,
1106
+ { chainDir: customChainDir },
1107
+ ),
1108
+ );
1109
+
1110
+ assert.ok(!result.isError);
1111
+ assert.ok(fs.existsSync(customChainDir), "custom chainDir should exist");
1112
+ });
1113
+
1114
+ it("tightens child recursion depth per agent without relaxing the inherited chain max", async () => {
1115
+ const originalDepth = process.env.SELESAI_SUBAGENT_DEPTH;
1116
+ const originalMaxDepth = process.env.SELESAI_SUBAGENT_MAX_DEPTH;
1117
+ delete process.env.SELESAI_SUBAGENT_DEPTH;
1118
+ delete process.env.SELESAI_SUBAGENT_MAX_DEPTH;
1119
+ try {
1120
+ mockPi.onCall({ echoEnv: ["SELESAI_SUBAGENT_DEPTH", "SELESAI_SUBAGENT_MAX_DEPTH"] });
1121
+ const agents = [makeAgent("worker", { maxSubagentDepth: 1 })];
1122
+
1123
+ const result = await executeChain(
1124
+ makeChainParams(
1125
+ [{ agent: "worker", task: "Inspect env" }],
1126
+ agents,
1127
+ { maxSubagentDepth: 3 },
1128
+ ),
1129
+ );
1130
+
1131
+ assert.ok(!result.isError);
1132
+ assert.deepEqual(JSON.parse(result.details.results[0].finalOutput ?? "{}"), {
1133
+ SELESAI_SUBAGENT_DEPTH: "1",
1134
+ SELESAI_SUBAGENT_MAX_DEPTH: "1",
1135
+ });
1136
+ } finally {
1137
+ if (originalDepth === undefined) delete process.env.SELESAI_SUBAGENT_DEPTH;
1138
+ else process.env.SELESAI_SUBAGENT_DEPTH = originalDepth;
1139
+ if (originalMaxDepth === undefined) delete process.env.SELESAI_SUBAGENT_MAX_DEPTH;
1140
+ else process.env.SELESAI_SUBAGENT_MAX_DEPTH = originalMaxDepth;
1141
+ }
1142
+ });
1143
+ });
1144
+
1145
+ describe("chain execution — parallel steps", { skip: !available ? "pi packages not available" : undefined }, () => {
1146
+ let tempDir: string;
1147
+ let mockPi: MockPi;
1148
+
1149
+ before(() => {
1150
+ mockPi = createMockPi();
1151
+ mockPi.install();
1152
+ });
1153
+
1154
+ after(() => {
1155
+ mockPi.uninstall();
1156
+ });
1157
+
1158
+ beforeEach(() => {
1159
+ tempDir = createTempDir();
1160
+ mockPi.reset();
1161
+ });
1162
+
1163
+ afterEach(() => {
1164
+ removeTempDir(tempDir);
1165
+ });
1166
+
1167
+ function makeChainParams(
1168
+ chain: TestChainStep[],
1169
+ agents: ReturnType<typeof makeAgent>[],
1170
+ overrides: Record<string, unknown> = {},
1171
+ ) {
1172
+ return {
1173
+ chain,
1174
+ agents,
1175
+ ctx: makeMinimalCtx(tempDir),
1176
+ runId: `test-${Date.now().toString(36)}`,
1177
+ shareEnabled: false,
1178
+ sessionDirForIndex: () => undefined,
1179
+ artifactsDir: path.join(tempDir, "artifacts"),
1180
+ artifactConfig: { enabled: false },
1181
+ clarify: false,
1182
+ ...overrides,
1183
+ };
1184
+ }
1185
+
1186
+ function readCallArgs(index: number): string[] {
1187
+ const callFiles = fs.readdirSync(mockPi.dir)
1188
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
1189
+ .sort();
1190
+ const callFile = callFiles[index];
1191
+ assert.ok(callFile, `expected call ${index}`);
1192
+ return JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[];
1193
+ }
1194
+
1195
+ function readCallArgsMatching(text: string): string[] {
1196
+ const callFiles = fs.readdirSync(mockPi.dir)
1197
+ .filter((name) => name.startsWith("call-") && name.endsWith(".json"))
1198
+ .sort();
1199
+ for (const callFile of callFiles) {
1200
+ const args = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")).args as string[];
1201
+ if (args.join("\n").includes(text)) return args;
1202
+ }
1203
+ assert.fail(`expected recorded call containing ${text}`);
1204
+ }
1205
+
1206
+ it("runs parallel tasks within a chain step", async () => {
1207
+ mockPi.onCall({ output: "Parallel task done" });
1208
+ const agents = [makeAgent("reviewer-a"), makeAgent("reviewer-b")];
1209
+
1210
+ const result = await executeChain(
1211
+ makeChainParams(
1212
+ [
1213
+ {
1214
+ parallel: [
1215
+ { agent: "reviewer-a", task: "Review auth module" },
1216
+ { agent: "reviewer-b", task: "Review data layer" },
1217
+ ],
1218
+ },
1219
+ ],
1220
+ agents,
1221
+ ),
1222
+ );
1223
+
1224
+ assert.ok(!result.isError, `should succeed: ${JSON.stringify(result.content)}`);
1225
+ assert.equal(result.details.results.length, 2);
1226
+ });
1227
+
1228
+ it("aggregates parallel outputs for next sequential step", async () => {
1229
+ mockPi.onCall({ output: "Review findings here" });
1230
+ const agents = [makeAgent("reviewer-a"), makeAgent("reviewer-b"), makeAgent("synthesizer")];
1231
+
1232
+ const result = await executeChain(
1233
+ makeChainParams(
1234
+ [
1235
+ {
1236
+ parallel: [
1237
+ { agent: "reviewer-a", task: "Review security" },
1238
+ { agent: "reviewer-b", task: "Review performance" },
1239
+ ],
1240
+ },
1241
+ { agent: "synthesizer" },
1242
+ ],
1243
+ agents,
1244
+ ),
1245
+ );
1246
+
1247
+ assert.ok(!result.isError);
1248
+ assert.equal(result.details.results.length, 3);
1249
+ const synthTask = result.details.results[2].task;
1250
+ assert.ok(
1251
+ synthTask.includes("=== Parallel Task 1 (reviewer-a) ==="),
1252
+ "synthesizer should include reviewer-a output block",
1253
+ );
1254
+ assert.ok(
1255
+ synthTask.includes("=== Parallel Task 2 (reviewer-b) ==="),
1256
+ "synthesizer should include reviewer-b output block",
1257
+ );
1258
+ });
1259
+
1260
+ it("passes completed parallel task outputs to later {outputs.name} references", async () => {
1261
+ mockPi.onCall({ matchArgIncludes: "Alpha", output: "Alpha named output" });
1262
+ mockPi.onCall({ matchArgIncludes: "Beta", output: "Beta named output" });
1263
+ mockPi.onCall({ output: "Final" });
1264
+ const agents = [makeAgent("alpha"), makeAgent("beta"), makeAgent("writer")];
1265
+
1266
+ const result = await executeChain(
1267
+ makeChainParams(
1268
+ [
1269
+ {
1270
+ parallel: [
1271
+ { agent: "alpha", task: "Alpha", as: "alphaOutput" },
1272
+ { agent: "beta", task: "Beta", as: "betaOutput" },
1273
+ ],
1274
+ },
1275
+ { agent: "writer", task: "Use {outputs.alphaOutput} and {outputs.betaOutput}" },
1276
+ ],
1277
+ agents,
1278
+ ),
1279
+ );
1280
+
1281
+ assert.ok(!result.isError);
1282
+ const finalTask = readCallArgs(2).at(-1) ?? "";
1283
+ assert.match(finalTask, /Alpha named output/);
1284
+ assert.match(finalTask, /Beta named output/);
1285
+ });
1286
+
1287
+ it("funnels an initial parallel step through one agent, then fans the funnel output back out", async () => {
1288
+ mockPi.onCall({ matchArgIncludes: "Scout API", output: "Scout A findings" });
1289
+ mockPi.onCall({ matchArgIncludes: "Scout UI", output: "Scout B findings" });
1290
+ mockPi.onCall({ matchArgIncludes: "Synthesize:", output: "Funnel synthesis" });
1291
+ mockPi.onCall({ matchArgIncludes: "Review funnel A:", output: "Reviewer A done" });
1292
+ mockPi.onCall({ matchArgIncludes: "Review funnel B:", output: "Reviewer B done" });
1293
+ const agents = [makeAgent("scout-a"), makeAgent("scout-b"), makeAgent("synthesizer"), makeAgent("review-a"), makeAgent("review-b")];
1294
+
1295
+ const result = await executeChain(
1296
+ makeChainParams(
1297
+ [
1298
+ {
1299
+ parallel: [
1300
+ { agent: "scout-a", task: "Scout API" },
1301
+ { agent: "scout-b", task: "Scout UI" },
1302
+ ],
1303
+ },
1304
+ { agent: "synthesizer", task: "Synthesize:\n{previous}" },
1305
+ {
1306
+ parallel: [
1307
+ { agent: "review-a", task: "Review funnel A:\n{previous}" },
1308
+ { agent: "review-b", task: "Review funnel B:\n{previous}" },
1309
+ ],
1310
+ },
1311
+ ],
1312
+ agents,
1313
+ ),
1314
+ );
1315
+
1316
+ assert.ok(!result.isError, `should succeed: ${JSON.stringify(result.content)}`);
1317
+ assert.deepEqual(result.details.results.map((entry) => entry.agent), ["scout-a", "scout-b", "synthesizer", "review-a", "review-b"]);
1318
+ assert.equal(result.details.totalSteps, 3);
1319
+ const funnelTask = readCallArgsMatching("Synthesize:").at(-1) ?? "";
1320
+ assert.match(funnelTask, /=== Parallel Task 1 \(scout-a\) ===/);
1321
+ assert.match(funnelTask, /Scout A findings/);
1322
+ assert.match(funnelTask, /=== Parallel Task 2 \(scout-b\) ===/);
1323
+ assert.match(funnelTask, /Scout B findings/);
1324
+ const fanoutTaskA = readCallArgsMatching("Review funnel A:").at(-1) ?? "";
1325
+ const fanoutTaskB = readCallArgsMatching("Review funnel B:").at(-1) ?? "";
1326
+ assert.match(fanoutTaskA, /Review funnel A:\nFunnel synthesis/);
1327
+ assert.match(fanoutTaskB, /Review funnel B:\nFunnel synthesis/);
1328
+ assert.equal(result.details.workflowGraph?.nodes[0]?.kind, "parallel-group");
1329
+ assert.equal(result.details.workflowGraph?.nodes[1]?.kind, "step");
1330
+ assert.equal(result.details.workflowGraph?.nodes[2]?.kind, "parallel-group");
1331
+ });
1332
+
1333
+ it("aggregates file-only parallel outputs as file references for the next step", async () => {
1334
+ mockPi.onCall({ output: "full parallel chain output\nwith details" });
1335
+ const agents = [makeAgent("reviewer-a"), makeAgent("reviewer-b"), makeAgent("synthesizer")];
1336
+
1337
+ const result = await executeChain(
1338
+ makeChainParams(
1339
+ [
1340
+ {
1341
+ parallel: [
1342
+ { agent: "reviewer-a", task: "Review A", output: "a.md", outputMode: "file-only" },
1343
+ { agent: "reviewer-b", task: "Review B", output: "b.md", outputMode: "file-only" },
1344
+ ],
1345
+ },
1346
+ { agent: "synthesizer" },
1347
+ ],
1348
+ agents,
1349
+ { chainDir: tempDir },
1350
+ ),
1351
+ );
1352
+
1353
+ assert.ok(!result.isError, `should succeed: ${JSON.stringify(result.content)}`);
1354
+ assert.doesNotMatch(result.details.results[0]?.finalOutput ?? "", /full parallel chain output/);
1355
+ assert.doesNotMatch(result.details.results[1]?.finalOutput ?? "", /full parallel chain output/);
1356
+ const synthTaskArg = readCallArgs(2).at(-1) ?? "";
1357
+ assert.match(synthTaskArg, /Output saved to:/);
1358
+ assert.match(synthTaskArg, /2 lines/);
1359
+ assert.doesNotMatch(synthTaskArg, /full parallel chain output/);
1360
+ });
1361
+
1362
+ it("rejects chain parallel file-only output without spawning siblings", async () => {
1363
+ const agents = [makeAgent("reviewer-a"), makeAgent("reviewer-b")];
1364
+
1365
+ const result = await executeChain(
1366
+ makeChainParams(
1367
+ [{
1368
+ parallel: [
1369
+ { agent: "reviewer-a", task: "Review A", outputMode: "file-only" },
1370
+ { agent: "reviewer-b", task: "Review B", output: "b.md" },
1371
+ ],
1372
+ }],
1373
+ agents,
1374
+ { chainDir: tempDir },
1375
+ ),
1376
+ );
1377
+
1378
+ assert.equal(result.isError, true);
1379
+ assert.match(result.content[0]?.text ?? "", /outputMode: "file-only"/);
1380
+ assert.equal(mockPi.callCount(), 0);
1381
+ });
1382
+
1383
+ it("detaches parallel chain children cleanly on intercom handoff", async () => {
1384
+ mockPi.onCall({
1385
+ steps: [
1386
+ { jsonl: [events.toolStart("intercom", { action: "send", to: "orchestrator" })] },
1387
+ { delay: 1000, jsonl: [events.assistantMessage("after handoff")] },
1388
+ ],
1389
+ });
1390
+ mockPi.onCall({ output: "Other task done" });
1391
+ const agents = [
1392
+ makeAgent("a", { systemPrompt: "Intercom orchestration channel:" }),
1393
+ makeAgent("b", { systemPrompt: "Intercom orchestration channel:" }),
1394
+ ];
1395
+ const intercomEvents = createEventBus();
1396
+ let detachEmitted = false;
1397
+
1398
+ const result = await executeChain(
1399
+ makeChainParams(
1400
+ [
1401
+ {
1402
+ parallel: [
1403
+ { agent: "a", task: "Send handoff" },
1404
+ { agent: "b", task: "Keep working" },
1405
+ ],
1406
+ },
1407
+ ],
1408
+ agents,
1409
+ {
1410
+ intercomEvents,
1411
+ onUpdate(update: { details?: { progress?: Array<{ currentTool?: string }> } }) {
1412
+ if (detachEmitted) return;
1413
+ if (!update.details?.progress?.some((entry) => entry.currentTool === "intercom")) return;
1414
+ detachEmitted = true;
1415
+ intercomEvents.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "chain-parallel-detach" });
1416
+ },
1417
+ },
1418
+ ),
1419
+ );
1420
+
1421
+ assert.equal(result.isError, undefined);
1422
+ assert.match(result.content[0]?.text ?? "", /Chain detached for intercom coordination/);
1423
+ assert.doesNotMatch(result.content[0]?.text ?? "", /resume/);
1424
+ assert.equal(detachEmitted, true);
1425
+ assert.equal(result.details.results.some((entry) => entry.detached === true && entry.exitCode === 0), true);
1426
+ });
1427
+
1428
+ it("stops a sequential chain when a child detaches for intercom coordination", async () => {
1429
+ mockPi.onCall({
1430
+ steps: [
1431
+ { jsonl: [events.toolStart("contact_supervisor", { reason: "need_decision", message: "Need a decision" })] },
1432
+ { delay: 1000, jsonl: [events.assistantMessage("after reply")] },
1433
+ ],
1434
+ });
1435
+ const agents = [
1436
+ makeAgent("a", { systemPrompt: "Intercom orchestration channel:" }),
1437
+ makeAgent("b"),
1438
+ ];
1439
+ const intercomEvents = createEventBus();
1440
+ let detachEmitted = false;
1441
+
1442
+ const result = await executeChain(
1443
+ makeChainParams(
1444
+ [
1445
+ { agent: "a", task: "Ask supervisor" },
1446
+ { agent: "b", task: "Must not run yet" },
1447
+ ],
1448
+ agents,
1449
+ {
1450
+ intercomEvents,
1451
+ onUpdate(update: { details?: { progress?: Array<{ currentTool?: string }> } }) {
1452
+ if (detachEmitted) return;
1453
+ if (!update.details?.progress?.some((entry) => entry.currentTool === "contact_supervisor")) return;
1454
+ detachEmitted = true;
1455
+ intercomEvents.emit(INTERCOM_DETACH_REQUEST_EVENT, { requestId: "chain-sequential-detach" });
1456
+ },
1457
+ },
1458
+ ),
1459
+ );
1460
+
1461
+ assert.equal(result.isError, undefined);
1462
+ assert.match(result.content[0]?.text ?? "", /Chain detached for intercom coordination/);
1463
+ assert.doesNotMatch(result.content[0]?.text ?? "", /resume/);
1464
+ assert.equal(detachEmitted, true);
1465
+ assert.equal(mockPi.callCount(), 1);
1466
+ });
1467
+
1468
+ it("fails chain on parallel step failure", async () => {
1469
+ mockPi.onCall({ exitCode: 1, stderr: "Parallel task failed" });
1470
+ const agents = [makeAgent("a"), makeAgent("b")];
1471
+
1472
+ const result = await executeChain(
1473
+ makeChainParams(
1474
+ [
1475
+ {
1476
+ parallel: [
1477
+ { agent: "a", task: "Task A" },
1478
+ { agent: "b", task: "Task B" },
1479
+ ],
1480
+ },
1481
+ ],
1482
+ agents,
1483
+ ),
1484
+ );
1485
+
1486
+ assert.ok(result.isError, "chain should fail when parallel step fails");
1487
+ });
1488
+
1489
+ it("rejects worktree parallel steps that set a different task cwd", async () => {
1490
+ const agents = [makeAgent("a"), makeAgent("b")];
1491
+ const result = await executeChain(
1492
+ makeChainParams(
1493
+ [
1494
+ {
1495
+ parallel: [
1496
+ { agent: "a", task: "Task A" },
1497
+ { agent: "b", task: "Task B", cwd: path.join(tempDir, "other") },
1498
+ ],
1499
+ worktree: true,
1500
+ },
1501
+ ],
1502
+ agents,
1503
+ ),
1504
+ );
1505
+
1506
+ assert.ok(result.isError, "chain should reject conflicting task cwd under worktree");
1507
+ assert.match(result.content[0]?.text ?? "", /worktree isolation uses the shared cwd/i);
1508
+ assert.match(result.content[0]?.text ?? "", /task 2 \(b\) sets cwd/i);
1509
+ });
1510
+
1511
+ it("sequential → parallel → sequential (mixed chain)", async () => {
1512
+ mockPi.onCall({ output: "Step complete" });
1513
+ const agents = [makeAgent("scout"), makeAgent("rev-a"), makeAgent("rev-b"), makeAgent("writer")];
1514
+
1515
+ const result = await executeChain(
1516
+ makeChainParams(
1517
+ [
1518
+ { agent: "scout", task: "Initial scan" },
1519
+ {
1520
+ parallel: [
1521
+ { agent: "rev-a", task: "Deep review A" },
1522
+ { agent: "rev-b", task: "Deep review B" },
1523
+ ],
1524
+ },
1525
+ { agent: "writer" },
1526
+ ],
1527
+ agents,
1528
+ ),
1529
+ );
1530
+
1531
+ assert.ok(!result.isError);
1532
+ assert.equal(result.details.results.length, 4);
1533
+ assert.equal(result.details.totalSteps, 3);
1534
+ });
1535
+ });