@desplega.ai/agent-swarm 2.5.4 → 2.6.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 (271) hide show
  1. package/README.md +2 -2
  2. package/dist/{acp-adapter-ek38cwsv.js → acp-adapter-dgfakfpw.js} +9 -9
  3. package/dist/{actions-92mepq03.js → actions-fbqma8aa.js} +21 -21
  4. package/dist/{amp-adapter-9gaxg015.js → amp-adapter-en0jy6hs.js} +4 -4
  5. package/dist/{app-ctn8gsgx.js → app-k8a5cgpp.js} +3 -3
  6. package/dist/{assistant-zyr5kz47.js → assistant-a16433gr.js} +29 -25
  7. package/dist/{boot-reembed-dwzfttdr.js → boot-reembed-6c1sjfnr.js} +1 -1
  8. package/dist/{boot-reembed-w75maga2.js → boot-reembed-f4nn561n.js} +1 -1
  9. package/dist/{boot-scrub-sweep-e9kq43kf.js → boot-scrub-sweep-d4w9gajf.js} +4 -4
  10. package/dist/{boot-seeds-x28v3fhb.js → boot-seeds-9bmkspyw.js} +4 -4
  11. package/dist/{claude-adapter-f0afqnne.js → claude-adapter-nys1kjxa.js} +9 -9
  12. package/dist/{claude-managed-adapter-x81a7r65.js → claude-managed-adapter-55207973.js} +2 -2
  13. package/dist/{claude-managed-setup-bqzwjd9x.js → claude-managed-setup-3cm35gey.js} +10 -3
  14. package/dist/{claude-sdk-session-7b7whkaq.js → claude-sdk-session-nw5yd8cp.js} +11 -11
  15. package/dist/{cli-c61mbdds.js → cli-03gg9pd1.js} +4 -4
  16. package/dist/{cli-kxpfx2xr.js → cli-0z1xjzwa.js} +2 -2
  17. package/dist/cli-1tem6ns9.js +894 -0
  18. package/dist/{cli-jbnn4vxb.js → cli-24j1jeeb.js} +3 -3
  19. package/dist/{cli-pzatvw94.js → cli-2sjhpsns.js} +2 -2
  20. package/dist/{cli-dew6x255.js → cli-39aet655.js} +2 -2
  21. package/dist/{cli-g1cw7xtt.js → cli-39rrw8yp.js} +1 -1
  22. package/dist/{cli-x0f2v69c.js → cli-3aqd4jaa.js} +12 -7
  23. package/dist/{cli-gbfxp48k.js → cli-3fwqy71j.js} +15 -15
  24. package/dist/{cli-7p4jxgtt.js → cli-3gfvmzht.js} +6 -6
  25. package/dist/{cli-03vpq45j.js → cli-3ndmpxxv.js} +2 -2
  26. package/dist/{cli-asq5ydm4.js → cli-4hxrf0p4.js} +131 -50
  27. package/dist/{cli-mz9afxxf.js → cli-4rf0f5vg.js} +15 -3
  28. package/dist/{cli-k4tjsdx9.js → cli-5rj83bam.js} +272 -195
  29. package/dist/{cli-sbwsxvtd.js → cli-5sckpqcx.js} +2 -2
  30. package/dist/{cli-cz5nmdxt.js → cli-683y0b3a.js} +2 -2
  31. package/dist/{cli-ethgy7ha.js → cli-6y51ryva.js} +2 -2
  32. package/dist/{cli-n4c8e3s5.js → cli-708wdy32.js} +1 -1
  33. package/dist/{cli-pasyvjmm.js → cli-737pyqxr.js} +1 -1
  34. package/dist/{cli-gx4rm0qx.js → cli-7enjwya9.js} +1 -1
  35. package/dist/{cli-6x9ma5w5.js → cli-803188pb.js} +1 -1
  36. package/dist/{cli-hh5we352.js → cli-9abdgn5t.js} +7 -7
  37. package/dist/{cli-ff1tm9n9.js → cli-a2rr0fmg.js} +1 -1
  38. package/dist/{cli-c952c7ac.js → cli-a34pr3w1.js} +2 -2
  39. package/dist/{cli-s682zgqz.js → cli-abkm71vr.js} +1 -1
  40. package/dist/{cli-mmhj8vd7.js → cli-ag2yrnt5.js} +11 -2
  41. package/dist/{cli-bvpgr9ny.js → cli-aj8djyqt.js} +5 -5
  42. package/dist/{cli-gwhjmh8g.js → cli-art7c1zh.js} +5 -5
  43. package/dist/{cli-5bnsc0e1.js → cli-az25fn5f.js} +4 -4
  44. package/dist/{cli-9s05422w.js → cli-bqztfyve.js} +1 -1
  45. package/dist/{cli-bn0rewsy.js → cli-c081bj3b.js} +2 -2
  46. package/dist/{cli-1r5ad7j5.js → cli-c2habvsf.js} +12 -12
  47. package/dist/{cli-64mh9mf3.js → cli-ca6banwz.js} +69 -17
  48. package/dist/{cli-25n3mv5d.js → cli-cz37jqss.js} +5 -5
  49. package/dist/{cli-6n8axjv1.js → cli-d2dpf95n.js} +2 -2
  50. package/dist/{cli-gn5af0x5.js → cli-emhjcj2y.js} +11 -11
  51. package/dist/{cli-6a1xg1p7.js → cli-esakngh8.js} +2 -2
  52. package/dist/{cli-ha9rmwgq.js → cli-fb8m699c.js} +2 -2
  53. package/dist/{cli-4b2z6n0m.js → cli-ffvsj2qw.js} +17 -8
  54. package/dist/{cli-abs8ndtr.js → cli-g20jjwt4.js} +26 -26
  55. package/dist/{cli-jb30dzvk.js → cli-g8ka5j4j.js} +446 -164
  56. package/dist/{cli-19202kew.js → cli-gf95q3bm.js} +5 -5
  57. package/dist/{cli-eftzyh9y.js → cli-gnsb1zxa.js} +2 -2
  58. package/dist/{cli-nwyw3zhg.js → cli-gxee0bqa.js} +1 -1
  59. package/dist/{cli-e42vss7b.js → cli-hfeb68z4.js} +5 -5
  60. package/dist/{cli-g9vd9ece.js → cli-k21gx5ht.js} +14 -16
  61. package/dist/{cli-tsma7p8v.js → cli-kf4jree9.js} +2 -2
  62. package/dist/{cli-dabehxhp.js → cli-kt907da6.js} +1 -1
  63. package/dist/{cli-wx2nj19m.js → cli-m5a9m0ak.js} +6 -6
  64. package/dist/{cli-621ce7kw.js → cli-m70bajzr.js} +3 -3
  65. package/dist/{cli-9j129brq.js → cli-mtnj4fwa.js} +6 -6
  66. package/dist/{cli-xzfq4at3.js → cli-mvjz5an3.js} +15 -11
  67. package/dist/{cli-277c31dw.js → cli-n7asmce6.js} +225 -3
  68. package/dist/{cli-xtrz6bzs.js → cli-nn6vx2mq.js} +1 -1
  69. package/dist/{cli-2655k16q.js → cli-nq677csy.js} +1 -1
  70. package/dist/{cli-0shx9j93.js → cli-pfk92eqz.js} +2 -2
  71. package/dist/{cli-wghjkr0e.js → cli-sqma6vw1.js} +10 -7
  72. package/dist/{cli-nzfnt2gs.js → cli-tf1n0r0b.js} +1 -1
  73. package/dist/{cli-qqzmjxkf.js → cli-v49fkghp.js} +11 -6
  74. package/dist/{cli-x77gen2p.js → cli-w2656ka1.js} +7 -7
  75. package/dist/{cli-5kqnemen.js → cli-yvy48785.js} +1 -1
  76. package/dist/{cli-m4ecdxvz.js → cli-zqne1qcj.js} +7 -7
  77. package/dist/cli.js +17 -17
  78. package/dist/{codex-adapter-wch12q99.js → codex-adapter-d95q4qsn.js} +7 -7
  79. package/dist/{codex-login-rn7c5gxt.js → codex-login-8cg0vhf2.js} +1 -1
  80. package/dist/{codex-session-runner-41pfcr95.js → codex-session-runner-gdwd8h0c.js} +8 -8
  81. package/dist/{commands-9849dkj6.js → commands-jqmgk54b.js} +2 -2
  82. package/dist/{controller-ja0sd95p.js → controller-z3jq589f.js} +4 -4
  83. package/dist/{controllers-connect-xp05dj6x.js → controllers-connect-r2za39ef.js} +23 -23
  84. package/dist/{cursor-adapter-623qxqew.js → cursor-adapter-4mr4jgkw.js} +2 -2
  85. package/dist/{db-47vj40dy.js → db-ppv2tze7.js} +3 -3
  86. package/dist/{db-w759qmx0.js → db-w36wwb5g.js} +4 -2
  87. package/dist/{devin-adapter-4ycy4vrf.js → devin-adapter-byen2cxt.js} +1 -1
  88. package/dist/{dispatcher-7b8e4z2j.js → dispatcher-pv5h77vf.js} +20 -20
  89. package/dist/{docker-s84hjs91.js → docker-fdzyewpz.js} +2 -2
  90. package/dist/{dsh-adapter-2ezw3xea.js → dsh-adapter-d4pdpb3r.js} +2 -2
  91. package/dist/{e2b-9gkdhm3c.js → e2b-222hsajc.js} +2 -2
  92. package/dist/{e2b-cjtf8k1z.js → e2b-fn3zacvt.js} +2 -2
  93. package/dist/{easter-egg-3d-e4qv8dxj.js → easter-egg-3d-4sp4dvmc.js} +1 -1
  94. package/dist/{event-bus-3z78ep5n.js → event-bus-pghg8hqy.js} +1 -1
  95. package/dist/{execute-0xpz8950.js → execute-jev839y1.js} +1 -1
  96. package/dist/{external-channel-e2s028da.js → external-channel-y6nkzhnn.js} +2 -2
  97. package/dist/{grok-adapter-ys7gaqee.js → grok-adapter-t08b9ntm.js} +10 -10
  98. package/dist/handlers-eqsnmv95.js +41 -0
  99. package/dist/{heartbeat-w3mntghj.js → heartbeat-vn31xwdk.js} +23 -23
  100. package/dist/{hook-g7kvv6x9.js → hook-6d0zbvvt.js} +6 -6
  101. package/dist/{hook-3jjywnpb.js → hook-cpjs8q51.js} +8 -8
  102. package/dist/{http-tkp7d4kb.js → http-9c6medda.js} +856 -99
  103. package/dist/{index-xdaahthb.js → index-38t35h0n.js} +20 -20
  104. package/dist/{index-3n8m9ea2.js → index-g6k9tctj.js} +1 -1
  105. package/dist/{index-1ef1myq2.js → index-k0yt1tvq.js} +20 -20
  106. package/dist/{index-31ceyf42.js → index-pkma1yf8.js} +1 -1
  107. package/dist/{index-q7m26kvs.js → index-qhjf2htm.js} +13 -10
  108. package/dist/index-yqgbfgaj.js +43 -0
  109. package/dist/{keepalive-v4ta8wgg.js → keepalive-em0117bp.js} +4 -4
  110. package/dist/lead-23e7z59t.js +79 -0
  111. package/dist/{maintenance-84n1ex21.js → maintenance-kbme3qy9.js} +1 -1
  112. package/dist/{oauth-refresh-sweep-vjnnandw.js → oauth-refresh-sweep-xysj4yse.js} +3 -3
  113. package/dist/{onboard-sfmpe4np.js → onboard-p6q0vz97.js} +3 -3
  114. package/dist/{opencode-adapter-jmpst4q4.js → opencode-adapter-esv3k9yn.js} +7 -7
  115. package/dist/{otel-impl-v6cc0zve.js → otel-impl-54qhqh1z.js} +2 -2
  116. package/dist/{pi-mono-adapter-fk8fkcf6.js → pi-mono-adapter-mymbwhj0.js} +10 -10
  117. package/dist/{pricing-refresh-fsk1e1zc.js → pricing-refresh-yb75wv33.js} +3 -3
  118. package/dist/{rbac-roles-prmkdnkw.js → rbac-roles-1vp2mw4p.js} +2 -2
  119. package/dist/{rbac-roles-zxmxqa39.js → rbac-roles-dexhc9x8.js} +1 -1
  120. package/dist/render-v2-tjay5hhf.js +16 -0
  121. package/dist/{resume-frdtgfa0.js → resume-8ayf37g5.js} +9 -9
  122. package/dist/{runtime-bt5pkpm1.js → runtime-hqe5cqcd.js} +1 -1
  123. package/dist/{session-access-fpez4y5q.js → session-access-fdhp987s.js} +4 -4
  124. package/dist/session-source-backfill-qgtc86z1.js +110 -0
  125. package/dist/{setup-mq6m3m8q.js → setup-4rrmqcq3.js} +1 -1
  126. package/dist/{virtual-modules-mnfbh5mr.js → virtual-modules-52km00d6.js} +2 -2
  127. package/dist/{work-objects-kfahhjsq.js → work-objects-p28fgd70.js} +23 -23
  128. package/dist/worker-e3w7vye1.js +79 -0
  129. package/dist/{x-pbpezr0s.js → x-wxrzvzx8.js} +2 -2
  130. package/openapi.json +1274 -32
  131. package/package.json +1 -1
  132. package/src/assets/source-key.ts +95 -0
  133. package/src/be/db/api-keys.ts +80 -0
  134. package/src/be/db/session-labels.ts +28 -0
  135. package/src/be/db/session-moves.ts +5 -6
  136. package/src/be/db/session-tree.ts +153 -14
  137. package/src/be/db/tasks/read.ts +11 -0
  138. package/src/be/db/tasks/write.ts +6 -1
  139. package/src/be/db/workflow-runs.ts +183 -1
  140. package/src/be/db-retention.ts +61 -6
  141. package/src/be/db.ts +59 -114
  142. package/src/be/memory/providers/openai-embedding.ts +5 -1
  143. package/src/be/migrations/244_api_key_validation.sql +4 -0
  144. package/src/be/onboarding.ts +4 -0
  145. package/src/be/scripts/typecheck.ts +1 -1
  146. package/src/be/session-source-backfill.ts +163 -0
  147. package/src/be/swarm-config-guard.ts +1 -0
  148. package/src/comb/review-batch.ts +28 -1
  149. package/src/comb/templates.ts +7 -2
  150. package/src/commands/claude-managed-setup.ts +7 -1
  151. package/src/commands/credential-refresh.ts +76 -1
  152. package/src/commands/credential-wait.ts +2 -2
  153. package/src/commands/provider-credentials.ts +210 -170
  154. package/src/commands/runner.ts +25 -4
  155. package/src/extensions/apply-task-create.ts +8 -1
  156. package/src/extensions/contract-types.generated.ts +1 -1
  157. package/src/extensions/contract.ts +5 -0
  158. package/src/http/all-routes.ts +2 -0
  159. package/src/http/api-keys.ts +53 -0
  160. package/src/http/assets.ts +15 -0
  161. package/src/http/codex-oauth-accounts.ts +191 -0
  162. package/src/http/comb.ts +15 -2
  163. package/src/http/config.ts +1 -1
  164. package/src/http/core.ts +6 -0
  165. package/src/http/fleet-benchmark.ts +5 -2
  166. package/src/http/index.ts +11 -0
  167. package/src/http/integrations.ts +5 -1
  168. package/src/http/kv.ts +8 -3
  169. package/src/http/onboarding.ts +6 -1
  170. package/src/http/pool-keys.ts +354 -0
  171. package/src/http/sessions.ts +37 -12
  172. package/src/http/stats.ts +1 -0
  173. package/src/http/status.ts +26 -12
  174. package/src/http/tasks.ts +3 -0
  175. package/src/http/used-by.ts +7 -2
  176. package/src/http/workflows.ts +78 -9
  177. package/src/integrations/chat-sdk/inbound.ts +27 -0
  178. package/src/kv-reserved-namespaces.ts +19 -0
  179. package/src/providers/acp-targets.ts +1 -1
  180. package/src/providers/amp-adapter.ts +1 -1
  181. package/src/providers/claude-adapter.ts +5 -1
  182. package/src/providers/claude-managed-adapter.ts +16 -9
  183. package/src/providers/codex-adapter.ts +71 -11
  184. package/src/scheduler/scheduler.ts +3 -2
  185. package/src/scripts-runtime/swarm-sdk.ts +7 -1
  186. package/src/scripts-runtime/types/stdlib.d.ts +1 -1
  187. package/src/scripts-runtime/types/swarm-extension.d.ts +17 -8
  188. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -1
  189. package/src/slack/assistant.ts +7 -0
  190. package/src/slack/channel-labels.ts +74 -0
  191. package/src/slack/handlers.ts +8 -0
  192. package/src/slack/inbound-files.ts +5 -2
  193. package/src/slack/thread-buffer.ts +4 -0
  194. package/src/tasks/sibling-awareness.ts +12 -3
  195. package/src/telemetry-context.ts +5 -6
  196. package/src/telemetry-identity.ts +3 -3
  197. package/src/tests/api-keys-validation.test.ts +170 -0
  198. package/src/tests/claude-managed-adapter.test.ts +46 -0
  199. package/src/tests/claude-routes-e2e.test.ts +7 -2
  200. package/src/tests/codex-credential-pools.test.ts +120 -0
  201. package/src/tests/codex-gateway-route.test.ts +165 -0
  202. package/src/tests/codex-oauth-accounts.test.ts +357 -0
  203. package/src/tests/comb-review-batch.test.ts +63 -2
  204. package/src/tests/config-pool-keys.test.ts +533 -0
  205. package/src/tests/credential-check.test.ts +290 -2
  206. package/src/tests/credential-refresh.test.ts +172 -1
  207. package/src/tests/db-retention.test.ts +9 -3
  208. package/src/tests/db-task-read-contract.test.ts +1 -0
  209. package/src/tests/db-task-write-contract.test.ts +1 -3
  210. package/src/tests/docs-index-footprint.test.js +3 -3
  211. package/src/tests/extensions-pre-task.test.ts +53 -1
  212. package/src/tests/fixtures/extensions/record-task-create/hooks.ts +9 -0
  213. package/src/tests/fixtures/extensions/record-task-create/manifest.json +7 -0
  214. package/src/tests/fleet-benchmark.test.ts +8 -0
  215. package/src/tests/get-workflow-run-tool.test.ts +98 -0
  216. package/src/tests/integrations-http.test.ts +32 -2
  217. package/src/tests/internal-ai/complete-structured.test.ts +37 -1
  218. package/src/tests/internal-ai/credentials.test.ts +23 -0
  219. package/src/tests/kv-http.test.ts +32 -0
  220. package/src/tests/kv-tool.test.ts +25 -0
  221. package/src/tests/multi-runtime-registration.test.ts +12 -12
  222. package/src/tests/onboarding.test.ts +108 -1
  223. package/src/tests/openai-embedding-credentials.test.ts +35 -0
  224. package/src/tests/openrouter-attribution-call-sites.test.ts +2 -2
  225. package/src/tests/org-website.test.ts +36 -0
  226. package/src/tests/provider-key-probes.test.ts +181 -0
  227. package/src/tests/rbac-e2e-helpers.test.ts +88 -1
  228. package/src/tests/rbac-e2e-helpers.ts +36 -11
  229. package/src/tests/script-connections.test.ts +2 -0
  230. package/src/tests/script-runs-retention.test.ts +212 -0
  231. package/src/tests/sdk-allowlist.test.ts +39 -0
  232. package/src/tests/session-source-backfill.test.ts +429 -0
  233. package/src/tests/session-source-keys.test.ts +236 -0
  234. package/src/tests/session-source-labels.test.ts +419 -0
  235. package/src/tests/sessions-detail-batch.test.ts +2 -0
  236. package/src/tests/sessions-move.test.ts +131 -0
  237. package/src/tests/sessions-tree.test.ts +299 -2
  238. package/src/tests/slack-inbound-files.test.ts +103 -23
  239. package/src/tests/status.test.ts +101 -18
  240. package/src/tests/tasks-workflow-run-filter.test.ts +146 -0
  241. package/src/tests/test-net.ts +31 -0
  242. package/src/tests/transcription.test.ts +8 -0
  243. package/src/tests/used-by-route.test.ts +11 -0
  244. package/src/tests/workflow-cascade-cancel.test.ts +172 -0
  245. package/src/tests/workflow-http-v2.test.ts +131 -0
  246. package/src/tests/workflow-step-order.test.ts +92 -0
  247. package/src/tests/workflow-sub-workflow.test.ts +30 -0
  248. package/src/tools/get-tasks.ts +8 -0
  249. package/src/tools/kv/kv-read-auth.ts +4 -2
  250. package/src/tools/task-action.ts +1 -1
  251. package/src/tools/workflows/get-workflow-run.ts +14 -4
  252. package/src/types.ts +24 -0
  253. package/src/utils/company-profile.ts +51 -1
  254. package/src/utils/credentials.ts +5 -2
  255. package/src/utils/internal-ai/complete-structured.ts +5 -0
  256. package/src/utils/internal-ai/credentials.ts +4 -3
  257. package/src/utils/provider-key-probes.ts +328 -0
  258. package/src/utils/transcription.ts +4 -1
  259. package/src/workflows/executors/sub-workflow.ts +8 -1
  260. package/templates/ai-toolbox.manifest.json +2 -2
  261. package/templates/skills/researching/content.md +2 -0
  262. package/templates/skills/swarm-scripts/SKILL.md +1 -0
  263. package/templates/skills/swarm-scripts/content.md +1 -0
  264. package/dist/cli-7421jzsw.js +0 -84
  265. package/dist/cli-e45e4m6b.js +0 -413
  266. package/dist/cli-zw88zjba.js +0 -307
  267. package/dist/handlers-wy1dr6ja.js +0 -41
  268. package/dist/index-nmm79qgr.js +0 -43
  269. package/dist/lead-sawnyqxk.js +0 -79
  270. package/dist/render-v2-pjwxvnkw.js +0 -12
  271. package/dist/worker-v5e5rxj3.js +0 -79
