@vellumai/assistant 0.8.12 → 0.9.0-staging.2

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 (442) hide show
  1. package/AGENTS.md +0 -14
  2. package/ARCHITECTURE.md +45 -45
  3. package/README.md +1 -1
  4. package/bun.lock +200 -154
  5. package/docs/architecture/integrations.md +3 -3
  6. package/docs/architecture/memory.md +2 -2
  7. package/docs/architecture/security.md +10 -10
  8. package/docs/runbook-trusted-contacts.md +12 -12
  9. package/docs/skills.md +6 -6
  10. package/docs/workflows-testing.md +221 -0
  11. package/docs/workflows.md +510 -0
  12. package/examples/plugins/echo/README.md +5 -5
  13. package/knip.json +2 -0
  14. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
  15. package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
  16. package/openapi.yaml +7197 -5708
  17. package/package.json +8 -4
  18. package/scripts/generate-openapi.ts +66 -114
  19. package/src/__tests__/access-request-seed-content-blocks.test.ts +213 -0
  20. package/src/__tests__/adaptive-thinking-repair.test.ts +32 -3
  21. package/src/__tests__/agent-loop-output-hooks.test.ts +183 -0
  22. package/src/__tests__/agent-loop-regrowth-guard.test.ts +506 -0
  23. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  24. package/src/__tests__/agent-wake-override-profile.test.ts +77 -0
  25. package/src/__tests__/app-compiler.test.ts +7 -1
  26. package/src/__tests__/app-dir-path-guard.test.ts +27 -3
  27. package/src/__tests__/app-executors.test.ts +43 -0
  28. package/src/__tests__/approval-cascade.test.ts +0 -5
  29. package/src/__tests__/approval-routes-http.test.ts +91 -0
  30. package/src/__tests__/assistant-stream-state.test.ts +107 -0
  31. package/src/__tests__/browser-fill-credential.test.ts +3 -3
  32. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +1 -1
  33. package/src/__tests__/compaction-events.test.ts +63 -7
  34. package/src/__tests__/compaction-trail-store.test.ts +74 -1
  35. package/src/__tests__/compaction.benchmark.test.ts +63 -41
  36. package/src/__tests__/compactor-low-watermark-cut.test.ts +349 -0
  37. package/src/__tests__/context-window-manager-compact-retry.test.ts +64 -0
  38. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -5
  39. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -5
  40. package/src/__tests__/conversation-history-web-search.test.ts +7 -0
  41. package/src/__tests__/conversation-process-callsite.test.ts +0 -5
  42. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -5
  43. package/src/__tests__/conversation-queue.test.ts +0 -5
  44. package/src/__tests__/conversation-slash-queue.test.ts +0 -5
  45. package/src/__tests__/conversation-slash-unknown.test.ts +0 -5
  46. package/src/__tests__/conversation-speed-override.test.ts +0 -5
  47. package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
  48. package/src/__tests__/conversation-surfaces-task-progress.test.ts +67 -0
  49. package/src/__tests__/conversation-usage.test.ts +2 -0
  50. package/src/__tests__/conversation-workspace-injection.test.ts +0 -5
  51. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -5
  52. package/src/__tests__/credential-broker-browser-fill.test.ts +2 -2
  53. package/src/__tests__/credential-broker-server-use.test.ts +2 -2
  54. package/src/__tests__/credential-broker.test.ts +1 -1
  55. package/src/__tests__/credential-prompt-route.test.ts +417 -0
  56. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  57. package/src/__tests__/credential-vault.test.ts +37 -0
  58. package/src/__tests__/db-schedule-syntax-migration.test.ts +24 -0
  59. package/src/__tests__/dynamic-page-surface.test.ts +219 -0
  60. package/src/__tests__/empty-state-greeting-cache.test.ts +94 -0
  61. package/src/__tests__/gateway-flag-listener.test.ts +24 -7
  62. package/src/__tests__/guardian-action-sweep.test.ts +56 -219
  63. package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
  64. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  65. package/src/__tests__/list-messages-hidden-metadata.test.ts +99 -0
  66. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +87 -1
  67. package/src/__tests__/llm-resolver.test.ts +115 -0
  68. package/src/__tests__/managed-profile-guard.test.ts +6 -5
  69. package/src/__tests__/max-tokens-continue-hook.test.ts +184 -0
  70. package/src/__tests__/media-generate-image.test.ts +20 -9
  71. package/src/__tests__/mock-gateway-ipc.ts +23 -0
  72. package/src/__tests__/model-intents.test.ts +1 -1
  73. package/src/__tests__/normalize-onboarding.test.ts +26 -0
  74. package/src/__tests__/notification-decision-strategy.test.ts +4 -2
  75. package/src/__tests__/notification-telegram-adapter.test.ts +21 -3
  76. package/src/__tests__/pending-interactions-resolved-event.test.ts +62 -0
  77. package/src/__tests__/post-turn-tool-result-truncation.test.ts +72 -18
  78. package/src/__tests__/require-fresh-approval.test.ts +425 -1
  79. package/src/__tests__/resolve-app-id.test.ts +56 -0
  80. package/src/__tests__/runtime-events-sse-parity.test.ts +2 -0
  81. package/src/__tests__/schedule-routes-workflow-validation.test.ts +408 -0
  82. package/src/__tests__/schedule-routes.test.ts +257 -4
  83. package/src/__tests__/schedule-store.test.ts +60 -0
  84. package/src/__tests__/schedule-tools.test.ts +247 -2
  85. package/src/__tests__/skill-execute-input.test.ts +85 -0
  86. package/src/__tests__/skill-secret-handling-guard.test.ts +21 -20
  87. package/src/__tests__/skills.test.ts +3 -3
  88. package/src/__tests__/slack-app-setup-skill-regression.test.ts +1 -1
  89. package/src/__tests__/subagent-tool-filtering.test.ts +50 -0
  90. package/src/__tests__/subagent-tool-gate-mode.test.ts +547 -0
  91. package/src/__tests__/system-prompt.test.ts +1 -1
  92. package/src/__tests__/task-progress-nudge-hook.test.ts +372 -0
  93. package/src/__tests__/task-scheduler.test.ts +299 -0
  94. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +209 -0
  95. package/src/__tests__/tool-result-spool.test.ts +3 -1
  96. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +181 -0
  97. package/src/__tests__/workspace-migration-103-upgrade-quality-profile-to-opus-4-8.test.ts +174 -0
  98. package/src/agent/compaction-circuit.ts +11 -0
  99. package/src/agent/loop.ts +181 -12
  100. package/src/api/constants/call-sites.ts +12 -0
  101. package/src/api/events/assistant-thinking-delta.ts +10 -0
  102. package/src/api/events/usage-update.ts +7 -0
  103. package/src/api/index.ts +4 -1
  104. package/src/api/responses/memory-v3-selection-log.ts +18 -11
  105. package/src/approvals/approval-primitive.ts +2 -2
  106. package/src/background-wake/background-wake-routes.test.ts +5 -2
  107. package/src/bundler/compiler-tools.ts +1 -1
  108. package/src/bundler/package-resolver.ts +0 -1
  109. package/src/calls/call-domain.ts +1 -1
  110. package/src/calls/guardian-action-sweep.ts +16 -93
  111. package/src/cli/AGENTS.md +4 -0
  112. package/src/cli/commands/__tests__/schedules.test.ts +430 -1
  113. package/src/cli/commands/credentials.ts +28 -24
  114. package/src/cli/commands/image-generation.ts +23 -9
  115. package/src/cli/commands/notifications.ts +1 -1
  116. package/src/cli/commands/plugins.ts +89 -46
  117. package/src/cli/commands/schedules.ts +384 -11
  118. package/src/cli/lib/__tests__/inspect-plugin.test.ts +69 -5
  119. package/src/cli/lib/__tests__/install-from-github.test.ts +15 -0
  120. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +81 -4
  121. package/src/cli/lib/inspect-plugin.ts +62 -1
  122. package/src/cli/lib/install-from-github.ts +52 -5
  123. package/src/cli/lib/upgrade-plugin.ts +18 -0
  124. package/src/config/__tests__/workflows-schema.test.ts +60 -0
  125. package/src/config/bundled-skills/acp/SKILL.md +2 -2
  126. package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
  127. package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
  128. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
  129. package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
  130. package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
  131. package/src/config/bundled-skills/image-studio/SKILL.md +66 -19
  132. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -6
  133. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +22 -3
  134. package/src/config/bundled-skills/personal-page/SKILL.md +57 -0
  135. package/src/config/bundled-skills/personal-page/TOOLS.json +27 -0
  136. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +17 -0
  137. package/src/config/bundled-skills/schedule/SKILL.md +7 -2
  138. package/src/config/bundled-skills/schedule/TOOLS.json +48 -4
  139. package/src/config/bundled-skills/workflows/SKILL.md +214 -0
  140. package/src/config/bundled-skills/workflows/TOOLS.json +84 -0
  141. package/src/config/bundled-skills/workflows/tools/manage-workflows.ts +12 -0
  142. package/src/config/bundled-skills/workflows/tools/run-workflow.ts +12 -0
  143. package/src/config/bundled-tool-registry.ts +14 -2
  144. package/src/config/call-site-defaults.ts +5 -0
  145. package/src/config/feature-flag-registry.json +12 -4
  146. package/src/config/llm-context-resolution.ts +8 -0
  147. package/src/config/llm-resolver.ts +30 -0
  148. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +22 -0
  149. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +16 -0
  150. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +77 -0
  151. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +57 -0
  152. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +28 -0
  153. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +234 -0
  154. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +48 -0
  155. package/src/config/preloaded-apps/personal-page/src/components/media.ts +16 -0
  156. package/src/config/preloaded-apps/personal-page/src/index.html +20 -0
  157. package/src/config/preloaded-apps/personal-page/src/main.tsx +7 -0
  158. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +82 -0
  159. package/src/config/preloaded-apps/personal-page/src/styles.css +759 -0
  160. package/src/config/schema.ts +2 -0
  161. package/src/config/schemas/call-site-catalog.ts +7 -0
  162. package/src/config/schemas/heartbeat.ts +4 -1
  163. package/src/config/schemas/llm.ts +33 -26
  164. package/src/config/schemas/memory-retrospective.ts +19 -0
  165. package/src/config/schemas/platform.ts +8 -0
  166. package/src/config/schemas/services.ts +5 -2
  167. package/src/config/schemas/workflows.ts +42 -0
  168. package/src/config/skills.ts +3 -3
  169. package/src/context/compactor.ts +273 -39
  170. package/src/context/post-turn-tool-result-truncation.ts +23 -6
  171. package/src/context/tool-result-spool.ts +12 -17
  172. package/src/credential-execution/executable-discovery.ts +1 -1
  173. package/src/credential-execution/process-manager.ts +37 -3
  174. package/src/credential-execution/prompted-credential.ts +205 -0
  175. package/src/daemon/conversation-agent-loop-handlers.ts +14 -0
  176. package/src/daemon/conversation-process.ts +11 -2
  177. package/src/daemon/conversation-surfaces.ts +167 -3
  178. package/src/daemon/conversation-tool-setup.ts +103 -26
  179. package/src/daemon/conversation-usage.ts +2 -0
  180. package/src/daemon/conversation.ts +115 -11
  181. package/src/daemon/handlers/shared.ts +26 -14
  182. package/src/daemon/host-cu-proxy.ts +15 -12
  183. package/src/daemon/host-file-proxy.ts +15 -12
  184. package/src/daemon/host-transfer-proxy.ts +30 -24
  185. package/src/daemon/lifecycle.ts +40 -3
  186. package/src/daemon/message-protocol.ts +3 -0
  187. package/src/daemon/message-types/messages.ts +2 -10
  188. package/src/daemon/message-types/workflows.ts +49 -0
  189. package/src/daemon/parse-actual-tokens-from-error.test.ts +62 -1
  190. package/src/daemon/parse-actual-tokens-from-error.ts +43 -4
  191. package/src/daemon/process-message.ts +6 -0
  192. package/src/daemon/tool-setup-types.ts +57 -0
  193. package/src/daemon/wake-conversation-ops.ts +18 -0
  194. package/src/heartbeat/heartbeat-run-store.ts +8 -2
  195. package/src/home/feed-types.ts +1 -1
  196. package/src/ipc/gateway-flag-listener.ts +28 -6
  197. package/src/mcp/mcp-auth-state.ts +8 -20
  198. package/src/media/__tests__/image-models.test.ts +57 -0
  199. package/src/media/image-models.ts +66 -0
  200. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +38 -0
  201. package/src/memory/__tests__/find-most-recent-retrospective-for.test.ts +12 -2
  202. package/src/memory/__tests__/memory-retrospective-job.test.ts +911 -34
  203. package/src/memory/__tests__/memory-retrospective-startup-cleanup.test.ts +227 -5
  204. package/src/memory/__tests__/memory-retrospective-state.test.ts +195 -0
  205. package/src/memory/__tests__/preloaded-apps.test.ts +85 -0
  206. package/src/memory/auto-analysis-enqueue.ts +14 -1
  207. package/src/memory/compaction-log-store-clickhouse.ts +6 -4
  208. package/src/memory/conversation-crud.ts +9 -2
  209. package/src/memory/conversation-disk-view.ts +1 -1
  210. package/src/memory/conversation-queries.ts +22 -7
  211. package/src/memory/db-init.ts +20 -0
  212. package/src/memory/db-maintenance.ts +16 -0
  213. package/src/memory/embedding-runtime-manager.ts +1 -1
  214. package/src/memory/llm-request-log-source-clickhouse.ts +112 -14
  215. package/src/memory/llm-request-log-source-local.ts +19 -1
  216. package/src/memory/llm-request-log-source.ts +35 -6
  217. package/src/memory/llm-request-log-store.ts +90 -2
  218. package/src/memory/memory-retrospective-constants.ts +9 -0
  219. package/src/memory/memory-retrospective-enqueue.ts +3 -6
  220. package/src/memory/memory-retrospective-fork-boundary.ts +94 -0
  221. package/src/memory/memory-retrospective-job.ts +500 -208
  222. package/src/memory/memory-retrospective-startup-cleanup.ts +97 -19
  223. package/src/memory/memory-retrospective-state.ts +85 -2
  224. package/src/memory/migrations/281-memory-retrospective-remembered-log.ts +40 -0
  225. package/src/memory/migrations/282-schedule-inference-profile.test.ts +77 -0
  226. package/src/memory/migrations/282-schedule-inference-profile.ts +26 -0
  227. package/src/memory/migrations/283-memory-v3-selections-message-id-and-sections.test.ts +102 -0
  228. package/src/memory/migrations/283-memory-v3-selections-message-id-and-sections.ts +53 -0
  229. package/src/memory/migrations/284-workflow-runs.ts +51 -0
  230. package/src/memory/migrations/285-schedule-workflow-mode.ts +26 -0
  231. package/src/memory/migrations/286-workflow-run-trust.ts +27 -0
  232. package/src/memory/migrations/287-conversation-origin-channel-index.ts +15 -0
  233. package/src/memory/migrations/288-backfill-origin-channel-from-bindings.ts +43 -0
  234. package/src/memory/migrations/289-contact-channels-unique-ext-user.ts +115 -0
  235. package/src/memory/migrations/290-schedule-capabilities.test.ts +77 -0
  236. package/src/memory/migrations/290-schedule-capabilities.ts +25 -0
  237. package/src/memory/migrations/__tests__/281-memory-retrospective-remembered-log.test.ts +96 -0
  238. package/src/memory/migrations/__tests__/289-contact-channels-unique-ext-user.test.ts +571 -0
  239. package/src/memory/migrations/index.ts +10 -0
  240. package/src/memory/preloaded-apps.ts +116 -0
  241. package/src/memory/schema/infrastructure.ts +4 -0
  242. package/src/memory/schema/memory-core.ts +4 -0
  243. package/src/memory/v2/__tests__/concept-page-frontmatter-schema.test.ts +45 -0
  244. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +11 -7
  245. package/src/memory/v2/__tests__/page-store.test.ts +13 -2
  246. package/src/memory/v2/__tests__/qdrant.test.ts +24 -0
  247. package/src/memory/v2/frontmatter-sweep.ts +7 -6
  248. package/src/memory/v2/page-store.ts +4 -3
  249. package/src/memory/v2/qdrant.ts +42 -3
  250. package/src/memory/v2/types.ts +16 -10
  251. package/src/messaging/draft-store.ts +1 -1
  252. package/src/notifications/access-request-copy.ts +200 -113
  253. package/src/notifications/adapters/slack.ts +250 -111
  254. package/src/notifications/adapters/telegram.ts +7 -44
  255. package/src/notifications/approval-card-builder.ts +93 -0
  256. package/src/notifications/broadcaster.ts +74 -0
  257. package/src/notifications/conversation-pairing.ts +8 -6
  258. package/src/notifications/copy-composer.ts +32 -26
  259. package/src/notifications/decision-engine.ts +59 -7
  260. package/src/notifications/guardian-question-mode.ts +145 -155
  261. package/src/notifications/home-feed-side-effect.ts +28 -11
  262. package/src/notifications/notification-utils.ts +66 -0
  263. package/src/notifications/signal.ts +6 -0
  264. package/src/notifications/tool-approval-copy.ts +142 -0
  265. package/src/notifications/types.ts +19 -0
  266. package/src/permissions/threshold.ts +11 -0
  267. package/src/plugin-api/types.ts +16 -4
  268. package/src/plugins/defaults/compaction/window-manager.ts +44 -0
  269. package/src/plugins/defaults/index.ts +46 -0
  270. package/src/plugins/defaults/max-tokens-continue/continue-state-store.ts +53 -0
  271. package/src/plugins/defaults/max-tokens-continue/hooks/post-model-call.ts +80 -0
  272. package/src/plugins/defaults/max-tokens-continue/hooks/stop.ts +20 -0
  273. package/src/plugins/defaults/max-tokens-continue/package.json +14 -0
  274. package/src/plugins/defaults/memory-retrieval/hooks/__tests__/user-prompt-submit.test.ts +37 -0
  275. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +29 -1
  276. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +8 -3
  277. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +4 -2
  278. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +28 -18
  279. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +67 -0
  280. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +122 -22
  281. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +2 -0
  282. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +63 -1
  283. package/src/plugins/defaults/memory-v3-shadow/injector.ts +61 -18
  284. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +1 -1
  285. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +39 -10
  286. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +34 -1
  287. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +112 -47
  288. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +78 -15
  289. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +206 -0
  290. package/src/plugins/defaults/task-progress-nudge/package.json +15 -0
  291. package/src/prompts/__tests__/system-prompt.test.ts +100 -1
  292. package/src/prompts/__tests__/task-progress-hint-section.test.ts +5 -7
  293. package/src/prompts/normalize-onboarding.ts +2 -0
  294. package/src/prompts/persona-resolver.ts +3 -0
  295. package/src/prompts/system-prompt.ts +51 -2
  296. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +3 -1
  297. package/src/prompts/templates/system-sections.ts +8 -3
  298. package/src/providers/call-site-routing.ts +6 -3
  299. package/src/providers/fireworks/client.ts +3 -0
  300. package/src/providers/inference/auth.ts +52 -46
  301. package/src/providers/model-intents.ts +2 -2
  302. package/src/providers/openai/__tests__/coerce-object-args.test.ts +105 -0
  303. package/src/providers/openai/chat-completions-provider.ts +47 -9
  304. package/src/providers/openai/coerce-object-args.ts +104 -0
  305. package/src/providers/retry.ts +8 -5
  306. package/src/providers/types.ts +10 -0
  307. package/src/runtime/__tests__/agent-wake.test.ts +629 -7
  308. package/src/runtime/access-request-helper.ts +16 -9
  309. package/src/runtime/agent-wake.ts +302 -51
  310. package/src/runtime/assistant-stream-state.ts +141 -8
  311. package/src/runtime/background-job-runner.ts +9 -0
  312. package/src/runtime/channel-approval-types.ts +1 -0
  313. package/src/runtime/channel-invite-transports/telegram.ts +6 -5
  314. package/src/runtime/channel-invite-transports/voice.ts +2 -2
  315. package/src/runtime/channel-invite-types.ts +4 -2
  316. package/src/runtime/channel-retry-sweep.ts +19 -41
  317. package/src/runtime/finalize-event-delivery.ts +72 -0
  318. package/src/runtime/guardian-action-message-composer.ts +0 -54
  319. package/src/runtime/http-server.ts +6 -14
  320. package/src/runtime/http-types.ts +0 -1
  321. package/src/runtime/message-composer-types.ts +0 -9
  322. package/src/runtime/middleware/__tests__/rate-limiter.test.ts +63 -0
  323. package/src/runtime/middleware/auth.ts +27 -3
  324. package/src/runtime/middleware/rate-limiter.ts +28 -1
  325. package/src/runtime/migrations/vbundle-builder.ts +6 -5
  326. package/src/runtime/pending-interactions.ts +20 -1
  327. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +232 -173
  328. package/src/runtime/routes/__tests__/plugins-routes.test.ts +18 -0
  329. package/src/runtime/routes/__tests__/retrospective-routes.test.ts +436 -0
  330. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +11 -0
  331. package/src/runtime/routes/approval-routes.ts +35 -8
  332. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -1
  333. package/src/runtime/routes/btw-routes.ts +37 -1
  334. package/src/runtime/routes/channel-delivery-routes.ts +11 -7
  335. package/src/runtime/routes/channel-route-definitions.ts +3 -0
  336. package/src/runtime/routes/channel-route-shared.ts +3 -1
  337. package/src/runtime/routes/consolidation-routes.ts +17 -13
  338. package/src/runtime/routes/conversation-compaction-routes.ts +159 -119
  339. package/src/runtime/routes/conversation-list-routes.ts +41 -4
  340. package/src/runtime/routes/conversation-query-routes.ts +196 -11
  341. package/src/runtime/routes/conversation-routes.ts +15 -1
  342. package/src/runtime/routes/credential-prompt-routes.ts +39 -17
  343. package/src/runtime/routes/empty-state-greeting-cache.ts +65 -0
  344. package/src/runtime/routes/heartbeat-routes.ts +18 -13
  345. package/src/runtime/routes/image-generation-routes.ts +20 -2
  346. package/src/runtime/routes/inbound-message-handler.ts +13 -12
  347. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +32 -31
  348. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
  349. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
  350. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
  351. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
  352. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
  353. package/src/runtime/routes/index.ts +6 -0
  354. package/src/runtime/routes/log-export/AGENTS.md +1 -1
  355. package/src/runtime/routes/log-export/workspace-allowlist.ts +1 -1
  356. package/src/runtime/routes/migration-routes.ts +5 -9
  357. package/src/runtime/routes/plugins-routes.ts +26 -0
  358. package/src/runtime/routes/ps-routes.ts +10 -8
  359. package/src/runtime/routes/retrospective-routes.ts +235 -0
  360. package/src/runtime/routes/runs-pagination.ts +75 -0
  361. package/src/runtime/routes/schedule-routes.ts +367 -59
  362. package/src/runtime/routes/sounds-config-routes.ts +239 -0
  363. package/src/runtime/routes/surface-action-routes.ts +84 -4
  364. package/src/runtime/routes/workflow-routes.test.ts +372 -0
  365. package/src/runtime/routes/workflow-routes.ts +363 -0
  366. package/src/runtime/routes/workspace-routes.test.ts +61 -1
  367. package/src/runtime/routes/workspace-routes.ts +26 -1
  368. package/src/runtime/services/__tests__/analyze-conversation.test.ts +38 -0
  369. package/src/runtime/services/analyze-conversation.ts +26 -13
  370. package/src/schedule/inference-profile.ts +28 -0
  371. package/src/schedule/schedule-store.ts +152 -4
  372. package/src/schedule/scheduler-types.ts +6 -0
  373. package/src/schedule/scheduler.ts +96 -0
  374. package/src/security/secret-allowlist.ts +1 -1
  375. package/src/skills/path-classifier.ts +1 -1
  376. package/src/tools/apps/executors.ts +23 -0
  377. package/src/tools/apps/resolve-app-id.ts +42 -0
  378. package/src/tools/browser/browser-execution.ts +9 -11
  379. package/src/tools/credentials/broker.ts +4 -4
  380. package/src/tools/credentials/vault.ts +26 -137
  381. package/src/tools/executor.ts +69 -0
  382. package/src/tools/flag-gated-tools.test.ts +76 -0
  383. package/src/tools/permission-checker.ts +8 -1
  384. package/src/tools/registry.ts +51 -0
  385. package/src/tools/schedule/create.ts +77 -1
  386. package/src/tools/schedule/list.ts +1 -0
  387. package/src/tools/schedule/update.ts +73 -1
  388. package/src/tools/skills/execute.ts +56 -0
  389. package/src/tools/terminal/shell.ts +1 -1
  390. package/src/tools/ui-surface/definitions.ts +91 -2
  391. package/src/tools/workflows/manage-workflows.ts +183 -0
  392. package/src/tools/workflows/run-workflow.test.ts +442 -0
  393. package/src/tools/workflows/run-workflow.ts +88 -0
  394. package/src/types/onboarding-context.ts +2 -0
  395. package/src/usage/attribution.ts +24 -0
  396. package/src/util/canonicalize-identity.ts +12 -3
  397. package/src/util/platform.ts +17 -17
  398. package/src/watcher/__tests__/engine.test.ts +24 -0
  399. package/src/watcher/__tests__/telemetry.test.ts +135 -0
  400. package/src/watcher/engine.ts +7 -0
  401. package/src/watcher/telemetry.ts +74 -0
  402. package/src/workflows/capabilities.test.ts +365 -0
  403. package/src/workflows/capabilities.ts +359 -0
  404. package/src/workflows/deterministic-stringify.ts +27 -0
  405. package/src/workflows/engine-integration.test.ts +656 -0
  406. package/src/workflows/engine.test.ts +1144 -0
  407. package/src/workflows/engine.ts +1078 -0
  408. package/src/workflows/fanout-load.test.ts +168 -0
  409. package/src/workflows/journal-store.test.ts +369 -0
  410. package/src/workflows/journal-store.ts +470 -0
  411. package/src/workflows/leaf-runner.test.ts +704 -0
  412. package/src/workflows/leaf-runner.ts +589 -0
  413. package/src/workflows/library.test.ts +134 -0
  414. package/src/workflows/library.ts +124 -0
  415. package/src/workflows/run-manager.test.ts +711 -0
  416. package/src/workflows/run-manager.ts +593 -0
  417. package/src/workflows/sandbox-escape.test.ts +339 -0
  418. package/src/workflows/sandbox.test.ts +251 -0
  419. package/src/workflows/sandbox.ts +447 -0
  420. package/src/workspace/adaptive-thinking-repair.ts +33 -11
  421. package/src/workspace/migrations/021-move-signals-to-workspace.ts +1 -1
  422. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +1 -1
  423. package/src/workspace/migrations/026-backfill-install-meta.ts +1 -1
  424. package/src/workspace/migrations/030-seed-pkb-autoinject.ts +2 -1
  425. package/src/workspace/migrations/031-drop-user-md.ts +1 -4
  426. package/src/workspace/migrations/048-remove-workspace-hooks.ts +1 -1
  427. package/src/workspace/migrations/056-release-notes-inference-profile-reordering.ts +5 -2
  428. package/src/workspace/migrations/061-move-backup-key-to-workspace.ts +1 -1
  429. package/src/workspace/migrations/082-backfill-managed-profile-labels.ts +8 -2
  430. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +41 -14
  431. package/src/workspace/migrations/102-preserve-heartbeat-enabled-for-existing-workspaces.ts +69 -0
  432. package/src/workspace/migrations/103-upgrade-quality-profile-to-opus-4-8.ts +83 -0
  433. package/src/workspace/migrations/104-recheck-adaptive-thinking-model-implied-anthropic.ts +133 -0
  434. package/src/workspace/migrations/registry.ts +6 -0
  435. package/src/workspace/migrations/runner.ts +1 -1
  436. package/tsconfig.json +1 -1
  437. package/src/__tests__/guardian-action-copy-generator.test.ts +0 -200
  438. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -579
  439. package/src/__tests__/guardian-action-store.test.ts +0 -106
  440. package/src/daemon/guardian-action-generators.ts +0 -71
  441. package/src/memory/guardian-action-store.ts +0 -484
  442. package/src/runtime/guardian-action-grant-minter.ts +0 -150
