@desplega.ai/agent-swarm 2.4.5 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/README.md +4 -2
  2. package/dist/{acp-adapter-2wdkktv6.js → acp-adapter-11gp67nr.js} +8 -6
  3. package/dist/{actions-c40ebcm4.js → actions-8p2kze8j.js} +23 -21
  4. package/dist/{amp-adapter-wb67m95z.js → amp-adapter-z4qvhacj.js} +2 -2
  5. package/dist/{anthropic-messages-g6pqmqk4.js → anthropic-messages-txs190dd.js} +17 -20
  6. package/dist/{app-353vre8g.js → app-0qp0a2gk.js} +3 -3
  7. package/dist/{assistant-sj1njh23.js → assistant-jedd3ktm.js} +24 -22
  8. package/dist/{azure-openai-responses-9dbmj346.js → azure-openai-responses-9kfwhmn7.js} +18 -13
  9. package/dist/{boot-reembed-qdkvxeem.js → boot-reembed-4xwq5tg0.js} +1 -1
  10. package/dist/{boot-reembed-b9c3az3c.js → boot-reembed-n4wq5dd2.js} +1 -1
  11. package/dist/{boot-scrub-sweep-m3wh8eks.js → boot-scrub-sweep-69h90xcq.js} +4 -4
  12. package/dist/{boot-seeds-j2273s54.js → boot-seeds-3vn0m9sg.js} +4 -4
  13. package/dist/{claude-adapter-m7q0a085.js → claude-adapter-221mvad5.js} +8 -6
  14. package/dist/{claude-managed-adapter-5ptb6tzs.js → claude-managed-adapter-gt6pa35d.js} +2 -2
  15. package/dist/{claude-managed-setup-smrqk5j5.js → claude-managed-setup-c3hp36ph.js} +4 -4
  16. package/dist/{claude-sdk-session-38xnz21e.js → claude-sdk-session-8thjsk5e.js} +9012 -8928
  17. package/dist/{cli-b9fcxxev.js → cli-007cnhm1.js} +50 -4
  18. package/dist/{cli-j4rqdf2n.js → cli-03vpq45j.js} +2 -2
  19. package/dist/{cli-dk44dxec.js → cli-0y6f0ckq.js} +1 -1
  20. package/dist/{cli-kpw04va0.js → cli-0z3vjrpm.js} +1 -1
  21. package/dist/{cli-94gwyg7m.js → cli-179xcyky.js} +9 -9
  22. package/dist/{cli-3r8samv3.js → cli-1f0gp9vz.js} +3 -2
  23. package/dist/{cli-4zfqgjc5.js → cli-1pr672hz.js} +2 -2
  24. package/dist/{cli-0bqteszy.js → cli-2nf3b08t.js} +328 -25
  25. package/dist/{cli-43x64hv3.js → cli-36j4jnk3.js} +4 -4
  26. package/dist/{cli-k8g16gz3.js → cli-3qv8naq8.js} +115 -119
  27. package/dist/cli-3zwye9sn.js +1193 -0
  28. package/dist/{cli-60cgh1rv.js → cli-459fk97x.js} +7 -7
  29. package/dist/{cli-v8fjakwf.js → cli-4w24f7z3.js} +1 -1
  30. package/dist/{cli-n72rg53p.js → cli-5kzk3472.js} +812 -418
  31. package/dist/{cli-h7gc0b4r.js → cli-5m84zd2k.js} +55 -18
  32. package/dist/{cli-j99azg3g.js → cli-5rvg1ddk.js} +5 -5
  33. package/dist/{cli-dvkmp9bt.js → cli-6ctwrf1z.js} +20 -10
  34. package/dist/{cli-848hxdwj.js → cli-6rg2jn2y.js} +2 -1
  35. package/dist/{cli-fv5hepyy.js → cli-7bwtc6yx.js} +3 -3
  36. package/dist/{cli-ms85309d.js → cli-7g40g8hq.js} +11 -11
  37. package/dist/{cli-xpa64rek.js → cli-92mqenkc.js} +2362 -159
  38. package/dist/{cli-018r4jq6.js → cli-9ce2xw98.js} +8 -8
  39. package/dist/{cli-1syn2xy1.js → cli-bgdtv62r.js} +32 -18
  40. package/dist/{cli-wxbn3yfx.js → cli-cz5nmdxt.js} +2 -2
  41. package/dist/{cli-wt645v7q.js → cli-dabehxhp.js} +1 -1
  42. package/dist/{cli-vfha21c9.js → cli-e5bxnjps.js} +2 -2
  43. package/dist/{cli-wt0jf9hm.js → cli-fcc5qe3f.js} +553 -193
  44. package/dist/{cli-ftc1g5tj.js → cli-fes51ya2.js} +3 -3
  45. package/dist/{cli-3a45zcgm.js → cli-ftmq4nvk.js} +2 -2
  46. package/dist/{cli-37j04k05.js → cli-fy1jyyym.js} +13 -13
  47. package/dist/{cli-s95dknxh.js → cli-g1cw7xtt.js} +1 -1
  48. package/dist/{cli-74c1wpm0.js → cli-gpv60278.js} +1 -1
  49. package/dist/{cli-pemywhfq.js → cli-ha9rmwgq.js} +2 -1
  50. package/dist/{cli-g35m2xwm.js → cli-j1eghqfm.js} +2 -2
  51. package/dist/{cli-qfxc84gh.js → cli-jpxsrxcb.js} +1 -1
  52. package/dist/{cli-ejs3m1sx.js → cli-kxpfx2xr.js} +1 -1
  53. package/dist/{cli-p14m2cnp.js → cli-mbfj11fq.js} +1 -1
  54. package/dist/{cli-pnp2j5zg.js → cli-mmhj8vd7.js} +2 -2
  55. package/dist/{cli-xf234ws8.js → cli-n4c8e3s5.js} +2 -0
  56. package/dist/{cli-f01h25yt.js → cli-n6kmqngw.js} +2 -2
  57. package/dist/{cli-v0yrfmdh.js → cli-nnvjb17s.js} +3 -3
  58. package/dist/{cli-1dmcq2eh.js → cli-nzfnt2gs.js} +1 -1
  59. package/dist/{cli-625tb7aq.js → cli-p859hcc8.js} +2 -2
  60. package/dist/{cli-smvcw9z5.js → cli-pasyvjmm.js} +1 -1
  61. package/dist/cli-pkaqk57w.js +128 -0
  62. package/dist/{cli-8nyhf7za.js → cli-qqzmjxkf.js} +129 -105
  63. package/dist/{cli-9yqgm9nd.js → cli-r25qpaxt.js} +161 -63
  64. package/dist/{cli-eww120g8.js → cli-rh5smt8t.js} +18 -4
  65. package/dist/{cli-qwkcwjdn.js → cli-rycdaj0a.js} +1 -1
  66. package/dist/{cli-a92r1127.js → cli-s4sntwt4.js} +2 -2
  67. package/dist/{cli-be2161h1.js → cli-segs7hx9.js} +6 -6
  68. package/dist/{cli-36ckw0wx.js → cli-stxdrqs2.js} +2 -2
  69. package/dist/{cli-z7d29rw9.js → cli-sy8f9705.js} +4 -4
  70. package/dist/{cli-1m17dc9d.js → cli-ta8bd7th.js} +2 -2
  71. package/dist/{cli-st8aw1m6.js → cli-we0ra057.js} +4 -4
  72. package/dist/{cli-7c9qs530.js → cli-x21dqn18.js} +5 -5
  73. package/dist/{cli-1sp861s4.js → cli-x6vf4sad.js} +11 -11
  74. package/dist/{cli-d2mb5s8a.js → cli-x77gen2p.js} +680 -440
  75. package/dist/{cli-tw4p90jb.js → cli-xawhpgc9.js} +5 -5
  76. package/dist/{cli-f0g3pgvf.js → cli-xd4eqtr2.js} +2 -2
  77. package/dist/{cli-1f3qzw6y.js → cli-xt768r5m.js} +2 -2
  78. package/dist/{cli-cxxe61f6.js → cli-y4x8ww6s.js} +2 -2
  79. package/dist/{cli-bnes4z6b.js → cli-y9jvxfhk.js} +3 -3
  80. package/dist/{cli-qwhy2qma.js → cli-z63a269p.js} +2 -2
  81. package/dist/{cli-sm8rses9.js → cli-zfdp01hn.js} +8 -5
  82. package/dist/cli.js +29 -27
  83. package/dist/{cloudflare-workers-ai-system-one-ch2wxfcp.js → cloudflare-workers-ai-system-one-43v9qkjk.js} +7 -2
  84. package/dist/{codex-adapter-tn6k5mkg.js → codex-adapter-tcg1q3jk.js} +6 -4
  85. package/dist/{codex-login-m819c41r.js → codex-login-rn7c5gxt.js} +1 -1
  86. package/dist/{codex-session-runner-7vjt7jbw.js → codex-session-runner-xfx4wd9x.js} +7 -5
  87. package/dist/{commands-t7rz9146.js → commands-ew83nj70.js} +2 -2
  88. package/dist/{controller-m2jasma5.js → controller-gnq1k1mf.js} +4 -4
  89. package/dist/{controllers-connect-9ftpex99.js → controllers-connect-tw1dan9r.js} +25 -23
  90. package/dist/{cursor-adapter-mjv79e4z.js → cursor-adapter-yq5xgfcg.js} +1 -1
  91. package/dist/{db-6sfrn9g3.js → db-7gbc53fd.js} +14 -2
  92. package/dist/{db-rn673r06.js → db-t86xrnes.js} +3 -3
  93. package/dist/{devin-adapter-zgqadvnm.js → devin-adapter-4ycy4vrf.js} +1 -1
  94. package/dist/{dispatcher-c155xz0p.js → dispatcher-64bdyhps.js} +22 -20
  95. package/dist/{docker-fkgtr75t.js → docker-nfy2gc8t.js} +2 -2
  96. package/dist/{dsh-adapter-3mmszc1x.js → dsh-adapter-tvhc4k2d.js} +1 -1
  97. package/dist/{e2b-16d6ch39.js → e2b-9gkdhm3c.js} +2 -2
  98. package/dist/{e2b-zn0b4w5w.js → e2b-cjtf8k1z.js} +2 -2
  99. package/dist/{easter-egg-3d-5cgynaja.js → easter-egg-3d-e4qv8dxj.js} +1 -1
  100. package/dist/{event-bus-9vpk8gmq.js → event-bus-3z78ep5n.js} +1 -1
  101. package/dist/{execute-t4e6m3rw.js → execute-0xpz8950.js} +85 -23
  102. package/dist/{external-channel-35k6tkv8.js → external-channel-e9nw24zv.js} +2 -2
  103. package/dist/{google-generative-ai-bmq8gkf3.js → google-generative-ai-mk9vcpe1.js} +15 -15
  104. package/dist/{google-vertex-b1rvp52s.js → google-vertex-q580kz8k.js} +20 -20
  105. package/dist/{grok-adapter-tfrq7tg1.js → grok-adapter-v82srhrx.js} +9 -7
  106. package/dist/handlers-c9ajzhbn.js +41 -0
  107. package/dist/{heartbeat-dyg4npkp.js → heartbeat-2vqs1319.js} +25 -23
  108. package/dist/{hook-x26b31bg.js → hook-01nk13wx.js} +7 -5
  109. package/dist/{hook-drspbwpe.js → hook-jzpheegg.js} +9 -7
  110. package/dist/{http-pk972xyw.js → http-fc1k6ec8.js} +544 -76
  111. package/dist/index-07ef7nkm.js +42 -0
  112. package/dist/{index-g6tbb6ve.js → index-31ceyf42.js} +1 -1
  113. package/dist/index-7yts8n9s.js +43 -0
  114. package/dist/index-q3t40ryv.js +46 -0
  115. package/dist/{index-arytgj5q.js → index-sepja052.js} +1 -1
  116. package/dist/{index-jwxkkex2.js → index-zdycv3ja.js} +13 -11
  117. package/dist/{index-g216q6jr.js → index-zsyx51ap.js} +1 -4
  118. package/dist/{keepalive-7yrng7px.js → keepalive-5nh2rhmr.js} +4 -4
  119. package/dist/lead-aayq64jn.js +79 -0
  120. package/dist/{llama-cpp-classify-vv43gswy.js → llama-cpp-classify-ee7vmd7q.js} +4 -1
  121. package/dist/{maintenance-e6cemdqt.js → maintenance-hjhzdt3k.js} +1 -1
  122. package/dist/{mistral-conversations-1medfwv7.js → mistral-conversations-sd0xf1tn.js} +13 -13
  123. package/dist/{node-de6akczr.js → node-jzjj7r9x.js} +1 -1
  124. package/dist/{oauth-refresh-sweep-yw3xdbcj.js → oauth-refresh-sweep-y3n1knmd.js} +3 -3
  125. package/dist/{onboard-0a9y5yrc.js → onboard-1zq3m7db.js} +2 -2
  126. package/dist/{openai-codex-responses-de9pvs4d.js → openai-codex-responses-79s8xnbh.js} +29 -23
  127. package/dist/{openai-completions-mtqge3nf.js → openai-completions-tdbyts0x.js} +21 -20
  128. package/dist/{openai-decisions-ygk4kkxd.js → openai-decisions-bqhnjyfz.js} +2 -1
  129. package/dist/{openai-responses-dkyng71g.js → openai-responses-dm3t5vqp.js} +26 -17
  130. package/dist/{opencode-adapter-x4tqzhkh.js → opencode-adapter-e56086m1.js} +7 -5
  131. package/dist/{openrouter-images-wvv7camj.js → openrouter-images-ssttr1y4.js} +6 -3
  132. package/dist/{otel-impl-trjpkfb7.js → otel-impl-h9yyza47.js} +2 -2
  133. package/dist/{pi-messages-nhh1pkz2.js → pi-messages-3nf8bq1a.js} +6 -4
  134. package/dist/{pi-mono-adapter-mcfkn9vf.js → pi-mono-adapter-tzps8f38.js} +9 -37
  135. package/dist/{pricing-refresh-d2b5my0y.js → pricing-refresh-nevhat8h.js} +3 -3
  136. package/dist/{rbac-roles-nxh5ahwk.js → rbac-roles-4y6ngvzq.js} +1 -1
  137. package/dist/{rbac-roles-hey3yv9d.js → rbac-roles-exbg8xb6.js} +2 -2
  138. package/dist/{render-v2-rr3p2atg.js → render-v2-cfsh13z3.js} +3 -3
  139. package/dist/{resume-2efkf1s1.js → resume-5s2t0dbs.js} +10 -8
  140. package/dist/{runtime-xe3aqy4x.js → runtime-bt5pkpm1.js} +1 -1
  141. package/dist/{session-access-s2mttxpw.js → session-access-dnbyp3cn.js} +6 -4
  142. package/dist/{setup-vypma5yt.js → setup-1wj1y77j.js} +1 -1
  143. package/dist/{typesafe-system-one-2hgpayzy.js → typesafe-system-one-bre38hvs.js} +4 -2
  144. package/dist/{virtual-modules-4r3zkc20.js → virtual-modules-mnfbh5mr.js} +8 -2
  145. package/dist/{work-objects-rvhckdkp.js → work-objects-7mg6216g.js} +25 -23
  146. package/dist/worker-nwb789mg.js +79 -0
  147. package/dist/{x-y45mthwn.js → x-pbpezr0s.js} +2 -2
  148. package/openapi.json +785 -5
  149. package/package.json +9 -9
  150. package/src/assets/key.ts +17 -0
  151. package/src/azure-devops/handlers.ts +1 -1
  152. package/src/be/db/approvals.ts +18 -0
  153. package/src/be/db/session-moves.ts +164 -0
  154. package/src/be/db/session-tree.ts +384 -0
  155. package/src/be/db/tasks/read.ts +40 -4
  156. package/src/be/db/workflow-runs.ts +96 -0
  157. package/src/be/db.ts +80 -236
  158. package/src/be/onboarding.ts +3 -0
  159. package/src/be/skill-sync.ts +43 -2
  160. package/src/be/swarm-config-guard.ts +1 -0
  161. package/src/bitbucket/api.ts +81 -0
  162. package/src/bitbucket/auth.ts +64 -0
  163. package/src/bitbucket/handlers.ts +434 -0
  164. package/src/bitbucket/index.ts +28 -0
  165. package/src/bitbucket/templates.ts +134 -0
  166. package/src/bitbucket/types.ts +65 -0
  167. package/src/commands/runner.ts +55 -3
  168. package/src/e2b/env.ts +1 -0
  169. package/src/extensions/contract-types.generated.ts +1 -1
  170. package/src/extensions/contract.ts +2 -2
  171. package/src/extensions/post-bridge.ts +1 -0
  172. package/src/github/handlers.ts +6 -6
  173. package/src/gitlab/handlers.ts +5 -5
  174. package/src/http/assets.ts +14 -2
  175. package/src/http/core.ts +12 -0
  176. package/src/http/index.ts +4 -0
  177. package/src/http/onboarding.ts +1 -0
  178. package/src/http/sessions.ts +366 -12
  179. package/src/http/skills.ts +48 -1
  180. package/src/http/tasks.ts +1 -1
  181. package/src/http/users.ts +1 -0
  182. package/src/http/webhooks.ts +141 -0
  183. package/src/integrations/chat-sdk/adapters.ts +19 -3
  184. package/src/integrations/chat-sdk/facts.ts +30 -12
  185. package/src/integrations/chat-sdk/outbound.ts +38 -1
  186. package/src/providers/acp-targets.ts +1 -1
  187. package/src/providers/amp-adapter.ts +1 -1
  188. package/src/providers/pi-mono-adapter.ts +5 -53
  189. package/src/scripts-runtime/types/swarm-extension.d.ts +6 -4
  190. package/src/slack/handlers.test.ts +158 -0
  191. package/src/slack/handlers.ts +19 -28
  192. package/src/tasks/context-key.ts +61 -1
  193. package/src/tests/bitbucket-auth.test.ts +156 -0
  194. package/src/tests/bitbucket-handlers.test.ts +425 -0
  195. package/src/tests/bitbucket-webhook.test.ts +220 -0
  196. package/src/tests/chat-sdk-inbound.test.ts +39 -0
  197. package/src/tests/chat-sdk-telegram-markdown.test.ts +92 -0
  198. package/src/tests/context-key.test.ts +50 -0
  199. package/src/tests/core-auth.test.ts +5 -0
  200. package/src/tests/db-task-read-contract.test.ts +7 -0
  201. package/src/tests/db-task-write-contract.test.ts +6 -0
  202. package/src/tests/docs-index-footprint.test.js +3 -3
  203. package/src/tests/fixtures/bitbucket/pullrequest-comment-created-inline.json +174 -0
  204. package/src/tests/fixtures/bitbucket/pullrequest-comment-created.json +170 -0
  205. package/src/tests/fixtures/bitbucket/pullrequest-created-mention.json +132 -0
  206. package/src/tests/fixtures/bitbucket/pullrequest-created-reviewer.json +140 -0
  207. package/src/tests/fixtures/bitbucket/pullrequest-fulfilled.json +134 -0
  208. package/src/tests/fixtures/bitbucket/pullrequest-rejected.json +133 -0
  209. package/src/tests/generate-mcp-docs.test.ts +101 -0
  210. package/src/tests/gitlab-handlers.test.ts +3 -3
  211. package/src/tests/gitlab-vcs-db.test.ts +4 -4
  212. package/src/tests/integration-disable-flags.test.ts +36 -0
  213. package/src/tests/mcp-tools.test.ts +1 -1
  214. package/src/tests/onboarding.test.ts +12 -0
  215. package/src/tests/pi-codemode.test.ts +8 -2
  216. package/src/tests/preload-env.test.ts +57 -0
  217. package/src/tests/preload.ts +21 -0
  218. package/src/tests/prompt-template-remaining.test.ts +38 -0
  219. package/src/tests/run-root-coverage.test.ts +39 -0
  220. package/src/tests/runner-bitbucket-vcs.test.ts +191 -0
  221. package/src/tests/runner-skills-refresh.test.ts +58 -0
  222. package/src/tests/secret-scrubber.test.ts +15 -0
  223. package/src/tests/sessions-move.test.ts +576 -0
  224. package/src/tests/sessions-tree.test.ts +493 -0
  225. package/src/tests/skill-files-http.test.ts +50 -0
  226. package/src/tests/skills-bundle.test.ts +99 -0
  227. package/src/tests/swarm-config-export-to-env.test.ts +2 -1
  228. package/src/tests/vcs-bitbucket.test.ts +91 -0
  229. package/src/tests/vcs-provider-isolation.test.ts +123 -0
  230. package/src/tests/vcs-provider.test.ts +16 -1
  231. package/src/tests/vcs-tracking.test.ts +3 -3
  232. package/src/tests/workflow-foreach-bodies.test.ts +637 -0
  233. package/src/tests/workflow-foreach-concurrency.test.ts +342 -0
  234. package/src/tests/workflow-foreach-retry.test.ts +537 -0
  235. package/src/tests/workflow-foreach.test.ts +44 -23
  236. package/src/tests/workflow-step-claims.test.ts +6 -6
  237. package/src/tests/workflow-tla-races.test.ts +2 -2
  238. package/src/tools/script-delete.ts +1 -1
  239. package/src/tools/workflows/create-workflow.ts +5 -3
  240. package/src/types.ts +3 -2
  241. package/src/utils/config-env-export.ts +1 -0
  242. package/src/utils/secret-scrubber.ts +4 -0
  243. package/src/utils/skills-refresh.ts +83 -43
  244. package/src/vcs/bitbucket.ts +58 -0
  245. package/src/vcs/index.ts +2 -0
  246. package/src/vcs/types.ts +2 -2
  247. package/src/workflows/approval-resolution.ts +4 -1
  248. package/src/workflows/checkpoint.ts +9 -1
  249. package/src/workflows/constants.ts +3 -0
  250. package/src/workflows/definition.ts +48 -17
  251. package/src/workflows/engine.ts +25 -15
  252. package/src/workflows/executors/foreach.ts +45 -99
  253. package/src/workflows/foreach-body.ts +146 -0
  254. package/src/workflows/foreach-driver.ts +552 -0
  255. package/src/workflows/foreach-join.ts +206 -7
  256. package/src/workflows/node-timeout.ts +21 -0
  257. package/src/workflows/recovery.ts +68 -3
  258. package/src/workflows/resume.ts +221 -43
  259. package/src/workflows/retry-poller.ts +39 -24
  260. package/src/workflows/step-input.ts +59 -0
  261. package/src/workflows/task-step-routing.ts +114 -56
  262. package/templates/skills/slack-interaction/content.md +5 -0
  263. package/templates/skills/work-on-task/SKILL.md +1 -1
  264. package/templates/skills/work-on-task/content.md +1 -1
  265. package/dist/cli-9hstj09h.js +0 -2657
  266. package/dist/cli-yw91rnmw.js +0 -13
  267. package/dist/handlers-7gw64dxw.js +0 -39
  268. package/dist/index-bgdyvcnv.js +0 -40
  269. package/dist/index-gabbcdn2.js +0 -43
  270. package/dist/index-tfb26zhy.js +0 -41
  271. package/dist/lead-7e02z839.js +0 -77
  272. package/dist/worker-5snytpbd.js +0 -77