@@ -0,0 +1,419 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, spyOn, test } from "bun:test";
2
+ import { createServer, type Server } from "node:http";
3
+ import { closeDb, createAgent, createTaskExtended, getDbClient, initDb, upsertKv } from "../be/db";
4
+ import * as labelsDb from "../be/db/session-labels";
5
+ import { getSessionLabels, rememberSessionLabel } from "../be/db/session-labels";
6
+ import { handleSessions } from "../http/sessions";
7
+ import { getPathSegments, parseQueryParams } from "../http/utils";
8
+ import { flushChatThread } from "../integrations/chat-sdk/inbound";
9
+ import type { ChatBufferedMessage } from "../integrations/chat-sdk/thread-buffer";
10
+ import * as slackApp from "../slack/app";
11
+ import { warmSlackChannelLabels } from "../slack/channel-labels";
12
+ import { chatSdkContextKey } from "../tasks/context-key";
13
+ import { beginRequestAuthScope, setRequestAuth } from "../utils/request-auth-context";
14
+ import {
15
+ flushChatBuffers,
16
+ postTelegramUpdate,
17
+ startTelegramHarness,
18
+ telegramUpdate,
19
+ } from "./chat-sdk-harness";
20
+ import { listenOnFreePort } from "./test-net";
21
+
22
+ let server: Server;
23
+ let baseUrl: string;
24
+ const getApp = spyOn(slackApp, "getSlackApp");
25
+
26
+ beforeAll(async () => {
27
+ initDb(":memory:");
28
+ await createAgent({ id: crypto.randomUUID(), name: "LabelsLead", isLead: true, status: "idle" });
29
+ server = createServer(async (req, res) => {
30
+ try {
31
+ beginRequestAuthScope();
32
+ setRequestAuth(req, { kind: "operator", fingerprint: "session-labels-test" });
33
+ if (
34
+ await handleSessions(
35
+ req,
36
+ res,
37
+ getPathSegments(req.url ?? ""),
38
+ parseQueryParams(req.url ?? ""),
39
+ )
40
+ )
41
+ return;
42
+ res.writeHead(404).end();
43
+ } catch (error) {
44
+ res.writeHead(500).end(String(error));
45
+ }
46
+ });
47
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
48
+ });
49
+
50
+ beforeEach(async () => {
51
+ getApp.mockReturnValue(null);
52
+ await getDbClient().run("DELETE FROM agent_tasks");
53
+ await getDbClient().run("DELETE FROM kv_entries WHERE namespace = 'session-labels'");
54
+ });
55
+ afterEach(() => {
56
+ getApp.mockReturnValue(null);
57
+ });
58
+ afterAll(async () => {
59
+ getApp.mockRestore();
60
+ server.closeAllConnections();
61
+ await new Promise<void>((resolve) => server.close(() => resolve()));
62
+ closeDb();
63
+ });
64
+
65
+ async function until(check: () => Promise<boolean> | boolean): Promise<void> {
66
+ for (let attempt = 0; attempt < 200; attempt++) {
67
+ if (await check()) return;
68
+ await Bun.sleep(5);
69
+ }
70
+ throw new Error("Background label lookup did not finish");
71
+ }
72
+
73
+ function fakeSlack(
74
+ apiCall: (method: string, payload: Record<string, unknown>) => Promise<unknown>,
75
+ ) {
76
+ const call = mock(apiCall);
77
+ getApp.mockReturnValue({ client: { apiCall: call } } as unknown as NonNullable<
78
+ ReturnType<typeof slackApp.getSlackApp>
79
+ >);
80
+ return call;
81
+ }
82
+
83
+ const keyOf = (id: string) => `shared/slack/${id}/`;
84
+ async function hasLabel(id: string, label: string) {
85
+ return (await getSessionLabels([keyOf(id)]))[keyOf(id)] === label;
86
+ }
87
+
88
+ test("cache reads one batch, keeps plain strings and skips expired or foreign rows", async () => {
89
+ const now = Date.now();
90
+ await rememberSessionLabel("shared/telegram/-1/", "Life");
91
+ await rememberSessionLabel(keyOf("c1"), "#eng", 86_400_000);
92
+ await rememberSessionLabel(keyOf("expired"), "old", -1);
93
+ await upsertKv({
94
+ namespace: "other",
95
+ key: keyOf("foreign"),
96
+ value: "other",
97
+ valueType: "string",
98
+ });
99
+ const client = getDbClient();
100
+ const query = spyOn(client, "query");
101
+ try {
102
+ expect(await getSessionLabels([])).toEqual({});
103
+ expect(query).not.toHaveBeenCalled();
104
+ expect(
105
+ await getSessionLabels([
106
+ "shared/telegram/-1/",
107
+ keyOf("c1"),
108
+ keyOf("expired"),
109
+ keyOf("foreign"),
110
+ ]),
111
+ ).toEqual({
112
+ "shared/telegram/-1/": "Life",
113
+ [keyOf("c1")]: "#eng",
114
+ });
115
+ expect(query).toHaveBeenCalledTimes(1);
116
+ } finally {
117
+ query.mockRestore();
118
+ }
119
+ const row = await client.get<{ value: string; value_type: string; expires_at: number }>(
120
+ "SELECT value, value_type, expires_at FROM kv_entries WHERE namespace = 'session-labels' AND key = ?",
121
+ [keyOf("c1")],
122
+ );
123
+ expect(row).toMatchObject({ value: "#eng", value_type: "string" });
124
+ expect(row!.expires_at).toBeGreaterThanOrEqual(now + 86_400_000);
125
+ });
126
+
127
+ test("Slack resolves a channel with uppercase id, a DM and a group DM with a 24 hour TTL", async () => {
128
+ const now = Date.now();
129
+ const call = fakeSlack(async (method, payload) => {
130
+ if (method === "users.info")
131
+ return { ok: true, user: { profile: { display_name: "Lena" }, real_name: "Elena" } };
132
+ if (payload.channel === "DLABEL") return { ok: true, channel: { is_im: true, user: "ULENA" } };
133
+ if (payload.channel === "GLABEL")
134
+ return { ok: true, channel: { is_mpim: true, name: "private" } };
135
+ return { ok: true, channel: { name: "eng" } };
136
+ });
137
+ warmSlackChannelLabels([keyOf("clabel"), keyOf("dlabel"), keyOf("glabel")]);
138
+ warmSlackChannelLabels([keyOf("clabel"), "shared/slack/", "shared/slack/clabel/thread:1.2/"]);
139
+ await until(() => hasLabel("glabel", "Group DM"));
140
+ expect(await hasLabel("clabel", "#eng")).toBe(true);
141
+ expect(await hasLabel("dlabel", "DM · Lena")).toBe(true);
142
+ expect(call).toHaveBeenCalledTimes(4);
143
+ expect(call).toHaveBeenCalledWith("conversations.info", { channel: "CLABEL" });
144
+ expect(call).toHaveBeenCalledWith("users.info", { user: "ULENA" });
145
+ const row = await getDbClient().get<{ expires_at: number }>(
146
+ "SELECT expires_at FROM kv_entries WHERE namespace = 'session-labels' AND key = ?",
147
+ [keyOf("clabel")],
148
+ );
149
+ expect(row!.expires_at).toBeGreaterThanOrEqual(now + 86_400_000);
150
+ });
151
+
152
+ test("Slack uses the real name when the display name is empty", async () => {
153
+ fakeSlack(async (method) =>
154
+ method === "users.info"
155
+ ? { ok: true, user: { profile: { display_name: "" }, real_name: "Lena" } }
156
+ : { ok: true, channel: { is_im: true, user: "ULENA" } },
157
+ );
158
+ warmSlackChannelLabels([keyOf("dreal")]);
159
+ await until(() => hasLabel("dreal", "DM · Lena"));
160
+ });
161
+
162
+ test("Slack serializes work across calls and retries ratelimited errors", async () => {
163
+ let release!: () => void;
164
+ const gate = new Promise<void>((resolve) => {
165
+ release = resolve;
166
+ });
167
+ let attempts = 0;
168
+ const call = fakeSlack(async (_method, payload) => {
169
+ if (payload.channel === "CSERIAL") await gate;
170
+ else if (attempts++ === 0) throw { data: { error: "ratelimited", retry_after: 0 } };
171
+ return { ok: true, channel: { name: "eng" } };
172
+ });
173
+ try {
174
+ warmSlackChannelLabels([keyOf("cserial")]);
175
+ warmSlackChannelLabels([keyOf("cretry")]);
176
+ await until(() => call.mock.calls.length === 1);
177
+ expect(call.mock.calls[0]![1]).toEqual({ channel: "CSERIAL" });
178
+ } finally {
179
+ release();
180
+ }
181
+ await until(() => hasLabel("cretry", "#eng"));
182
+ expect(call).toHaveBeenCalledTimes(3);
183
+ });
184
+
185
+ test("Slack caches a failure in memory for ten minutes without writing KV or logging names", async () => {
186
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
187
+ const call = fakeSlack(async () => {
188
+ throw new Error("secret label text");
189
+ });
190
+ try {
191
+ warmSlackChannelLabels([keyOf("cfail")]);
192
+ await until(() => warn.mock.calls.length === 1);
193
+ warmSlackChannelLabels([keyOf("cfail")]);
194
+ expect(call).toHaveBeenCalledTimes(1);
195
+ expect(await getSessionLabels([keyOf("cfail")])).toEqual({});
196
+ expect(warn.mock.calls.flat().join(" ")).not.toContain("secret label text");
197
+ const now = Date.now();
198
+ const clock = spyOn(Date, "now").mockReturnValue(now + 10 * 60_000 + 1);
199
+ try {
200
+ call.mockImplementation(async () => ({ ok: true, channel: { name: "recovered" } }));
201
+ warmSlackChannelLabels([keyOf("cfail")]);
202
+ await until(() => hasLabel("cfail", "#recovered"));
203
+ expect(call).toHaveBeenCalledTimes(2);
204
+ } finally {
205
+ clock.mockRestore();
206
+ }
207
+ } finally {
208
+ warn.mockRestore();
209
+ }
210
+ });
211
+
212
+ test("a null Slack client performs no work and does not reserve the id", async () => {
213
+ warmSlackChannelLabels([keyOf("cnull")]);
214
+ expect(await getSessionLabels([keyOf("cnull")])).toEqual({});
215
+ const call = fakeSlack(async () => ({ ok: true, channel: { name: "eng" } }));
216
+ warmSlackChannelLabels([keyOf("cnull")]);
217
+ await until(() => hasLabel("cnull", "#eng"));
218
+ expect(call).toHaveBeenCalledTimes(1);
219
+ });
220
+
221
+ test("Slack caps pending and active lookups across calls and accepts skipped ids on a later poll", async () => {
222
+ let release!: () => void;
223
+ const gate = new Promise<void>((resolve) => {
224
+ release = resolve;
225
+ });
226
+ const call = fakeSlack(async () => {
227
+ await gate;
228
+ return { ok: true, channel: { name: "bounded" } };
229
+ });
230
+ const keys = Array.from({ length: 60 }, (_, i) => keyOf(`cqueue${i}`));
231
+ try {
232
+ warmSlackChannelLabels(keys.slice(0, 30));
233
+ await until(() => call.mock.calls.length === 1);
234
+ warmSlackChannelLabels(keys.slice(30));
235
+ expect(call).toHaveBeenCalledTimes(1);
236
+ } finally {
237
+ release();
238
+ }
239
+ await until(() => hasLabel("cqueue49", "#bounded"));
240
+ expect(call).toHaveBeenCalledTimes(50);
241
+ expect(await getSessionLabels(keys.slice(50))).toEqual({});
242
+ warmSlackChannelLabels(keys.slice(50));
243
+ await until(() => hasLabel("cqueue59", "#bounded"));
244
+ expect(call).toHaveBeenCalledTimes(60);
245
+ });
246
+
247
+ test("an explicit Slack key without matching Slack fields starts no lookup", async () => {
248
+ await createTaskExtended("explicit source key", { key: `${keyOf("cfake")}task:fake/` });
249
+ await createTaskExtended("mismatched source key", {
250
+ key: `${keyOf("cfake")}task:mismatch/`,
251
+ slackChannelId: "COTHER",
252
+ slackThreadTs: "123.456",
253
+ });
254
+ const call = fakeSlack(async () => ({ ok: true, channel: { name: "untrusted" } }));
255
+ for (let i = 0; i < 2; i++) {
256
+ const response = await fetch(`${baseUrl}/api/session-tree`);
257
+ expect(response.status).toBe(200);
258
+ const body = await response.json();
259
+ expect(body.groups.map((group: { key: string }) => group.key)).toEqual([keyOf("cfake")]);
260
+ expect(body.labels).toEqual({});
261
+ expect(body.slackChannels).toBeUndefined();
262
+ }
263
+ expect(call).not.toHaveBeenCalled();
264
+ });
265
+
266
+ test("tree returns cached source labels and starts one background lookup for a Slack miss", async () => {
267
+ for (const channel of ["CTREECACHED", "CTREEMISS"]) {
268
+ await createTaskExtended("label tree", {
269
+ source: "slack",
270
+ slackChannelId: channel,
271
+ slackThreadTs: "123.456",
272
+ });
273
+ }
274
+ await createTaskExtended("ordinary group", { key: "shared/research/" });
275
+ await rememberSessionLabel(keyOf("ctreecached"), "#cached");
276
+ await rememberSessionLabel("shared/research/", "omit ordinary labels");
277
+ let release!: () => void;
278
+ const gate = new Promise<void>((resolve) => {
279
+ release = resolve;
280
+ });
281
+ const call = fakeSlack(async () => {
282
+ await gate;
283
+ return { ok: true, channel: { name: "found" } };
284
+ });
285
+ try {
286
+ for (let i = 0; i < 2; i++) {
287
+ const response = await fetch(`${baseUrl}/api/session-tree`);
288
+ expect(response.status).toBe(200);
289
+ expect((await response.json()).labels).toEqual({ [keyOf("ctreecached")]: "#cached" });
290
+ }
291
+ expect(call).toHaveBeenCalledTimes(1);
292
+ } finally {
293
+ release();
294
+ }
295
+ await until(() => hasLabel("ctreemiss", "#found"));
296
+ expect((await (await fetch(`${baseUrl}/api/session-tree`)).json()).labels).toEqual({
297
+ [keyOf("ctreecached")]: "#cached",
298
+ [keyOf("ctreemiss")]: "#found",
299
+ });
300
+ });
301
+
302
+ test("tree returns labels for source groups the filter hides", async () => {
303
+ await createTaskExtended("hidden label tree", {
304
+ source: "slack",
305
+ slackChannelId: "CTREEHIDDEN",
306
+ slackThreadTs: "123.456",
307
+ });
308
+ await rememberSessionLabel(keyOf("ctreehidden"), "#hidden");
309
+ const body = await (await fetch(`${baseUrl}/api/session-tree?showSource=ui`)).json();
310
+ expect(body.groups.some((group: { key: string }) => group.key === keyOf("ctreehidden"))).toBe(
311
+ false,
312
+ );
313
+ expect(body.labels[keyOf("ctreehidden")]).toBe("#hidden");
314
+ expect(body.sourceGroups).toBeUndefined();
315
+ expect(body.slackChannels).toBeUndefined();
316
+ });
317
+
318
+ test("Telegram capture stores a group title and a DM sender under their source group keys without TTL", async () => {
319
+ const harness = await startTelegramHarness();
320
+ try {
321
+ await postTelegramUpdate(
322
+ telegramUpdate({ id: -9911, type: "group" }, "@swarm_test_bot first ask"),
323
+ );
324
+ await flushChatBuffers();
325
+ await postTelegramUpdate(telegramUpdate({ id: 9912, type: "private" }, "first ask"));
326
+ await flushChatBuffers();
327
+ expect(await getSessionLabels(["shared/telegram/-9911/", "shared/telegram/9912/"])).toEqual({
328
+ "shared/telegram/-9911/": "Swarm group",
329
+ "shared/telegram/9912/": "DM · Tester (@tester)",
330
+ });
331
+ const rows = await getDbClient().query<{ expires_at: number | null }>(
332
+ "SELECT expires_at FROM kv_entries WHERE namespace = 'session-labels'",
333
+ );
334
+ expect(rows.every((row) => row.expires_at === null)).toBe(true);
335
+ await postTelegramUpdate(
336
+ telegramUpdate({ id: -9911, type: "group" }, "@swarm_test_bot follow-up", {
337
+ chat: { id: -9911, type: "group", title: "Changed title" },
338
+ }),
339
+ );
340
+ await flushChatBuffers();
341
+ expect((await getSessionLabels(["shared/telegram/-9911/"]))["shared/telegram/-9911/"]).toBe(
342
+ "Changed title",
343
+ );
344
+ } finally {
345
+ await harness.stop();
346
+ }
347
+ });
348
+
349
+ function discordMessage(dm: boolean): ChatBufferedMessage {
350
+ return {
351
+ messageId: crypto.randomUUID(),
352
+ channelId: "discord:guild:channel",
353
+ text: "hello",
354
+ userId: "sender",
355
+ botMentioned: true,
356
+ isDirectMessage: dm,
357
+ addressed: dm ? "dm" : "mention",
358
+ facts: { chatType: dm ? "dm" : undefined, from: "Lena", replyToBot: false },
359
+ attachments: [],
360
+ };
361
+ }
362
+
363
+ test("Discord capture stores a DM label and leaves channel names absent", async () => {
364
+ await flushChatThread("discord:@me:dmchannel", [discordMessage(true)], true);
365
+ await flushChatThread("discord:guild:channel", [discordMessage(false)], true);
366
+ expect(await getSessionLabels(["shared/discord/dmchannel/", "shared/discord/channel/"])).toEqual({
367
+ "shared/discord/dmchannel/": "DM · Lena",
368
+ });
369
+ });
370
+
371
+ test("a label write failure does not fail chat task creation or log the label", async () => {
372
+ const save = spyOn(labelsDb, "rememberSessionLabel").mockRejectedValue(new Error("DM · private"));
373
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
374
+ try {
375
+ await flushChatThread("discord:@me:failedlabel", [discordMessage(true)], true);
376
+ const row = await getDbClient().get<{ id: string }>(
377
+ "SELECT id FROM agent_tasks WHERE contextKey = ?",
378
+ [chatSdkContextKey("discord:@me:failedlabel")],
379
+ );
380
+ expect(row?.id).toBeTruthy();
381
+ expect(warn.mock.calls.flat().join(" ")).not.toContain("DM · private");
382
+ expect(save).toHaveBeenCalledTimes(1);
383
+ } finally {
384
+ save.mockRestore();
385
+ warn.mockRestore();
386
+ }
387
+ });
388
+
389
+ test("a full Slack failure cache skips new ids without evicting live failures and reclaims expired entries", async () => {
390
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
391
+ const now = Date.now();
392
+ const clock = spyOn(Date, "now").mockReturnValue(now);
393
+ const call = fakeSlack(async () => {
394
+ throw new Error("lookup failed");
395
+ });
396
+ const keys = Array.from({ length: 500 }, (_, i) => keyOf(`cfull${i}`));
397
+ try {
398
+ for (let offset = 0; offset < keys.length; offset += 50) {
399
+ warmSlackChannelLabels(keys.slice(offset, offset + 50));
400
+ await until(() => warn.mock.calls.length === offset + 50);
401
+ }
402
+ expect(call).toHaveBeenCalledTimes(500);
403
+ warmSlackChannelLabels([keyOf("cfullnew")]);
404
+ await Bun.sleep(0);
405
+ warmSlackChannelLabels(keys);
406
+ await Bun.sleep(0);
407
+ expect(call).toHaveBeenCalledTimes(500);
408
+ expect(await getSessionLabels(keys)).toEqual({});
409
+
410
+ clock.mockReturnValue(now + 10 * 60_000 + 1);
411
+ call.mockImplementation(async () => ({ ok: true, channel: { name: "recovered" } }));
412
+ warmSlackChannelLabels([keyOf("cfullnew")]);
413
+ await until(() => hasLabel("cfullnew", "#recovered"));
414
+ expect(call).toHaveBeenCalledTimes(501);
415
+ } finally {
416
+ clock.mockRestore();
417
+ warn.mockRestore();
418
+ }
419
+ });
@@ -14,6 +14,7 @@ import {
14
14
  getTaskById,
15
15
  initDb,
16
16
  } from "../be/db";