@@ -0,0 +1,1144 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ // Silence the logger.
7
+ mock.module("../util/logger.js", () => ({
8
+ getLogger: () =>
9
+ new Proxy({} as Record<string, unknown>, {
10
+ get: () => () => {},
11
+ }),
12
+ }));
13
+
14
+ import type { WorkflowsConfig } from "../config/schemas/workflows.js";
15
+ import type { TrustContext } from "../daemon/trust-context.js";
16
+ import { getSqlite } from "../memory/db-connection.js";
17
+ import { initializeDb } from "../memory/db-init.js";
18
+ import type { ResolvedCapabilities } from "./capabilities.js";
19
+ import { executeWorkflow, extractWorkflowMeta } from "./engine.js";
20
+ import * as journalStore from "./journal-store.js";
21
+ import type { LeafResult, RunLeafOptions } from "./leaf-runner.js";
22
+
23
+ initializeDb();
24
+
25
+ function resetTables(): void {
26
+ getSqlite().exec("DELETE FROM workflow_journal");
27
+ getSqlite().exec("DELETE FROM workflow_runs");
28
+ }
29
+
30
+ /** Poll until `predicate()` is true (or a short deadline elapses). */
31
+ async function waitUntil(predicate: () => boolean): Promise<void> {
32
+ const deadline = Date.now() + 1000;
33
+ while (Date.now() < deadline) {
34
+ if (predicate()) return;
35
+ await new Promise((r) => setTimeout(r, 2));
36
+ }
37
+ throw new Error("Timed out waiting for condition");
38
+ }
39
+
40
+ const TRUST: TrustContext = { sourceChannel: "vellum", trustClass: "guardian" };
41
+
42
+ const CAPABILITIES: ResolvedCapabilities = {
43
+ tools: [],
44
+ hostFunctions: [],
45
+ persona: false,
46
+ };
47
+
48
+ function configWith(overrides: Partial<WorkflowsConfig> = {}): WorkflowsConfig {
49
+ return {
50
+ maxAgentsPerRun: 500,
51
+ maxConcurrentLeaves: 6,
52
+ maxConcurrentRuns: 3,
53
+ journalRetentionDays: 30,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Fake leaf runner. Records every prompt it is called with, tracks the
60
+ * concurrency high-water mark (so a test can assert the cap), and returns a
61
+ * deterministic echo output with fixed token usage. An optional `failOn`
62
+ * predicate makes a leaf throw.
63
+ */
64
+ function makeFakeRunner(opts?: {
65
+ delayMs?: number;
66
+ failOn?: (prompt: string) => boolean;
67
+ onCall?: (prompt: string) => void;
68
+ }) {
69
+ const prompts: string[] = [];
70
+ const calls: RunLeafOptions[] = [];
71
+ let inFlight = 0;
72
+ let highWater = 0;
73
+
74
+ const runner = async (leafOpts: RunLeafOptions): Promise<LeafResult> => {
75
+ prompts.push(leafOpts.prompt);
76
+ calls.push(leafOpts);
77
+ opts?.onCall?.(leafOpts.prompt);
78
+ inFlight += 1;
79
+ highWater = Math.max(highWater, inFlight);
80
+ try {
81
+ if (opts?.delayMs) {
82
+ await new Promise((r) => setTimeout(r, opts.delayMs));
83
+ } else {
84
+ await Promise.resolve();
85
+ }
86
+ if (opts?.failOn?.(leafOpts.prompt)) {
87
+ throw new Error(`leaf failed: ${leafOpts.prompt}`);
88
+ }
89
+ return {
90
+ output: `out:${leafOpts.prompt}`,
91
+ inputTokens: 10,
92
+ outputTokens: 5,
93
+ toolCallCount: 0,
94
+ };
95
+ } finally {
96
+ inFlight -= 1;
97
+ }
98
+ };
99
+
100
+ return {
101
+ runner: runner as unknown as typeof import("./leaf-runner.js").runLeaf,
102
+ prompts,
103
+ calls,
104
+ get highWater() {
105
+ return highWater;
106
+ },
107
+ get inFlight() {
108
+ return inFlight;
109
+ },
110
+ };
111
+ }
112
+
113
+ const META = `export const meta = { name: "test-wf", description: "a test workflow" };\n`;
114
+
115
+ function execute(
116
+ runId: string,
117
+ scriptSource: string,
118
+ runner: typeof import("./leaf-runner.js").runLeaf,
119
+ config: WorkflowsConfig = configWith(),
120
+ args: unknown = null,
121
+ capabilities: ResolvedCapabilities = CAPABILITIES,
122
+ signal?: AbortSignal,
123
+ ) {
124
+ return executeWorkflow({
125
+ runId,
126
+ scriptSource: META + scriptSource,
127
+ args,
128
+ capabilities,
129
+ config,
130
+ journal: journalStore,
131
+ leafRunner: runner,
132
+ trustContext: TRUST,
133
+ ...(signal ? { signal } : {}),
134
+ });
135
+ }
136
+
137
+ describe("extractWorkflowMeta", () => {
138
+ test("extracts a pure-literal meta", () => {
139
+ expect(
140
+ extractWorkflowMeta(
141
+ `export const meta = { name: "n", description: "d" };`,
142
+ ),
143
+ ).toEqual({ name: "n", description: "d" });
144
+ });
145
+
146
+ test("rejects a missing meta", () => {
147
+ expect(() => extractWorkflowMeta(`const x = 1;`)).toThrow();
148
+ });
149
+
150
+ test("rejects a non-string name/description", () => {
151
+ expect(() =>
152
+ extractWorkflowMeta(`export const meta = { name: 1, description: "d" };`),
153
+ ).toThrow();
154
+ });
155
+
156
+ test("accepts single-quoted and unquoted-key literals", () => {
157
+ expect(
158
+ extractWorkflowMeta(
159
+ `export const meta = { name: 'n', description: 'd', };`,
160
+ ),
161
+ ).toEqual({ name: "n", description: "d" });
162
+ });
163
+
164
+ test("rejects a computed meta without executing it (no host eval)", () => {
165
+ // A function call in the literal must NOT run in the host process — it
166
+ // simply fails to JSON-parse and is rejected.
167
+ (globalThis as Record<string, unknown>).__pwned = false;
168
+ expect(() =>
169
+ extractWorkflowMeta(
170
+ `export const meta = { name: (((globalThis).__pwned = true), "n"), description: "d" };`,
171
+ ),
172
+ ).toThrow();
173
+ expect((globalThis as Record<string, unknown>).__pwned).toBe(false);
174
+ delete (globalThis as Record<string, unknown>).__pwned;
175
+ });
176
+ });
177
+
178
+ describe("executeWorkflow — export stripping touches only real top-level decls", () => {
179
+ beforeEach(resetTables);
180
+
181
+ test("preserves `export` inside a multiline template-literal prompt", async () => {
182
+ const fake = makeFakeRunner();
183
+ // The leaf prompt is a TS snippet whose lines begin with `export ...`. The
184
+ // engine must NOT rewrite literal text — only genuine top-level decls — or a
185
+ // workflow asking a leaf to inspect/emit TypeScript gets a corrupted prompt.
186
+ const script = [
187
+ "const snippet = `Review this module:",
188
+ "export const config = { debug: true };",
189
+ "export function run() { return 1; }",
190
+ "`;",
191
+ "return agent(snippet);",
192
+ ].join("\n");
193
+ const res = await execute("wf-tmpl-export", script, fake.runner);
194
+ expect(res.status).toBe("completed");
195
+ expect(fake.prompts[0]).toBe(
196
+ "Review this module:\n" +
197
+ "export const config = { debug: true };\n" +
198
+ "export function run() { return 1; }\n",
199
+ );
200
+ });
201
+
202
+ test("still strips real top-level exports (script stays runnable)", async () => {
203
+ const fake = makeFakeRunner();
204
+ // A top-level `export const` would be a syntax error inside the sandbox's
205
+ // function-body wrapper; stripping it is what keeps the script runnable.
206
+ const res = await execute(
207
+ "wf-real-export",
208
+ `export const greeting = "hello";\nreturn agent(greeting);`,
209
+ fake.runner,
210
+ );
211
+ expect(res.status).toBe("completed");
212
+ expect(fake.prompts[0]).toBe("hello");
213
+ });
214
+
215
+ test("a regex literal containing a backtick does not derail stripping", async () => {
216
+ const fake = makeFakeRunner();
217
+ // A naive template-tracking transform would treat the backtick inside the
218
+ // regex as opening a template literal and fail to strip the next line's
219
+ // top-level export, breaking the script. The lexer skips regex bodies.
220
+ const res = await execute(
221
+ "wf-regex-tick",
222
+ "const re = /[`]/;\nexport const greeting = re.source;\nreturn agent(greeting);",
223
+ fake.runner,
224
+ );
225
+ expect(res.status).toBe("completed");
226
+ expect(fake.prompts[0]).toBe("[`]");
227
+ });
228
+
229
+ test("a line comment containing a backtick does not derail stripping", async () => {
230
+ const fake = makeFakeRunner();
231
+ const res = await execute(
232
+ "wf-linecomment-tick",
233
+ 'const x = 1; // a ` backtick in a comment\nexport const greeting = "hi";\nreturn agent(greeting);',
234
+ fake.runner,
235
+ );
236
+ expect(res.status).toBe("completed");
237
+ expect(fake.prompts[0]).toBe("hi");
238
+ });
239
+
240
+ test("template `${}` interpolation returns to code so the next export strips", async () => {
241
+ const fake = makeFakeRunner();
242
+ const script = [
243
+ 'const name = "world";',
244
+ "const msg = `hello ${name}!`;",
245
+ "export const out = msg;",
246
+ "return agent(out);",
247
+ ].join("\n");
248
+ const res = await execute("wf-interp-export", script, fake.runner);
249
+ expect(res.status).toBe("completed");
250
+ expect(fake.prompts[0]).toBe("hello world!");
251
+ });
252
+
253
+ test("preserves `export` inside a block comment embedded in a prompt", async () => {
254
+ const fake = makeFakeRunner();
255
+ const script = [
256
+ "const snippet = `/*",
257
+ "export const x = 1;",
258
+ "*/`;",
259
+ "return agent(snippet);",
260
+ ].join("\n");
261
+ const res = await execute("wf-blockcomment-export", script, fake.runner);
262
+ expect(res.status).toBe("completed");
263
+ expect(fake.prompts[0]).toBe("/*\nexport const x = 1;\n*/");
264
+ });
265
+ });
266
+
267
+ describe("executeWorkflow — fan-out & aggregation", () => {
268
+ beforeEach(resetTables);
269
+
270
+ test("map/parallel aggregates results and respects the concurrency cap", async () => {
271
+ const fake = makeFakeRunner({ delayMs: 8 });
272
+ const items = Array.from({ length: 20 }, (_, i) => `item-${i}`);
273
+
274
+ const res = await execute(
275
+ "wf-map",
276
+ `const results = map(args.items, (it) => leaf("task:" + it));
277
+ return results;`,
278
+ fake.runner,
279
+ configWith({ maxConcurrentLeaves: 4 }),
280
+ { items },
281
+ );
282
+
283
+ expect(res.status).toBe("completed");
284
+ expect(res.result).toEqual(items.map((it) => `out:task:${it}`));
285
+ expect(res.agentsSpawned).toBe(20);
286
+ expect(res.inputTokens).toBe(200);
287
+ expect(res.outputTokens).toBe(100);
288
+ // Concurrency high-water never exceeds the cap.
289
+ expect(fake.highWater).toBeLessThanOrEqual(4);
290
+ expect(fake.highWater).toBeGreaterThan(1);
291
+ });
292
+
293
+ test("parallel results stay in spec-array order despite completion order", async () => {
294
+ // Earlier items take longer, so completion order is reversed.
295
+ const fake = makeFakeRunner();
296
+ const res = await execute(
297
+ "wf-order",
298
+ `return parallel([leaf("a"), leaf("b"), leaf("c")]);`,
299
+ fake.runner,
300
+ );
301
+ expect(res.result).toEqual(["out:a", "out:b", "out:c"]);
302
+ });
303
+
304
+ test("a failed leaf inside parallel yields null (never throws)", async () => {
305
+ const fake = makeFakeRunner({ failOn: (p) => p === "boom" });
306
+ const res = await execute(
307
+ "wf-fail",
308
+ `return parallel([leaf("ok1"), leaf("boom"), leaf("ok2")]);`,
309
+ fake.runner,
310
+ );
311
+ expect(res.status).toBe("completed");
312
+ expect(res.result).toEqual(["out:ok1", null, "out:ok2"]);
313
+ });
314
+
315
+ test("agent() runs one sequential leaf and returns its output", async () => {
316
+ const fake = makeFakeRunner();
317
+ const res = await execute(
318
+ "wf-agent",
319
+ `const a = agent("first");
320
+ const b = agent("second");
321
+ return [a, b];`,
322
+ fake.runner,
323
+ );
324
+ expect(res.result).toEqual(["out:first", "out:second"]);
325
+ expect(fake.prompts).toEqual(["first", "second"]);
326
+ });
327
+
328
+ test("usage() reflects accumulated agents/tokens mid-script", async () => {
329
+ const fake = makeFakeRunner();
330
+ const res = await execute(
331
+ "wf-usage",
332
+ `agent("one");
333
+ const u = usage();
334
+ return u;`,
335
+ fake.runner,
336
+ );
337
+ expect(res.result).toEqual({
338
+ agentsSpawned: 1,
339
+ inputTokens: 10,
340
+ outputTokens: 5,
341
+ });
342
+ });
343
+ });
344
+
345
+ describe("executeWorkflow — script return value becomes the run result", () => {
346
+ beforeEach(resetTables);
347
+
348
+ test("a top-level `return <value>` surfaces as the run result (not a bare expression)", async () => {
349
+ const fake = makeFakeRunner();
350
+ const res = await execute(
351
+ "wf-return",
352
+ `const summary = agent("summarize");
353
+ return { final: summary, kind: "report" };`,
354
+ fake.runner,
355
+ );
356
+ expect(res.status).toBe("completed");
357
+ expect(res.result).toEqual({ final: "out:summarize", kind: "report" });
358
+ });
359
+
360
+ test("a script with NO top-level return completes with no result (undefined)", async () => {
361
+ // The authoring contract requires `return <result>;`. A bare trailing
362
+ // expression is discarded by the function-body wrapper, so the run
363
+ // completes with NO result. This documents WHY the contract mandates an
364
+ // explicit return: the leaf ran, but its value never reached the caller.
365
+ const fake = makeFakeRunner();
366
+ const res = await execute(
367
+ "wf-no-return",
368
+ `const summary = agent("summarize");
369
+ summary;`,
370
+ fake.runner,
371
+ );
372
+ expect(res.status).toBe("completed");
373
+ expect(res.result).toBeUndefined();
374
+ // The leaf DID run — the value was simply discarded by the missing return.
375
+ expect(fake.prompts).toEqual(["summarize"]);
376
+ });
377
+ });
378
+
379
+ describe("executeWorkflow — abort handling (leaf abort = run abort)", () => {
380
+ beforeEach(resetTables);
381
+
382
+ /**
383
+ * A runner that signals (via `onStart`) when a leaf is in flight and blocks on
384
+ * `gate` until the test resolves it. When the run's signal aborts mid-flight,
385
+ * a real provider/agent-loop call rejects with an abort error; this fake
386
+ * mirrors that by rejecting with an `AbortError` once released after abort.
387
+ */
388
+ function makeBlockingRunner(signal: AbortSignal, onStart: () => void) {
389
+ const runner = async (): Promise<import("./leaf-runner.js").LeafResult> => {
390
+ onStart();
391
+ await new Promise<void>((resolve) => {
392
+ if (signal.aborted) return resolve();
393
+ signal.addEventListener("abort", () => resolve(), { once: true });
394
+ });
395
+ // The signal fired while we were awaiting — reject the way a cancelled
396
+ // provider call does.
397
+ const err = new Error("The operation was aborted");
398
+ err.name = "AbortError";
399
+ throw err;
400
+ };
401
+ return runner as unknown as typeof import("./leaf-runner.js").runLeaf;
402
+ }
403
+
404
+ test("aborting mid-leaf in agent() ends the run as `aborted` (not `failed`)", async () => {
405
+ const controller = new AbortController();
406
+ let started = false;
407
+ const runner = makeBlockingRunner(controller.signal, () => {
408
+ started = true;
409
+ });
410
+
411
+ const runPromise = execute(
412
+ "wf-abort-agent",
413
+ `const a = agent("long-running");
414
+ return a;`,
415
+ runner,
416
+ configWith(),
417
+ null,
418
+ CAPABILITIES,
419
+ controller.signal,
420
+ );
421
+
422
+ // Wait until the leaf is in flight, then abort.
423
+ await waitUntil(() => started);
424
+ controller.abort();
425
+
426
+ const res = await runPromise;
427
+ expect(res.status).toBe("aborted");
428
+ expect(res.result).toBeNull();
429
+
430
+ // The cancelled leaf is NOT journaled as a failed entry.
431
+ const journal = journalStore.getJournal("wf-abort-agent");
432
+ expect(journal.every((e) => e.status !== "failed")).toBe(true);
433
+ });
434
+
435
+ test("aborting mid-leaf in parallel() ends the run as `aborted` and does not null-coalesce", async () => {
436
+ const controller = new AbortController();
437
+ let startedCount = 0;
438
+ const runner = makeBlockingRunner(controller.signal, () => {
439
+ startedCount += 1;
440
+ });
441
+
442
+ const runPromise = execute(
443
+ "wf-abort-parallel",
444
+ `const r = parallel([leaf("a"), leaf("b"), leaf("c")]);
445
+ return r;`,
446
+ runner,
447
+ configWith({ maxConcurrentLeaves: 3 }),
448
+ null,
449
+ CAPABILITIES,
450
+ controller.signal,
451
+ );
452
+
453
+ await waitUntil(() => startedCount >= 1);
454
+ controller.abort();
455
+
456
+ const res = await runPromise;
457
+ // The run terminates as aborted rather than completing with [null,...].
458
+ expect(res.status).toBe("aborted");
459
+ expect(res.result).toBeNull();
460
+ const journal = journalStore.getJournal("wf-abort-parallel");
461
+ expect(journal.every((e) => e.status !== "failed")).toBe(true);
462
+ });
463
+
464
+ test("an abort raised at the leaf gate BEFORE the call also yields `aborted`", async () => {
465
+ // Pre-aborted signal: the engine's top-of-leaf `signal?.aborted` guard fires
466
+ // before the runner is invoked.
467
+ const controller = new AbortController();
468
+ controller.abort();
469
+ const fake = makeFakeRunner();
470
+
471
+ const res = await execute(
472
+ "wf-abort-pre",
473
+ `return agent("never-runs");`,
474
+ fake.runner,
475
+ configWith(),
476
+ null,
477
+ CAPABILITIES,
478
+ controller.signal,
479
+ );
480
+ expect(res.status).toBe("aborted");
481
+ expect(fake.prompts).toEqual([]);
482
+ });
483
+ });
484
+
485
+ describe("executeWorkflow — pipeline", () => {
486
+ beforeEach(resetTables);
487
+
488
+ test("pipeline advances across stages with a per-stage barrier", async () => {
489
+ const order: string[] = [];
490
+ const fake = makeFakeRunner({ onCall: (p) => order.push(p) });
491
+
492
+ const res = await execute(
493
+ "wf-pipeline",
494
+ `return pipeline(
495
+ args.items,
496
+ (it) => leaf("s1:" + it),
497
+ (prev) => leaf("s2:" + prev),
498
+ );`,
499
+ fake.runner,
500
+ configWith(),
501
+ { items: ["x", "y"] },
502
+ );
503
+
504
+ // Stage 1 output feeds stage 2's input.
505
+ expect(res.result).toEqual(["out:s2:out:s1:x", "out:s2:out:s1:y"]);
506
+ // Per-stage barrier: BOTH stage-1 calls precede ANY stage-2 call.
507
+ const firstStage2 = order.findIndex((p) => p.startsWith("s2:"));
508
+ const stage1AfterStage2 = order
509
+ .slice(firstStage2)
510
+ .some((p) => p.startsWith("s1:"));
511
+ expect(stage1AfterStage2).toBe(false);
512
+ });
513
+
514
+ test("a stage returning a plain value passes it through without spawning a leaf", async () => {
515
+ const fake = makeFakeRunner();
516
+ const res = await execute(
517
+ "wf-pipeline-passthrough",
518
+ `return pipeline(
519
+ args.items,
520
+ (it) => leaf("s1:" + it),
521
+ (prev) => ({ wrapped: prev }),
522
+ );`,
523
+ fake.runner,
524
+ configWith(),
525
+ { items: ["a", "b"] },
526
+ );
527
+
528
+ expect(res.status).toBe("completed");
529
+ // Stage 2's plain object passed through unchanged.
530
+ expect(res.result).toEqual([
531
+ { wrapped: "out:s1:a" },
532
+ { wrapped: "out:s1:b" },
533
+ ]);
534
+ // Only stage 1 ran leaves — the plain-value stage spent no agents.
535
+ expect(fake.prompts).toEqual(["s1:a", "s1:b"]);
536
+ });
537
+
538
+ test("a stage may mix leaf specs and plain pass-through values (order preserved)", async () => {
539
+ const fake = makeFakeRunner();
540
+ const res = await execute(
541
+ "wf-pipeline-mixed",
542
+ `return pipeline(
543
+ args.items,
544
+ (it) => (it === "skip" ? "kept:" + it : leaf("run:" + it)),
545
+ );`,
546
+ fake.runner,
547
+ configWith(),
548
+ { items: ["x", "skip", "y"] },
549
+ );
550
+
551
+ expect(res.status).toBe("completed");
552
+ // "skip" passed through as a plain string; the others ran as leaves, in order.
553
+ expect(res.result).toEqual(["out:run:x", "kept:skip", "out:run:y"]);
554
+ expect(fake.prompts).toEqual(["run:x", "run:y"]);
555
+ });
556
+ });
557
+
558
+ describe("executeWorkflow — resume", () => {
559
+ beforeEach(resetTables);
560
+
561
+ test("re-running the same runId replays the unchanged prefix (zero duplicate calls)", async () => {
562
+ const script = `const a = agent("alpha");
563
+ const b = agent("beta");
564
+ return [a, b];`;
565
+
566
+ const first = makeFakeRunner();
567
+ const res1 = await execute("wf-resume", script, first.runner);
568
+ expect(res1.result).toEqual(["out:alpha", "out:beta"]);
569
+ expect(first.prompts).toEqual(["alpha", "beta"]);
570
+
571
+ // Re-run with the SAME runId against the SAME DB. The journal replays both
572
+ // cached calls — the runner must NOT be invoked at all.
573
+ const second = makeFakeRunner();
574
+ const res2 = await execute("wf-resume", script, second.runner);
575
+ expect(res2.result).toEqual(["out:alpha", "out:beta"]);
576
+ expect(second.prompts).toEqual([]);
577
+ });
578
+
579
+ test("resume carries agentsSpawned/tokens forward from the persisted run row (not reset to 0)", async () => {
580
+ // First run journals 2 leaves → persisted agents_spawned = 2.
581
+ const first = makeFakeRunner();
582
+ await execute(
583
+ "wf-resume-count",
584
+ `agent("a"); return agent("b");`,
585
+ first.runner,
586
+ );
587
+ const afterFirst = journalStore.getRun("wf-resume-count");
588
+ expect(afterFirst?.agentsSpawned).toBe(2);
589
+ expect(afterFirst?.inputTokens).toBe(20);
590
+
591
+ // Resume with one ADDED leaf: a/b replay (no re-count), the new leaf spawns.
592
+ const second = makeFakeRunner();
593
+ const res = await execute(
594
+ "wf-resume-count",
595
+ `agent("a"); agent("b"); return agent("c");`,
596
+ second.runner,
597
+ );
598
+ expect(res.status).toBe("completed");
599
+ expect(second.prompts).toEqual(["c"]);
600
+ // Carried forward: persisted 2 + 1 newly spawned = 3 (NOT reset to 1).
601
+ expect(res.agentsSpawned).toBe(3);
602
+ expect(res.inputTokens).toBe(30);
603
+
604
+ // The persisted agents_spawned is not regressed downward by the resume.
605
+ const afterSecond = journalStore.getRun("wf-resume-count");
606
+ expect(afterSecond?.agentsSpawned).toBe(3);
607
+ expect(afterSecond!.agentsSpawned).toBeGreaterThanOrEqual(
608
+ afterFirst!.agentsSpawned,
609
+ );
610
+ });
611
+
612
+ test("resume enforces the agent cap against the carried-over total (no fresh budget)", async () => {
613
+ // First run spawns 3 leaves under a generous cap → persisted at 3.
614
+ const first = makeFakeRunner();
615
+ await execute(
616
+ "wf-resume-cap",
617
+ `agent("a"); agent("b"); return agent("c");`,
618
+ first.runner,
619
+ configWith({ maxAgentsPerRun: 500 }),
620
+ );
621
+ expect(journalStore.getRun("wf-resume-cap")?.agentsSpawned).toBe(3);
622
+
623
+ // Resume with cap=3 and one NEW leaf. Were the counter reset to 0, the new
624
+ // leaf would fit a fresh budget; seeded from the persisted 3, the cap is
625
+ // already met, so the new leaf trips it.
626
+ const second = makeFakeRunner();
627
+ const res = await execute(
628
+ "wf-resume-cap",
629
+ `agent("a"); agent("b"); agent("c"); return agent("d");`,
630
+ second.runner,
631
+ configWith({ maxAgentsPerRun: 3 }),
632
+ );
633
+
634
+ expect(res.status).toBe("cap_exceeded");
635
+ // The new leaf never ran; the carried total stays at the cap.
636
+ expect(second.prompts).toEqual([]);
637
+ expect(res.agentsSpawned).toBe(3);
638
+ expect(journalStore.getRun("wf-resume-cap")?.agentsSpawned).toBe(3);
639
+ });
640
+
641
+ test("a changed call breaks the replay prefix and re-runs from there", async () => {
642
+ const first = makeFakeRunner();
643
+ await execute(
644
+ "wf-resume2",
645
+ `agent("alpha"); return agent("beta");`,
646
+ first.runner,
647
+ );
648
+ expect(first.prompts).toEqual(["alpha", "beta"]);
649
+
650
+ // Second run: seq 0 ("alpha") is unchanged → replayed; seq 1 changes
651
+ // ("beta" → "gamma") → hash mismatch → re-run.
652
+ const second = makeFakeRunner();
653
+ const res = await execute(
654
+ "wf-resume2",
655
+ `agent("alpha"); return agent("gamma");`,
656
+ second.runner,
657
+ );
658
+ expect(res.result).toBe("out:gamma");
659
+ expect(second.prompts).toEqual(["gamma"]);
660
+ });
661
+
662
+ test("deterministic seq + call_hash are stable across two runs", async () => {
663
+ const script = `return parallel([leaf("p0"), leaf("p1"), leaf("p2")]);`;
664
+
665
+ const first = makeFakeRunner();
666
+ await execute("wf-hash", script, first.runner);
667
+ const journalA = journalStore.getJournal("wf-hash");
668
+
669
+ resetTables();
670
+
671
+ const second = makeFakeRunner();
672
+ await execute("wf-hash", script, second.runner);
673
+ const journalB = journalStore.getJournal("wf-hash");
674
+
675
+ expect(journalA.map((e) => [e.seq, e.callHash])).toEqual(
676
+ journalB.map((e) => [e.seq, e.callHash]),
677
+ );
678
+ expect(journalA.map((e) => e.seq)).toEqual([0, 1, 2]);
679
+ });
680
+ });
681
+
682
+ describe("executeWorkflow — schema vs tool leaf tool forwarding", () => {
683
+ beforeEach(resetTables);
684
+
685
+ // A non-empty capability set, mirroring a real run (which always carries the
686
+ // read-only baseline). The engine must forward these tools ONLY to tool leaves.
687
+ const toolsCapabilities: ResolvedCapabilities = {
688
+ tools: [{ name: "file_read" } as ResolvedCapabilities["tools"][number]],
689
+ hostFunctions: [],
690
+ persona: false,
691
+ };
692
+
693
+ test("a schema leaf is called with NO tools; a plain tool leaf gets capabilities.tools", async () => {
694
+ const fake = makeFakeRunner();
695
+ // The schema crosses the (here-bypassed) sandbox as a plain JSON Schema
696
+ // object; the engine forwards it verbatim. The plain leaf has no schema.
697
+ const res = await execute(
698
+ "wf-tool-forward",
699
+ `const schemaLeaf = agent("structured", { schema: { type: "object" } });
700
+ const toolLeaf = agent("freeform");
701
+ return [schemaLeaf, toolLeaf];`,
702
+ fake.runner,
703
+ configWith(),
704
+ null,
705
+ toolsCapabilities,
706
+ );
707
+
708
+ expect(res.status).toBe("completed");
709
+ expect(fake.prompts).toEqual(["structured", "freeform"]);
710
+
711
+ const [schemaCall, toolCall] = fake.calls;
712
+ // Schema leaf: schema present, NO tools forwarded.
713
+ expect(schemaCall!.schema).toEqual({ type: "object" });
714
+ expect(schemaCall!.tools ?? []).toEqual([]);
715
+ // Tool leaf: no schema, capabilities.tools forwarded.
716
+ expect(toolCall!.schema).toBeUndefined();
717
+ expect(toolCall!.tools).toBe(toolsCapabilities.tools);
718
+ });
719
+ });
720
+
721
+ describe("executeWorkflow — persona gating", () => {
722
+ beforeEach(resetTables);
723
+
724
+ const personaCapabilities: ResolvedCapabilities = {
725
+ tools: [],
726
+ hostFunctions: [],
727
+ persona: true,
728
+ };
729
+
730
+ test("a persona leaf forwards persona:true to the runner when the run declared persona", async () => {
731
+ const fake = makeFakeRunner();
732
+ const res = await execute(
733
+ "wf-persona-ok",
734
+ `return agent("draft a reply", { persona: true });`,
735
+ fake.runner,
736
+ configWith(),
737
+ null,
738
+ personaCapabilities,
739
+ );
740
+
741
+ expect(res.status).toBe("completed");
742
+ expect(fake.calls).toHaveLength(1);
743
+ expect(fake.calls[0]!.persona).toBe(true);
744
+ });
745
+
746
+ test("a non-persona leaf does NOT forward persona", async () => {
747
+ const fake = makeFakeRunner();
748
+ await execute(
749
+ "wf-persona-anon",
750
+ `return agent("plain task");`,
751
+ fake.runner,
752
+ configWith(),
753
+ null,
754
+ personaCapabilities,
755
+ );
756
+ expect(fake.calls[0]!.persona).toBeUndefined();
757
+ });
758
+
759
+ test("a persona leaf in a run WITHOUT declared persona fails the run (loud, never silent)", async () => {
760
+ const fake = makeFakeRunner();
761
+ // Default CAPABILITIES has persona:false.
762
+ const res = await execute(
763
+ "wf-persona-denied",
764
+ `return agent("draft a reply", { persona: true });`,
765
+ fake.runner,
766
+ );
767
+
768
+ expect(res.status).toBe("failed");
769
+ // The leaf runner is never invoked — the gate trips before the spawn.
770
+ expect(fake.calls).toHaveLength(0);
771
+ expect(res.agentsSpawned).toBe(0);
772
+ });
773
+ });
774
+
775
+ describe("executeWorkflow — agent cap", () => {
776
+ beforeEach(resetTables);
777
+
778
+ test("exceeding maxAgentsPerRun aborts with cap_exceeded and persists partials", async () => {
779
+ const fake = makeFakeRunner();
780
+ const items = Array.from({ length: 10 }, (_, i) => `i${i}`);
781
+
782
+ const res = await execute(
783
+ "wf-cap",
784
+ `const a = map(args.items, (it) => leaf(it));
785
+ return a;`,
786
+ fake.runner,
787
+ configWith({ maxAgentsPerRun: 3, maxConcurrentLeaves: 1 }),
788
+ { items },
789
+ );
790
+
791
+ expect(res.status).toBe("cap_exceeded");
792
+ // Exactly the cap's worth of leaves ran before the run aborted.
793
+ expect(res.agentsSpawned).toBe(3);
794
+ expect(fake.prompts.length).toBe(3);
795
+
796
+ // Partials are persisted: the run row reflects cap_exceeded + 3 agents, and
797
+ // 3 journal entries exist.
798
+ const run = journalStore.getRun("wf-cap");
799
+ expect(run?.status).toBe("cap_exceeded");
800
+ expect(run?.agentsSpawned).toBe(3);
801
+ expect(journalStore.getJournal("wf-cap").length).toBe(3);
802
+ });
803
+
804
+ test("a script that CATCHES the cap sentinel still ends cap_exceeded, not completed", async () => {
805
+ // The host CapExceededSignal crosses into the VM as a catchable exception,
806
+ // so a script can swallow it and return a partial result. The run still hit
807
+ // its cap, so it must NOT be recorded as completed — the host-side cap flag
808
+ // (which the sandboxed script cannot reach) is the source of truth.
809
+ const fake = makeFakeRunner();
810
+ const res = await execute(
811
+ "wf-cap-caught",
812
+ `const out = [agent("first")];
813
+ try { out.push(agent("second")); } catch (e) { /* swallow the cap */ }
814
+ return out;`,
815
+ fake.runner,
816
+ configWith({ maxAgentsPerRun: 1 }),
817
+ );
818
+
819
+ expect(res.status).toBe("cap_exceeded");
820
+ // The swallowed partial return is discarded for a non-completed run.
821
+ expect(res.result).toBeNull();
822
+ expect(res.agentsSpawned).toBe(1);
823
+ expect(fake.prompts).toEqual(["first"]);
824
+ expect(journalStore.getRun("wf-cap-caught")?.status).toBe("cap_exceeded");
825
+ });
826
+
827
+ test("the cap counts only NEW leaves; replayed leaves do not consume it", async () => {
828
+ // First run journals 2 leaves with a generous cap.
829
+ const first = makeFakeRunner();
830
+ await execute(
831
+ "wf-cap-replay",
832
+ `agent("a"); agent("b"); return agent("c");`,
833
+ first.runner,
834
+ configWith({ maxAgentsPerRun: 500 }),
835
+ );
836
+ expect(first.prompts).toEqual(["a", "b", "c"]);
837
+
838
+ // Re-run with cap=1: a/b/c all replay from the journal (hash match), so the
839
+ // cap (which only counts NEW spawns) is never tripped.
840
+ const second = makeFakeRunner();
841
+ const res = await execute(
842
+ "wf-cap-replay",
843
+ `agent("a"); agent("b"); return agent("c");`,
844
+ second.runner,
845
+ configWith({ maxAgentsPerRun: 1 }),
846
+ );
847
+ expect(res.status).toBe("completed");
848
+ expect(res.result).toBe("out:c");
849
+ expect(second.prompts).toEqual([]);
850
+ });
851
+ });
852
+
853
+ describe("executeWorkflow — fan-out unwind (no orphaned leaves)", () => {
854
+ beforeEach(resetTables);
855
+
856
+ test("a cap tripped mid-fan-out drains in-flight leaves before finishing (no post-finalization writes)", async () => {
857
+ // maxAgentsPerRun=1 + maxConcurrentLeaves=2: leaf "a" spawns and goes in
858
+ // flight (slow), then leaf "b" trips the cap and throws. The fan-out must
859
+ // NOT resolve while "a" is still running — otherwise the run is reported
860
+ // `cap_exceeded` and `finishRun` lands while "a" later appends a journal
861
+ // entry. This fake IGNORES the abort signal, so it proves the DRAIN: the
862
+ // engine waits for the orphan to settle rather than relying on cancellation.
863
+ const fake = makeFakeRunner({ delayMs: 80 });
864
+ const res = await execute(
865
+ "wf-unwind-drain",
866
+ `return parallel([leaf("a"), leaf("b")]);`,
867
+ fake.runner,
868
+ configWith({ maxAgentsPerRun: 1, maxConcurrentLeaves: 2 }),
869
+ );
870
+
871
+ expect(res.status).toBe("cap_exceeded");
872
+ expect(res.agentsSpawned).toBe(1);
873
+ // The run did not finish while a leaf was still in flight.
874
+ expect(fake.inFlight).toBe(0);
875
+
876
+ // No journal entry lands AFTER the run is finalized: snapshot now, wait past
877
+ // the leaf delay, and assert the count is unchanged (leaf "a" journaled
878
+ // DURING the drain, before finishRun — not after it).
879
+ const lenAtResolve = journalStore.getJournal("wf-unwind-drain").length;
880
+ await new Promise((r) => setTimeout(r, 150));
881
+ expect(journalStore.getJournal("wf-unwind-drain").length).toBe(
882
+ lenAtResolve,
883
+ );
884
+ });
885
+
886
+ test("a cap tripped mid-fan-out CANCELS the in-flight leaf (not just awaits it)", async () => {
887
+ // Same shape, but a signal-aware runner: when the fan-out's internal
888
+ // controller aborts (because the cap tripped), the in-flight leaf "a" is
889
+ // cancelled — it rejects like a real cancelled provider call. The run still
890
+ // ends `cap_exceeded` (the cap sentinel is the FIRST error), a cancelled
891
+ // leaf is NOT journaled, and the run waits for it to settle.
892
+ let inFlight = 0;
893
+ let abortedCount = 0;
894
+ const runner = (async (leafOpts: RunLeafOptions): Promise<LeafResult> => {
895
+ inFlight += 1;
896
+ const sig = leafOpts.signal;
897
+ try {
898
+ await new Promise<void>((resolve, reject) => {
899
+ const fail = (): void => {
900
+ abortedCount += 1;
901
+ const err = new Error("The operation was aborted");
902
+ err.name = "AbortError";
903
+ reject(err);
904
+ };
905
+ if (sig?.aborted) return fail();
906
+ // No timer: if never aborted the leaf stays pending (matches the
907
+ // existing blocking-runner pattern — no leaked timers).
908
+ sig?.addEventListener("abort", fail, { once: true });
909
+ });
910
+ return {
911
+ output: `out:${leafOpts.prompt}`,
912
+ inputTokens: 10,
913
+ outputTokens: 5,
914
+ toolCallCount: 0,
915
+ };
916
+ } finally {
917
+ inFlight -= 1;
918
+ }
919
+ }) as unknown as typeof import("./leaf-runner.js").runLeaf;
920
+
921
+ const res = await execute(
922
+ "wf-unwind-cancel",
923
+ `return parallel([leaf("a"), leaf("b")]);`,
924
+ runner,
925
+ configWith({ maxAgentsPerRun: 1, maxConcurrentLeaves: 2 }),
926
+ );
927
+
928
+ expect(res.status).toBe("cap_exceeded");
929
+ // The in-flight leaf was cancelled by the fan-out controller and drained.
930
+ expect(abortedCount).toBe(1);
931
+ expect(inFlight).toBe(0);
932
+ // A cancelled leaf is never journaled (neither completed nor failed); the
933
+ // cap leaf throws before journaling — so the journal is empty.
934
+ expect(journalStore.getJournal("wf-unwind-cancel")).toHaveLength(0);
935
+ });
936
+ });
937
+
938
+ describe("executeWorkflow — nested workflow()", () => {
939
+ // Saved workflows live at `<workspace>/workflows/*.workflow.ts`; point the
940
+ // workspace at a temp dir so `workflow(name)` resolves a fixture we control.
941
+ let workspaceDir: string;
942
+ let prevOverride: string | undefined;
943
+
944
+ beforeEach(() => {
945
+ resetTables();
946
+ workspaceDir = mkdtempSync(join(tmpdir(), "wf-nest-"));
947
+ prevOverride = process.env.VELLUM_WORKSPACE_DIR;
948
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
949
+ });
950
+
951
+ afterEach(() => {
952
+ if (prevOverride === undefined) delete process.env.VELLUM_WORKSPACE_DIR;
953
+ else process.env.VELLUM_WORKSPACE_DIR = prevOverride;
954
+ rmSync(workspaceDir, { recursive: true, force: true });
955
+ });
956
+
957
+ function writeSaved(file: string, source: string): void {
958
+ const dir = join(workspaceDir, "workflows");
959
+ mkdirSync(dir, { recursive: true });
960
+ writeFileSync(join(dir, file), source, "utf8");
961
+ }
962
+
963
+ test("workflow() runs a saved child inline and returns its result", async () => {
964
+ writeSaved(
965
+ "child.workflow.ts",
966
+ `export const meta = { name: "child", description: "c" };
967
+ return agent("child-task");`,
968
+ );
969
+
970
+ const fake = makeFakeRunner();
971
+ const res = await execute(
972
+ "wf-nest-basic",
973
+ `const c = workflow("child", {});
974
+ const p = agent("parent-task");
975
+ return [c, p];`,
976
+ fake.runner,
977
+ );
978
+
979
+ expect(res.status).toBe("completed");
980
+ // The child's agent() result threads back into the parent script.
981
+ expect(res.result).toEqual(["out:child-task", "out:parent-task"]);
982
+ // Child leaf ran before the parent's own leaf (inline, same run).
983
+ expect(fake.prompts).toEqual(["child-task", "parent-task"]);
984
+ });
985
+
986
+ test("nested leaves draw seq from the SAME run-scoped counter", async () => {
987
+ writeSaved(
988
+ "child.workflow.ts",
989
+ `export const meta = { name: "child", description: "c" };
990
+ return parallel([leaf("c0"), leaf("c1")]);`,
991
+ );
992
+
993
+ const fake = makeFakeRunner();
994
+ await execute(
995
+ "wf-nest-seq",
996
+ `agent("p0");
997
+ workflow("child", {});
998
+ return agent("p2");`,
999
+ fake.runner,
1000
+ );
1001
+
1002
+ // One contiguous seq sequence across the parent + child boundary. The
1003
+ // workflow() resolution itself takes seq 1 (snapshotting the child source),
1004
+ // then the child's two leaves take 2 and 3, and the parent's p2 takes 4.
1005
+ const journal = journalStore.getJournal("wf-nest-seq");
1006
+ expect(journal.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4]);
1007
+ // The workflow() resolution is journaled as a "workflow" snapshot at seq 1.
1008
+ const snapshot = journal.find((e) => e.seq === 1);
1009
+ expect(snapshot?.kind).toBe("workflow");
1010
+ expect((snapshot?.request as { name: string }).name).toBe("child");
1011
+ // Child leaf labels are attributed to the child workflow.
1012
+ const childEntries = journal.filter(
1013
+ (e) =>
1014
+ e.kind === "agent" &&
1015
+ ["c0", "c1"].includes((e.request as { prompt: string }).prompt),
1016
+ );
1017
+ expect(
1018
+ childEntries.map(
1019
+ (e) => (e.request as { opts: { label?: string } }).opts.label,
1020
+ ),
1021
+ ).toEqual(["child", "child"]);
1022
+ });
1023
+
1024
+ test("parent + child spawns share the agent cap (counted together)", async () => {
1025
+ // Child spawns 2 leaves; parent spawns 2 before + would spawn more after.
1026
+ // With cap=3, the run trips while running the child's second leaf.
1027
+ writeSaved(
1028
+ "child.workflow.ts",
1029
+ `export const meta = { name: "child", description: "c" };
1030
+ return parallel([leaf("c0"), leaf("c1")]);`,
1031
+ );
1032
+
1033
+ const fake = makeFakeRunner();
1034
+ const res = await execute(
1035
+ "wf-nest-cap",
1036
+ `agent("p0");
1037
+ agent("p1");
1038
+ workflow("child", {});
1039
+ return agent("never");`,
1040
+ fake.runner,
1041
+ configWith({ maxAgentsPerRun: 3, maxConcurrentLeaves: 1 }),
1042
+ );
1043
+
1044
+ expect(res.status).toBe("cap_exceeded");
1045
+ // Exactly the cap's worth ran across BOTH levels: p0, p1, then the child's
1046
+ // first leaf — the child's second leaf trips the shared cap.
1047
+ expect(res.agentsSpawned).toBe(3);
1048
+ expect(fake.prompts).toEqual(["p0", "p1", "c0"]);
1049
+ });
1050
+
1051
+ test("calling workflow() from inside a child throws (depth-1 only)", async () => {
1052
+ writeSaved(
1053
+ "grandchild.workflow.ts",
1054
+ `export const meta = { name: "grandchild", description: "g" };
1055
+ return agent("gc");`,
1056
+ );
1057
+ // The child illegally nests another workflow() — depth would be 2.
1058
+ writeSaved(
1059
+ "child.workflow.ts",
1060
+ `export const meta = { name: "child", description: "c" };
1061
+ return workflow("grandchild", {});`,
1062
+ );
1063
+
1064
+ const fake = makeFakeRunner();
1065
+ const res = await execute(
1066
+ "wf-nest-depth",
1067
+ `return workflow("child", {});`,
1068
+ fake.runner,
1069
+ );
1070
+
1071
+ // The depth guard fires inside the child; uncaught, it fails the run.
1072
+ expect(res.status).toBe("failed");
1073
+ // The grandchild never ran.
1074
+ expect(fake.prompts).toEqual([]);
1075
+ });
1076
+
1077
+ test("workflow() with an unknown name fails the run", async () => {
1078
+ const fake = makeFakeRunner();
1079
+ const res = await execute(
1080
+ "wf-nest-missing",
1081
+ `return workflow("ghost", {});`,
1082
+ fake.runner,
1083
+ );
1084
+ expect(res.status).toBe("failed");
1085
+ expect(fake.prompts).toEqual([]);
1086
+ });
1087
+
1088
+ test("resume uses the journaled child source after the saved child is deleted", async () => {
1089
+ writeSaved(
1090
+ "child.workflow.ts",
1091
+ `export const meta = { name: "child", description: "c" };
1092
+ return agent("c0");`,
1093
+ );
1094
+ const parent = `const c = workflow("child", {});
1095
+ const p = agent("p1");
1096
+ return [c, p];`;
1097
+
1098
+ const first = makeFakeRunner();
1099
+ const res1 = await execute("wf-nest-resume-del", parent, first.runner);
1100
+ expect(res1.status).toBe("completed");
1101
+ expect(res1.result).toEqual(["out:c0", "out:p1"]);
1102
+ expect(first.prompts).toEqual(["c0", "p1"]);
1103
+
1104
+ // Delete the saved child. A resume that re-resolved from the workspace file
1105
+ // would throw WorkflowNotFoundError; the journaled snapshot must be used.
1106
+ rmSync(join(workspaceDir, "workflows", "child.workflow.ts"));
1107
+
1108
+ const second = makeFakeRunner();
1109
+ const res2 = await execute("wf-nest-resume-del", parent, second.runner);
1110
+ expect(res2.status).toBe("completed");
1111
+ expect(res2.result).toEqual(["out:c0", "out:p1"]);
1112
+ // Everything replays from the journal — no runner calls, and crucially no
1113
+ // WorkflowNotFoundError despite the deleted file.
1114
+ expect(second.prompts).toEqual([]);
1115
+ });
1116
+
1117
+ test("resume runs the original child source even after the saved child is edited", async () => {
1118
+ writeSaved(
1119
+ "child.workflow.ts",
1120
+ `export const meta = { name: "child", description: "c" };
1121
+ return agent("c0");`,
1122
+ );
1123
+ const parent = `return workflow("child", {});`;
1124
+
1125
+ const first = makeFakeRunner();
1126
+ const res1 = await execute("wf-nest-resume-edit", parent, first.runner);
1127
+ expect(res1.result).toBe("out:c0");
1128
+ expect(first.prompts).toEqual(["c0"]);
1129
+
1130
+ // Edit the saved child to spawn a DIFFERENT leaf. Resume must ignore the
1131
+ // new file and re-run the snapshotted original, whose leaf replays from the
1132
+ // journal — so the edited "EDITED" leaf never runs.
1133
+ writeSaved(
1134
+ "child.workflow.ts",
1135
+ `export const meta = { name: "child", description: "c" };
1136
+ return agent("EDITED");`,
1137
+ );
1138
+
1139
+ const second = makeFakeRunner();
1140
+ const res2 = await execute("wf-nest-resume-edit", parent, second.runner);
1141
+ expect(res2.result).toBe("out:c0");
1142
+ expect(second.prompts).toEqual([]);
1143
+ });
1144
+ });