@@ -0,0 +1,493 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import { keyPrefixRange, sessionGroupKey } from "../assets/key";
4
+ import {
5
+ closeDb,
6
+ countSessions,
7
+ createTaskExtended,
8
+ createUser,
9
+ getDbClient,
10
+ initDb,
11
+ listRecentSessions,
12
+ upsertInboxState,
13
+ } from "../be/db";
14
+ import { getSessionTree, type SessionFilters } from "../be/db/session-tree";
15
+ import { setSessionShares } from "../be/session-shares";
16
+ import { handleSessions } from "../http/sessions";
17
+ import { getPathSegments, parseQueryParams } from "../http/utils";
18
+ import { beginRequestAuthScope, setRequestAuth } from "../utils/request-auth-context";
19
+ import { listenOnFreePort } from "./test-net";
20
+
21
+ const NOW = "2026-10-21T12:00:00.000Z";
22
+ const RECENT = "2026-10-20T12:00:00.000Z";
23
+ const OLD = "2026-10-01T12:00:00.000Z";
24
+ let owner: { id: string };
25
+ let other: { id: string };
26
+ let server: Server;
27
+ let baseUrl: string;
28
+ const actor = () => ({ kind: "user", userId: owner.id }) as const;
29
+ const filters = (extra: SessionFilters & { limit?: number } = {}) => ({
30
+ viewer: { userId: owner.id, scope: "mine" } as const,
31
+ now: NOW,
32
+ ...extra,
33
+ });
34
+
35
+ beforeAll(async () => {
36
+ initDb(":memory:");
37
+ owner = await createUser({ name: "Owner" });
38
+ other = await createUser({ name: "Other" });
39
+ server = createServer(async (req, res) => {
40
+ try {
41
+ beginRequestAuthScope();
42
+ setRequestAuth(req, { kind: "operator", fingerprint: "sessions-tree-test" });
43
+ if (
44
+ await handleSessions(
45
+ req,
46
+ res,
47
+ getPathSegments(req.url ?? ""),
48
+ parseQueryParams(req.url ?? ""),
49
+ )
50
+ )
51
+ return;
52
+ res.writeHead(404).end();
53
+ } catch (error) {
54
+ res.writeHead(500).end(String(error));
55
+ }
56
+ });
57
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
58
+ });
59
+ beforeEach(async () => {
60
+ await getDbClient().run("DELETE FROM approval_requests");
61
+ await getDbClient().run("DELETE FROM inbox_item_state");
62
+ await getDbClient().run("DELETE FROM session_shares");
63
+ await getDbClient().run("DELETE FROM agent_tasks");
64
+ });
65
+ afterAll(async () => {
66
+ server.closeAllConnections();
67
+ await new Promise<void>((resolve) => server.close(() => resolve()));
68
+ closeDb();
69
+ });
70
+
71
+ async function task(
72
+ key = "shared/a/",
73
+ extra: Parameters<typeof createTaskExtended>[1] = {},
74
+ activity = RECENT,
75
+ ) {
76
+ const root = await createTaskExtended("Session test", {
77
+ key,
78
+ requestedByUserId: owner.id,
79
+ ...extra,
80
+ });
81
+ await getDbClient().run(
82
+ "UPDATE agent_tasks SET lastUpdatedAt = ?, status = 'completed' WHERE id = ?",
83
+ [activity, root.id],
84
+ );
85
+ return root;
86
+ }
87
+ async function state(
88
+ id: string,
89
+ status: "open" | "done" | "snoozed" | "dismissed",
90
+ lastUpdatedAt = NOW,
91
+ extra: { lastReadAt?: string; snoozeUntil?: string } = {},
92
+ ) {
93
+ await upsertInboxState({ userId: owner.id, itemType: "to_read", itemId: id, status, ...extra });
94
+ await getDbClient().run("UPDATE inbox_item_state SET lastUpdatedAt = ? WHERE itemId = ?", [
95
+ lastUpdatedAt,
96
+ id,
97
+ ]);
98
+ }
99
+ async function share(
100
+ root: { id: string; requestedByUserId?: string },
101
+ users = [{ userId: owner.id, access: "read" as const }],
102
+ ) {
103
+ await setSessionShares(
104
+ { id: root.id, requestedByUserId: root.requestedByUserId ?? null },
105
+ { everyone: "none", users },
106
+ other.id,
107
+ );
108
+ }
109
+ async function pending(taskId: string, status = "pending") {
110
+ const id = crypto.randomUUID();
111
+ await getDbClient().run(
112
+ `INSERT INTO approval_requests (id, sourceTaskId, title, questions, approvers, status)
113
+ VALUES (?, ?, 'Approve deploy', '[]', '{}', ?)`,
114
+ [id, taskId, status],
115
+ );
116
+ return id;
117
+ }
118
+ async function ids(extra: SessionFilters) {
119
+ return (await listRecentSessions(filters(extra))).map((s) => s.root.id).sort();
120
+ }
121
+
122
+ describe("session groups and prefix bounds", () => {
123
+ test("leaf helpers preserve group names and namespace roots", () => {
124
+ expect(keyPrefixRange("shared/a/")).toEqual({ lo: "shared/a/", hi: "shared/a0" });
125
+ for (const kind of ["task", "thread", "schedule", "workflow"]) {
126
+ expect(sessionGroupKey(`shared/a/${kind}:abc/`)).toBe("shared/a/");
127
+ }
128
+ for (const key of [
129
+ "shared/release:v2/",
130
+ "shared/mytask:abc/",
131
+ "shared/a/",
132
+ `personal/${owner.id}/`,
133
+ ]) {
134
+ expect(sessionGroupKey(key)).toBe(key);
135
+ }
136
+ });
137
+ test("non-BMP group keys agree between the tree count and the exact-group list", async () => {
138
+ const group = "shared/🚀/";
139
+ const leaf = await task(`${group}task:a/`);
140
+ const root = await task(group);
141
+ await task(`${group}b/`);
142
+ expect(await ids({ groupKey: group })).toEqual([leaf.id, root.id].sort());
143
+ const headers = { "x-swarm-user-id": owner.id };
144
+ const tree = (await (
145
+ await fetch(`${baseUrl}/api/session-tree`, { headers })
146
+ ).json()) as Awaited<ReturnType<typeof getSessionTree>>;
147
+ const total = tree.groups.find((g) => g.key === group)!.total;
148
+ const response = await fetch(
149
+ `${baseUrl}/api/sessions?scope=tree&groupKey=${encodeURIComponent(group)}`,
150
+ { headers },
151
+ );
152
+ const body = (await response.json()) as { total: number; sessions: unknown[] };
153
+ expect(total).toBe(2);
154
+ expect(body.total).toBe(total);
155
+ expect(body.sessions).toHaveLength(total);
156
+ });
157
+ test("exact group differs from prefix and the planner avoids a root table scan", async () => {
158
+ const a = await task();
159
+ const leaf = await task("shared/a/task:abc/");
160
+ const nested = await task("shared/a/b/");
161
+ await task("shared/ab/");
162
+ expect(await ids({ groupKey: "shared/a/" })).toEqual([a.id, leaf.id].sort());
163
+ expect(await ids({ keyPrefix: "shared/a/" })).toEqual([a.id, leaf.id, nested.id].sort());
164
+ expect(await countSessions(filters({ groupKey: "shared/a/" }))).toBe(2);
165
+ const client = getDbClient();
166
+ const query = spyOn(client, "query");
167
+ let call: (typeof query.mock.calls)[number];
168
+ try {
169
+ await listRecentSessions(filters({ groupKey: "shared/a/" }));
170
+ call = query.mock.calls[0]!;
171
+ } finally {
172
+ query.mockRestore();
173
+ }
174
+ const [sql, params] = call;
175
+ const plan = await client.query<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, params);
176
+ expect(plan.some((p) => p.detail.startsWith("SEARCH r USING INDEX"))).toBe(true);
177
+ expect(plan.some((p) => p.detail === "SCAN r")).toBe(false);
178
+ expect(sql).not.toContain("INDEXED BY");
179
+ expect(sql).not.toContain('"key" LIKE');
180
+ });
181
+ test("No group includes shared and my personal namespace, including leaves", async () => {
182
+ const a = await task("shared/");
183
+ const b = await task(`personal/${owner.id}/thread:abc/`);
184
+ await task(`personal/${other.id}/`);
185
+ await task("shared/release:v2/");
186
+ expect(await ids({ noGroup: true })).toEqual([a.id, b.id].sort());
187
+ });
188
+ });
189
+
190
+ describe("chain-wide read state", () => {
191
+ test("every CASE branch uses the fixed clock and chain activity", async () => {
192
+ const cases = [
193
+ { activity: RECENT, expected: "unread" },
194
+ { activity: OLD, expected: "read" },
195
+ { activity: "2026-10-14T12:00:00.000Z", expected: "read" },
196
+ { status: "dismissed", updated: OLD, expected: "done" },
197
+ { status: "done", updated: NOW, expected: "done" },
198
+ { status: "done", updated: OLD, expected: "unread" },
199
+ { status: "snoozed", snoozeUntil: "2026-10-22T12:00:00.000Z", expected: "later" },
200
+ { status: "snoozed", snoozeUntil: NOW, expected: "unread" },
201
+ { status: "snoozed", expected: "unread" },
202
+ { status: "open", updated: OLD, expected: "unread" },
203
+ { status: "open", updated: NOW, expected: "read" },
204
+ { status: "open", updated: NOW, lastReadAt: OLD, expected: "unread" },
205
+ { status: "open", updated: OLD, lastReadAt: RECENT, expected: "read" },
206
+ ] as const;
207
+ for (const fixture of cases) {
208
+ const root = await task("shared/a/", {}, "activity" in fixture ? fixture.activity : RECENT);
209
+ if ("status" in fixture)
210
+ await state(root.id, fixture.status, "updated" in fixture ? fixture.updated : NOW, {
211
+ ...("lastReadAt" in fixture ? { lastReadAt: fixture.lastReadAt } : {}),
212
+ ...("snoozeUntil" in fixture ? { snoozeUntil: fixture.snoozeUntil } : {}),
213
+ });
214
+ const row = (await listRecentSessions(filters({ limit: 100 }))).find(
215
+ (s) => s.root.id === root.id,
216
+ )!;
217
+ expect({ id: root.id, state: row.readState }).toEqual({
218
+ id: root.id,
219
+ state: fixture.expected,
220
+ });
221
+ }
222
+ const root = await task("shared/a/", {}, OLD);
223
+ await task("shared/a/", { parentTaskId: root.id }, RECENT);
224
+ expect(
225
+ (await listRecentSessions(filters({ limit: 100 }))).find((s) => s.root.id === root.id)
226
+ ?.readState,
227
+ ).toBe("unread");
228
+ const tree = await getSessionTree(actor(), NOW);
229
+ const rows = await listRecentSessions(filters({ limit: 100 }));
230
+ expect(tree.inbox.unread).toBe(rows.filter((s) => s.readState === "unread").length);
231
+ expect(tree.later.total).toBe(rows.filter((s) => s.readState === "later").length);
232
+ expect(tree.done.total).toBe(rows.filter((s) => s.readState === "done").length);
233
+ });
234
+ test("Later and Done leave group, prefix, No group and Inbox lists", async () => {
235
+ const read = await task("shared/");
236
+ const later = await task("shared/");
237
+ const laterFirst = await task("shared/");
238
+ const done = await task("shared/");
239
+ await state(later.id, "snoozed", NOW, { snoozeUntil: "2026-10-23T12:00:00.000Z" });
240
+ await state(laterFirst.id, "snoozed", NOW, { snoozeUntil: "2026-10-22T12:00:00.000Z" });
241
+ await state(done.id, "done");
242
+ for (const extra of [
243
+ { noGroup: true },
244
+ { groupKey: "shared/" },
245
+ { keyPrefix: "shared/" },
246
+ { triage: "inbox" as const },
247
+ ]) {
248
+ expect(await ids(extra)).toEqual([read.id]);
249
+ expect(await countSessions(filters(extra))).toBe(1);
250
+ }
251
+ expect((await listRecentSessions(filters({ triage: "later" }))).map((s) => s.root.id)).toEqual([
252
+ laterFirst.id,
253
+ later.id,
254
+ ]);
255
+ expect(await ids({ triage: "done" })).toEqual([done.id]);
256
+ const tree = await getSessionTree(actor(), NOW);
257
+ expect(tree.noGroup.total).toBe(1);
258
+ expect(tree.inbox.total).toBe(1);
259
+ });
260
+ });
261
+
262
+ test("needs attention uses the latest failed status and pending approvals anywhere in the chain", async () => {
263
+ const failed = await task();
264
+ const child = await task("shared/a/", { parentTaskId: failed.id }, NOW);
265
+ await getDbClient().run("UPDATE agent_tasks SET status = 'failed' WHERE id = ?", [child.id]);
266
+ const readFailed = await task();
267
+ await getDbClient().run("UPDATE agent_tasks SET status = 'failed' WHERE id = ?", [readFailed.id]);
268
+ await state(readFailed.id, "open", NOW, { lastReadAt: NOW });
269
+ const approved = await task();
270
+ const approvalChild = await task("shared/a/", { parentTaskId: approved.id }, NOW);
271
+ const approvalId = await pending(approvalChild.id);
272
+ const resolved = await task();
273
+ await pending(resolved.id, "approved");
274
+ expect(await ids({ status: "needs" })).toEqual([failed.id, approved.id].sort());
275
+ expect(await ids({ status: "failed" })).toEqual([failed.id, readFailed.id].sort());
276
+ expect(await countSessions(filters({ status: "needs" }))).toBe(2);
277
+ const response = await fetch(`${baseUrl}/api/sessions/${approved.id}`);
278
+ expect(((await response.json()) as { approvals: unknown[] }).approvals).toEqual([
279
+ { id: approvalId, status: "pending", sourceTaskId: approvalChild.id, title: "Approve deploy" },
280
+ ]);
281
+ expect((await getSessionTree(actor(), NOW)).inbox.needsYou).toBe(2);
282
+ });
283
+
284
+ test("running and person filters examine descendants after aggregation", async () => {
285
+ const roots = [];
286
+ for (const status of ["pending", "in_progress"]) {
287
+ const root = await task();
288
+ const child = await task(
289
+ "shared/a/",
290
+ { parentTaskId: root.id, requestedByUserId: other.id },
291
+ NOW,
292
+ );
293
+ await getDbClient().run("UPDATE agent_tasks SET status = ? WHERE id = ?", [status, child.id]);
294
+ roots.push(root.id);
295
+ }
296
+ await task();
297
+ expect(await ids({ status: "running" })).toEqual(roots.sort());
298
+ expect(await ids({ withUserId: other.id })).toEqual(roots.sort());
299
+ const shared = await task();
300
+ await share(shared, [{ userId: other.id, access: "read" }]);
301
+ expect(await ids({ withUserId: other.id })).toEqual([...roots, shared.id].sort());
302
+ expect(await countSessions(filters({ withUserId: other.id }))).toBe(3);
303
+ });
304
+
305
+ test("people are distinct, requester first, capped at 8, with batched shares and previews", async () => {
306
+ const root = await task();
307
+ const members: { id: string }[] = [];
308
+ for (let i = 0; i < 9; i++) {
309
+ const user = await createUser({ name: `Member ${i}` });
310
+ members.push(user);
311
+ await task("shared/a/", { parentTaskId: root.id, requestedByUserId: user.id }, RECENT);
312
+ }
313
+ await share(root, [{ userId: other.id, access: "read" }]);
314
+ const output = await task("shared/a/", { parentTaskId: root.id }, NOW);
315
+ await getDbClient().run("UPDATE agent_tasks SET output = ? WHERE id = ?", [
316
+ "p".repeat(250),
317
+ output.id,
318
+ ]);
319
+ const review = await task(
320
+ "shared/a/",
321
+ { parentTaskId: root.id, source: "system", taskType: "follow-up" },
322
+ "2026-10-22T00:00:00.000Z",
323
+ );
324
+ await getDbClient().run("UPDATE agent_tasks SET output = 'auto review' WHERE id = ?", [
325
+ review.id,
326
+ ]);
327
+ const empty = await task("shared/b/");
328
+ for (const value of ["", " "]) {
329
+ const blank = await task("shared/a/", { parentTaskId: root.id }, "2026-10-23T00:00:00.000Z");
330
+ await getDbClient().run("UPDATE agent_tasks SET output = ? WHERE id = ?", [value, blank.id]);
331
+ }
332
+ await getDbClient().run("UPDATE agent_tasks SET output = ' ' WHERE id = ?", [empty.id]);
333
+ const query = spyOn(getDbClient(), "query");
334
+ let rows: Awaited<ReturnType<typeof listRecentSessions>>;
335
+ let calls: number;
336
+ try {
337
+ rows = await listRecentSessions(filters());
338
+ calls = query.mock.calls.length;
339
+ } finally {
340
+ query.mockRestore();
341
+ }
342
+ expect(calls).toBe(3);
343
+ const row = rows.find((s) => s.root.id === root.id)!;
344
+ expect(row.people).toHaveLength(8);
345
+ expect(row.people![0]).toEqual({ id: owner.id, name: "Owner" });
346
+ expect(row.peopleCount).toBe(11);
347
+ expect(row.people!.slice(1).every((p) => members.some((m) => m.id === p.id))).toBe(true);
348
+ expect(row.shared).toBe(true);
349
+ expect(row.shares).toEqual([{ userId: other.id, name: "Other", access: "read" }]);
350
+ expect(row.preview).toBe("p".repeat(200));
351
+ expect(rows.find((s) => s.root.id === empty.id)).toMatchObject({
352
+ shared: false,
353
+ shares: [],
354
+ preview: null,
355
+ });
356
+ });
357
+
358
+ test("tree visibility includes mine and shares, excludes ownerless, and preserves Done-only groups", async () => {
359
+ const mine = await task("shared/a/");
360
+ const shared = await task(`personal/${other.id}/notes/`, { requestedByUserId: other.id });
361
+ await share(shared);
362
+ await task("shared/private/", { requestedByUserId: other.id });
363
+ await task("shared/ownerless/", { requestedByUserId: undefined });
364
+ const done = await task("shared/finished/");
365
+ await state(done.id, "done");
366
+ const tree = await getSessionTree(actor(), NOW);
367
+ expect(tree.groups.map((g) => g.key).sort()).toEqual(
368
+ ["shared/a/", `personal/${other.id}/notes/`, "shared/finished/"].sort(),
369
+ );
370
+ expect(tree.groups.find((g) => g.key === "shared/finished/")?.total).toBe(0);
371
+ expect(tree.inbox.total).toBe(2);
372
+ for (const group of tree.groups) {
373
+ expect(group.total).toBe(
374
+ await countSessions(
375
+ filters({ viewer: { userId: owner.id, scope: "all" }, groupKey: group.key }),
376
+ ),
377
+ );
378
+ }
379
+ expect((await getSessionTree({ kind: "bypass" }, NOW)).inbox.total).toBe(5);
380
+ const bypassRows = await listRecentSessions();
381
+ expect(bypassRows).toHaveLength(5);
382
+ expect(bypassRows.every((r) => r.readState === undefined)).toBe(true);
383
+ const route = await fetch(`${baseUrl}/api/session-tree`, {
384
+ headers: { "x-swarm-user-id": owner.id },
385
+ });
386
+ expect(route.status).toBe(200);
387
+ expect(((await route.json()) as Awaited<ReturnType<typeof getSessionTree>>).inbox.total).toBe(2);
388
+ expect(await ids({ triage: "inbox" })).toEqual([mine.id]);
389
+ });
390
+
391
+ test("tree scope list totals match sections while default scope includes ownerless roots", async () => {
392
+ await task("shared/a/");
393
+ await task("shared/");
394
+ const later = await task("shared/a/");
395
+ await task("shared/a/", { requestedByUserId: undefined });
396
+ await task("shared/", { requestedByUserId: undefined });
397
+ const ownerlessLater = await task("shared/a/", { requestedByUserId: undefined });
398
+ const snoozeUntil = new Date(Date.now() + 86400000).toISOString();
399
+ for (const root of [later, ownerlessLater]) {
400
+ await state(root.id, "snoozed", NOW, { snoozeUntil });
401
+ }
402
+ const headers = { "x-swarm-user-id": owner.id };
403
+ const response = await fetch(`${baseUrl}/api/session-tree`, { headers });
404
+ const tree = (await response.json()) as Awaited<ReturnType<typeof getSessionTree>>;
405
+ const comparisons = [
406
+ ["triage=inbox", tree.inbox.total, 2],
407
+ ["noGroup=true", tree.noGroup.total, 1],
408
+ ["groupKey=shared/a/", tree.groups.find((g) => g.key === "shared/a/")!.total, 1],
409
+ ["triage=later", tree.later.total, 1],
410
+ ] as const;
411
+ for (const [params, expected, ownerless] of comparisons) {
412
+ const scoped = await fetch(`${baseUrl}/api/sessions?scope=tree&${params}`, { headers });
413
+ const body = (await scoped.json()) as { total: number; sessions: unknown[] };
414
+ expect(body.total).toBe(expected);
415
+ expect(body.sessions).toHaveLength(expected);
416
+ const legacy = await fetch(`${baseUrl}/api/sessions?${params}`, { headers });
417
+ expect(((await legacy.json()) as { total: number }).total).toBe(expected + ownerless);
418
+ }
419
+ const bypass = await fetch(`${baseUrl}/api/sessions?scope=tree`);
420
+ const body = (await bypass.json()) as { total: number; sessions: { readState?: string }[] };
421
+ expect(body.total).toBe(6);
422
+ expect(body.sessions.every((row) => row.readState === undefined)).toBe(true);
423
+ for (const scope of ["mine", "shared"]) {
424
+ const response = await fetch(`${baseUrl}/api/sessions?scope=${scope}`);
425
+ expect(((await response.json()) as { total: number }).total).toBe(0);
426
+ }
427
+ });
428
+
429
+ test("session detail carries the viewer's read state with the list rule", async () => {
430
+ // The route reads the real clock, so the activity sits inside the 7-day window.
431
+ const hourAgo = new Date(Date.now() - 3_600_000).toISOString();
432
+ const root = await task("shared/a/", {}, new Date(Date.now() - 7_200_000).toISOString());
433
+ const child = await task("shared/a/", { parentTaskId: root.id }, hourAgo);
434
+ const headers = { "x-swarm-user-id": owner.id };
435
+ type Detail = { readState?: string; lastActivityAt?: string };
436
+ const detail = async (id: string, init?: RequestInit) =>
437
+ (await (await fetch(`${baseUrl}/api/sessions/${id}`, init)).json()) as Detail;
438
+ const listRow = async () => {
439
+ const res = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50`, { headers });
440
+ const body = (await res.json()) as {
441
+ sessions: { root: { id: string }; readState?: string; lastActivityAt: string }[];
442
+ };
443
+ return body.sessions.find((row) => row.root.id === root.id)!;
444
+ };
445
+
446
+ // Chain activity, not the root's own, decides it: the same row as the list.
447
+ const unread = await detail(root.id, { headers });
448
+ expect(unread).toMatchObject({ readState: "unread", lastActivityAt: hourAgo });
449
+ const row = await listRow();
450
+ expect({ readState: row.readState, lastActivityAt: row.lastActivityAt }).toEqual({
451
+ readState: unread.readState,
452
+ lastActivityAt: unread.lastActivityAt,
453
+ });
454
+
455
+ await state(root.id, "done", new Date().toISOString());
456
+ expect((await detail(root.id, { headers })).readState).toBe("done");
457
+ // A child id opens the same session, with the root's state.
458
+ expect((await detail(child.id, { headers })).readState).toBe("done");
459
+
460
+ // No picked user: no one to read for, so both fields stay out.
461
+ const bypass = await detail(root.id);
462
+ expect(bypass.readState).toBeUndefined();
463
+ expect(bypass.lastActivityAt).toBeUndefined();
464
+ });
465
+
466
+ test("limit clamps to 200 and count without chain filters does not recurse", async () => {
467
+ await getDbClient().transaction(async () => {
468
+ for (let i = 0; i < 205; i++)
469
+ await getDbClient().run(
470
+ `INSERT INTO agent_tasks
471
+ (id, task, status, "key", requestedByUserId, createdAt, lastUpdatedAt)
472
+ VALUES (?, 'Paging', 'completed', 'shared/a/', ?, ?, ?)`,
473
+ [crypto.randomUUID(), owner.id, RECENT, RECENT],
474
+ );
475
+ });
476
+ const query = spyOn(getDbClient(), "get");
477
+ let total: number;
478
+ let sql: string;
479
+ try {
480
+ total = await countSessions(filters());
481
+ sql = query.mock.calls[0]![0];
482
+ } finally {
483
+ query.mockRestore();
484
+ }
485
+ expect(total).toBe(205);
486
+ expect(sql).not.toContain("RECURSIVE");
487
+ const res = await fetch(`${baseUrl}/api/sessions?limit=500`);
488
+ expect(res.status).toBe(200);
489
+ const body = (await res.json()) as { limit: number; sessions: unknown[]; total: number };
490
+ expect(body.limit).toBe(200);
491
+ expect(body.sessions).toHaveLength(200);
492
+ expect(body.total).toBe(205);
493
+ });
@@ -169,4 +169,54 @@ describe("/api/skills/:id/files", () => {
169
169
  });
170
170
  expect(del.status).toBe(403);
171
171
  });
172
+
173
+ test("bundle returns the agent's skills with file contents and the signature", async () => {
174
+ const swarmSkill = await createSkill({
175
+ name: `bundle-skill-${crypto.randomUUID()}`,
176
+ description: "Bundle skill",
177
+ content: "---\nname: bundle-skill\ndescription: Bundle skill\n---\n\nBody.",
178
+ type: "personal",
179
+ scope: "swarm",
180
+ isComplex: true,
181
+ });
182
+ const post = await dispatch(`/api/skills/${swarmSkill.id}/files`, {
183
+ method: "POST",
184
+ body: JSON.stringify({
185
+ files: [{ path: "refs/notes.md", content: "bundled note", mimeType: "text/markdown" }],
186
+ }),
187
+ });
188
+ expect(post.status).toBe(200);
189
+
190
+ const bundle = await dispatch(`/api/agents/some-agent/skills/bundle`);
191
+ expect(bundle.status).toBe(200);
192
+ const body = (await bundle.json()) as {
193
+ skills: Array<{
194
+ id: string;
195
+ files: Array<{ path: string; content: string; isBinary: boolean }>;
196
+ }>;
197
+ total: number;
198
+ signature: string;
199
+ };
200
+
201
+ const bundled = body.skills.find((skill) => skill.id === swarmSkill.id);
202
+ expect(bundled?.files).toEqual([
203
+ expect.objectContaining({ path: "refs/notes.md", content: "bundled note", isBinary: false }),
204
+ ]);
205
+ expect(body.total).toBe(body.skills.length);
206
+
207
+ // The bundle signs the rows it returns: the cheap probe must agree, or a
208
+ // worker that wrote this payload would skip every later refresh.
209
+ const probe = await dispatch(`/api/agents/some-agent/skills/signature`);
210
+ const probeBody = (await probe.json()) as { hash: string };
211
+ expect(body.signature).toBe(probeBody.hash);
212
+
213
+ const updated = await dispatch(`/api/skills/${swarmSkill.id}`, {
214
+ method: "PUT",
215
+ body: JSON.stringify({ description: "Bundle skill, edited" }),
216
+ });
217
+ expect(updated.status).toBe(200);
218
+ const afterEdit = await dispatch(`/api/agents/some-agent/skills/bundle`);
219
+ const afterEditBody = (await afterEdit.json()) as { signature: string };
220
+ expect(afterEditBody.signature).not.toBe(body.signature);
221
+ });
172
222
  });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Coverage for the bundle's snapshot ordering.
3
+ *
4
+ * The worker caches the signature it receives from the bundle and only
5
+ * refreshes when a later probe differs. If the signature were taken after the
6
+ * file reads, an edit landing in between would ship stale file content under
7
+ * the newer hash and the worker would never refresh again.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { buildSkillsBundle, computeSkillsSignature } from "../be/skill-sync";
11
+ import type { SkillFile, SkillWithInstallInfo } from "../types";
12
+
13
+ function makeSkill(overrides: Partial<SkillWithInstallInfo> = {}): SkillWithInstallInfo {
14
+ return {
15
+ id: "skill-1",
16
+ name: "alpha",
17
+ description: "first",
18
+ content: "# Alpha",
19
+ type: "personal",
20
+ scope: "swarm",
21
+ ownerAgentId: null,
22
+ sourceUrl: null,
23
+ sourceRepo: null,
24
+ sourcePath: null,
25
+ sourceBranch: "main",
26
+ sourceHash: null,
27
+ isComplex: true,
28
+ allowedTools: null,
29
+ model: null,
30
+ effort: null,
31
+ context: null,
32
+ version: 1,
33
+ isEnabled: true,
34
+ systemDefault: false,
35
+ createdAt: "2026-01-01T00:00:00Z",
36
+ lastUpdatedAt: "2026-01-01T00:00:00Z",
37
+ isActive: true,
38
+ installedAt: "2026-01-02T00:00:00Z",
39
+ ...overrides,
40
+ } as SkillWithInstallInfo;
41
+ }
42
+
43
+ function makeFile(): SkillFile {
44
+ return {
45
+ id: "file-1",
46
+ skillId: "skill-1",
47
+ path: "refs/notes.md",
48
+ content: "notes",
49
+ mimeType: "text/markdown",
50
+ isBinary: false,
51
+ size: 5,
52
+ createdAt: "2026-01-01T00:00:00Z",
53
+ updatedAt: "2026-01-01T00:00:00Z",
54
+ } as SkillFile;
55
+ }
56
+
57
+ describe("buildSkillsBundle", () => {
58
+ test("signs the rows it returns even when an edit lands during the file read", async () => {
59
+ let rows = [makeSkill()];
60
+ let edited = false;
61
+
62
+ const bundle = await buildSkillsBundle(
63
+ async () => rows.map((row) => ({ ...row })),
64
+ async () => {
65
+ // An edit interleaves with the bundle read, after the rows were read.
66
+ edited = true;
67
+ rows = [
68
+ {
69
+ ...rows[0]!,
70
+ description: "edited",
71
+ lastUpdatedAt: "2026-10-22T00:00:00Z",
72
+ },
73
+ ];
74
+ return [makeFile()];
75
+ },
76
+ );
77
+
78
+ expect(edited).toBe(true);
79
+ // The payload carries the pre-edit rows, so the signature must describe
80
+ // those rows — not the post-edit state that a second read would see.
81
+ expect(bundle.signature).toBe(computeSkillsSignature(bundle.skills).hash);
82
+ expect(bundle.signature).not.toBe(computeSkillsSignature(rows).hash);
83
+ expect(bundle.skills[0]?.description).toBe("first");
84
+ });
85
+
86
+ test("a simple skill carries no bundled files and needs no file read", async () => {
87
+ let fileReads = 0;
88
+ const bundle = await buildSkillsBundle(
89
+ async () => [makeSkill({ id: "skill-2", name: "beta", isComplex: false })],
90
+ async () => {
91
+ fileReads++;
92
+ return [makeFile()];
93
+ },
94
+ );
95
+
96
+ expect(fileReads).toBe(0);
97
+ expect(bundle.skills[0]?.files).toEqual([]);
98
+ });
99
+ });
@@ -247,8 +247,9 @@ describe("migration 215", () => {
247
247
  ];
248
248
  // Restricted after migration 215 shipped. Row readers force the flag off
249
249
  // (effectiveExportToEnv), so an older stored true never reaches an agent.
250
+ const RESTRICTED_AFTER_215 = new Set(["TRANSCRIPTION_API_KEY", "BITBUCKET_WEBHOOK_SECRET"]);
250
251
  const RESTRICTED_AT_215 = [...ENV_EXPORT_RESTRICTED_KEYS].filter(
251
- (key) => key !== "TRANSCRIPTION_API_KEY",
252
+ (key) => !RESTRICTED_AFTER_215.has(key),
252
253
  );
253
254
 
254
255
  test("backfills true and turns off only the deny list", () => {