17
+ import { getSessionGroupKey } from "../be/db/session-tree";
17
18
  import { getTaskSteeringFields, getTaskSteeringFieldsForTasks } from "../be/steering";
18
19
  import { getTaskCitations, getTaskCitationsForTasks } from "../be/task-citations";
19
20
  import { handleSessions } from "../http/sessions";
@@ -56,6 +57,7 @@ async function unbatchedSession(id: string) {
56
57
  // Session sharing: the bare operator key bypasses access checks, and the fixture root has no owner.
57
58
  access: "owner",
58
59
  owner: null,
60
+ groupKey: await getSessionGroupKey(root),
59
61
  };
60
62
  }
61
63
 
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
2
2
  import { createServer } from "node:http";
3
+ import { sessionSourceKey } from "../assets/source-key";
3
4
  import * as audit from "../be/asset-key-audit";
4
5
  import {
5
6
  closeDb,
@@ -21,6 +22,7 @@ import { handleAssets } from "../http/assets";
21
22
  import { SESSION_READ_DENIED } from "../http/session-access";
22
23
  import { handleSessions } from "../http/sessions";
23
24
  import { getPathSegments, parseQueryParams } from "../http/utils";
25
+ import { chatSdkContextKey } from "../tasks/context-key";
24
26
  import { setRequestAuth } from "../utils/request-auth-context";
25
27
  import { listenOnFreePort } from "./test-net";
26
28
 
@@ -100,6 +102,135 @@ async function share(root: { id: string; requestedByUserId?: string }, access: "
100
102
  );
101
103
  }
102
104
 
105
+ test("bulk source moves allow exits and exact Undo keys, but refuse source groups", async () => {
106
+ for (const origin of [
107
+ { slackChannelId: "C1", slackThreadTs: "1.2" },
108
+ { contextKey: chatSdkContextKey("slack:D1:") },
109
+ { contextKey: chatSdkContextKey("telegram:-100") },
110
+ { contextKey: chatSdkContextKey("discord:@me:c1") },
111
+ ]) {
112
+ const root = await createTaskExtended("source root", {
113
+ agentId,
114
+ requestedByUserId: owner,
115
+ ...origin,
116
+ });
117
+ const child = await createTaskExtended("child", { parentTaskId: root.id, agentId });
118
+ const ownKey = sessionSourceKey(root)!;
119
+ for (const groupKey of [
120
+ "shared/slack/c1/",
121
+ "shared/telegram/",
122
+ "shared/discord/c2/task:other/",
123
+ ]) {
124
+ expect(await call("session-moves", { rootTaskIds: [root.id], groupKey })).toEqual({
125
+ status: 200,
126
+ body: {
127
+ moved: [],
128
+ skipped: [{ rootTaskId: root.id, reason: "Source groups are read-only." }],
129
+ },
130
+ });
131
+ expect((await getTaskById(root.id))?.key).toBe(ownKey);
132
+ }
133
+ for (const groupKey of ["shared/research/", ownKey, null, ownKey]) {
134
+ expect(await call("session-moves", { rootTaskIds: [root.id], groupKey })).toEqual({
135
+ status: 200,
136
+ body: { moved: [root.id], skipped: [] },
137
+ });
138
+ for (const id of [root.id, child.id]) {
139
+ expect((await getTaskById(id))?.key).toBe(groupKey ?? `shared/task:${root.id}/`);
140
+ }
141
+ }
142
+ }
143
+ const { root } = await session();
144
+ const result = await call("session-moves", {
145
+ rootTaskIds: [root.id],
146
+ groupKey: "SHARED/SLACK/C1",
147
+ });
148
+ expect(result.body.skipped).toEqual([
149
+ { rootTaskId: root.id, reason: "Source groups are read-only." },
150
+ ]);
151
+ });
152
+
153
+ test("prefix refuses source prefixes before audit and root scans, including bypass requests", async () => {
154
+ const auditSpy = spyOn(audit, "auditAssetKeys");
155
+ const prefixScan = spyOn(sessionMoves, "listPrefixRoots");
156
+ const sharedScan = spyOn(sessionMoves, "listSharedPrefixRoots");
157
+ try {
158
+ for (const userId of [owner, null]) {
159
+ for (const body of [
160
+ { from: "shared/slack/c1/", to: "shared/research/" },
161
+ { from: "shared/slack/c1/", to: null },
162
+ { from: "shared/research/", to: "SHARED/SLACK/C1" },
163
+ { from: "SHARED/TELEGRAM", to: "shared/research/" },
164
+ { from: "shared/research/", to: "shared/discord/c1/task:root/" },
165
+ ]) {
166
+ expect(await call("session-moves/prefix", body, userId)).toEqual({
167
+ status: 400,
168
+ body: { error: "Source groups are read-only." },
169
+ });
170
+ }
171
+ }
172
+ expect(auditSpy).not.toHaveBeenCalled();
173
+ expect(prefixScan).not.toHaveBeenCalled();
174
+ expect(sharedScan).not.toHaveBeenCalled();
175
+ } finally {
176
+ auditSpy.mockRestore();
177
+ prefixScan.mockRestore();
178
+ sharedScan.mockRestore();
179
+ }
180
+ });
181
+
182
+ test("PATCH checks the actual root source for root and child moves", async () => {
183
+ for (const origin of [
184
+ { slackChannelId: "C1", slackThreadTs: "1.2" },
185
+ { contextKey: chatSdkContextKey("telegram:-100") },
186
+ { contextKey: chatSdkContextKey("discord:@me:c1") },
187
+ ]) {
188
+ const root = await createTaskExtended("source root", {
189
+ agentId,
190
+ requestedByUserId: owner,
191
+ ...origin,
192
+ });
193
+ // Different fields on the child must not determine its allowed Undo key.
194
+ const child = await createTaskExtended("child", {
195
+ parentTaskId: root.id,
196
+ agentId,
197
+ slackChannelId: "C_OTHER",
198
+ slackThreadTs: "9.9",
199
+ });
200
+ for (const id of [root.id, child.id]) {
201
+ for (const key of [
202
+ "SHARED/SLACK/C1",
203
+ "shared/slack/c_other/thread:9.9/",
204
+ "shared/telegram/-100/task:other/",
205
+ ]) {
206
+ expect(await call(`assets/task/${id}/key`, { key, cascade: true })).toEqual({
207
+ status: 400,
208
+ body: { error: "Source groups are read-only." },
209
+ });
210
+ }
211
+ expect((await getTaskById(id))?.key).toBe(root.key);
212
+ expect((await call(`assets/task/${id}/key`, { key: "shared/research/" })).status).toBe(200);
213
+ expect((await call(`assets/task/${id}/key`, { key: root.key.toUpperCase() })).status).toBe(
214
+ 200,
215
+ );
216
+ expect((await getTaskById(id))?.key).toBe(root.key);
217
+ }
218
+ }
219
+ });
220
+
221
+ test("prefix refuses a source destination produced by suffix replacement", async () => {
222
+ const { root } = await session("shared/indirect/slack/c1/");
223
+ expect(await call("session-moves/prefix", { from: "shared/indirect/", to: "shared/" })).toEqual({
224
+ status: 200,
225
+ body: {
226
+ moved: [],
227
+ remaining: 0,
228
+ skipped: [{ rootTaskId: root.id, reason: "Source groups are read-only." }],
229
+ },
230
+ });
231
+ expect((await getTaskById(root.id))?.key).toBe(root.key);
232
+ });
233
+
103
234
  test("bulk moves every chain task and attachment mapping with history and preserves read state", async () => {
104
235
  const { root, child, grandchild } = await session();
105
236
  const attachment = await insertTaskAttachment({