@desplega.ai/agent-swarm 2.0.0 → 2.1.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 (238) hide show
  1. package/dist/{acp-adapter-sj2g0m84.js → acp-adapter-kr4abrsk.js} +10 -10
  2. package/dist/{actions-fegk44gr.js → actions-eh4g6w2r.js} +27 -27
  3. package/dist/{amp-adapter-ad8ft89x.js → amp-adapter-7h7wdt8q.js} +5 -5
  4. package/dist/{app-kfvgbvzy.js → app-7k8s9w5p.js} +9 -9
  5. package/dist/{assistant-z7zdy9ja.js → assistant-wswt2y7p.js} +28 -28
  6. package/dist/{boot-reembed-j8xa181e.js → boot-reembed-9apm9qw6.js} +7 -7
  7. package/dist/{boot-reembed-5c2z0mbe.js → boot-reembed-whzbdthj.js} +7 -7
  8. package/dist/{boot-scrub-sweep-hnkn3rgt.js → boot-scrub-sweep-ndzmvmgt.js} +8 -8
  9. package/dist/{boot-seeds-kwjq4d2z.js → boot-seeds-e9sta0yt.js} +13 -11
  10. package/dist/{claude-adapter-9vmvpvgd.js → claude-adapter-4mqd3r6r.js} +11 -11
  11. package/dist/{claude-managed-adapter-k9qae05j.js → claude-managed-adapter-9wswb52m.js} +3 -3
  12. package/dist/{claude-managed-setup-q54zx5q2.js → claude-managed-setup-tpe1effx.js} +4 -4
  13. package/dist/{claude-sdk-session-9jye6cxr.js → claude-sdk-session-q49ghmh9.js} +10340 -10197
  14. package/dist/{cli-ke896fcf.js → cli-01xvj1rq.js} +10 -10
  15. package/dist/{cli-tr4sxp68.js → cli-035vekcw.js} +2 -2
  16. package/dist/{cli-z5jjpaxw.js → cli-0pd8hzhc.js} +3 -3
  17. package/dist/{cli-n5vwg842.js → cli-0veb92a3.js} +6 -1
  18. package/dist/{cli-wayw93nt.js → cli-10jesr25.js} +3 -3
  19. package/dist/{cli-se8w6wny.js → cli-1y7yywmj.js} +1 -1
  20. package/dist/{cli-v1yb0rqb.js → cli-20krdhz5.js} +47 -19
  21. package/dist/{cli-d5f9ed5c.js → cli-29rja109.js} +39 -20
  22. package/dist/{cli-apx87jne.js → cli-2pwa0q4d.js} +15 -4
  23. package/dist/{cli-ee9gtz70.js → cli-4mengw46.js} +348 -25
  24. package/dist/{cli-03gedhng.js → cli-5twdke5x.js} +6 -6
  25. package/dist/{cli-6razj6t4.js → cli-711vwbxn.js} +2 -2
  26. package/dist/{cli-s2sp957d.js → cli-78vv7446.js} +1 -1
  27. package/dist/{cli-3fbmmawm.js → cli-7n6s5man.js} +4 -4
  28. package/dist/{cli-hnhtk6cj.js → cli-8wwxw8a3.js} +1 -1
  29. package/dist/{cli-hnmhtzvh.js → cli-9jzdj90z.js} +2 -0
  30. package/dist/{cli-skxvvpap.js → cli-cvv9k0hh.js} +28 -25
  31. package/dist/{cli-74ahz61x.js → cli-d9286m9h.js} +2 -2
  32. package/dist/{cli-4jxm2qtz.js → cli-gxzkg1de.js} +28 -16
  33. package/dist/{cli-zkpks3r3.js → cli-h1arqn31.js} +1 -1
  34. package/dist/{cli-r62a35ea.js → cli-hqr88ew6.js} +1 -1
  35. package/dist/{cli-ra0xzggj.js → cli-j67mh2vg.js} +2 -2
  36. package/dist/{cli-29jc5q7w.js → cli-ksphgh48.js} +2 -2
  37. package/dist/{cli-eejsgvm4.js → cli-mgz4nbgg.js} +4 -4
  38. package/dist/{cli-vatyhshy.js → cli-mz5as5ha.js} +4 -4
  39. package/dist/{cli-f7kr7svz.js → cli-n8ndw8j7.js} +2 -2
  40. package/dist/{cli-1e4b6bzh.js → cli-naqzr17j.js} +7 -7
  41. package/dist/{cli-q0ke1yww.js → cli-nm1p8jfr.js} +6 -6
  42. package/dist/{cli-m50t4309.js → cli-p3zbc1rw.js} +5 -5
  43. package/dist/{cli-cgtksawx.js → cli-pe05f8yk.js} +23 -3
  44. package/dist/{cli-vdach5a9.js → cli-qmzsbkvy.js} +4 -4
  45. package/dist/{cli-n3hzg89b.js → cli-qnm4e9e4.js} +57 -579
  46. package/dist/{cli-s4setq91.js → cli-qqy4q48b.js} +3 -3
  47. package/dist/{cli-k382tshb.js → cli-r35rw5de.js} +8 -7
  48. package/dist/{cli-k3dp5tpf.js → cli-r610p9bs.js} +2 -2
  49. package/dist/{cli-e6mpjhed.js → cli-rc6pf5kv.js} +1 -1
  50. package/dist/{cli-3dj8k4vy.js → cli-rdxm77k3.js} +1 -1
  51. package/dist/{cli-tqaazemc.js → cli-s69nfyeh.js} +1 -1
  52. package/dist/{cli-6jyj59nb.js → cli-sawv63e8.js} +2 -2
  53. package/dist/{cli-q50942rs.js → cli-sg19gbf0.js} +1 -1
  54. package/dist/{cli-p1x85d2t.js → cli-shk7r5cs.js} +12 -9
  55. package/dist/{cli-przpmdgr.js → cli-sksetb2q.js} +11 -1
  56. package/dist/{cli-d2qr7aj4.js → cli-t02z59f2.js} +1 -1
  57. package/dist/{cli-j0pfptdp.js → cli-v7g551te.js} +39 -43
  58. package/dist/{cli-bzszjf2p.js → cli-vgqzp4em.js} +519 -126
  59. package/dist/cli-wfnemr1t.js +86 -0
  60. package/dist/{cli-ne236afy.js → cli-wms6x9db.js} +8 -8
  61. package/dist/{cli-5hkm7whv.js → cli-x1r545x8.js} +1 -1
  62. package/dist/{cli-kt6zqg0e.js → cli-xczsgshn.js} +2 -2
  63. package/dist/{cli-ef1hg1wv.js → cli-y3167wt1.js} +430 -238
  64. package/dist/{cli-tsde1hdr.js → cli-yjmqpxt3.js} +12 -11
  65. package/dist/{cli-9nfx44eh.js → cli-z780ec7c.js} +1 -1
  66. package/dist/{cli-r2pnvj8g.js → cli-zm9t2vde.js} +2 -2
  67. package/dist/cli.js +24 -23
  68. package/dist/{codex-adapter-y81sssk0.js → codex-adapter-4z16v9n0.js} +8 -8
  69. package/dist/{codex-login-f2rcfpp9.js → codex-login-pkepy2j1.js} +2 -2
  70. package/dist/{codex-session-runner-8vx4j6a9.js → codex-session-runner-rvms9y2x.js} +8 -8
  71. package/dist/{commands-kcpvpcrr.js → commands-2a9rtyat.js} +8 -8
  72. package/dist/{controller-ywrmt6yd.js → controller-3k3madbn.js} +4 -4
  73. package/dist/{controllers-connect-kwpvj3rf.js → controllers-connect-v9yn8zb9.js} +30 -30
  74. package/dist/{cursor-adapter-cehv1qns.js → cursor-adapter-98gbhyzn.js} +4 -4
  75. package/dist/{db-ww5d7jq3.js → db-eywwsnjw.js} +9 -7
  76. package/dist/{db-k8ankj20.js → db-ncypk9np.js} +9 -9
  77. package/dist/{devin-adapter-x1022qb1.js → devin-adapter-zeq8b9gz.js} +2 -2
  78. package/dist/{dispatcher-e8gq5m68.js → dispatcher-w94kc575.js} +26 -26
  79. package/dist/{docker-ff6y1190.js → docker-b1e4cqab.js} +2 -2
  80. package/dist/{dsh-adapter-3epjm3af.js → dsh-adapter-47ztej36.js} +4 -4
  81. package/dist/{e2b-8ygwhg0f.js → e2b-bbt8ey45.js} +3 -3
  82. package/dist/{e2b-swhnpw3n.js → e2b-dp804sen.js} +3 -3
  83. package/dist/{event-bus-6axnngdj.js → event-bus-j2jdg3qb.js} +2 -2
  84. package/dist/{external-channel-h3359cwn.js → external-channel-zrkg8ar9.js} +8 -8
  85. package/dist/{grok-adapter-0d4x7gkx.js → grok-adapter-s9ncp9vy.js} +11 -11
  86. package/dist/{handlers-an1gzv21.js → handlers-kqftvjgw.js} +29 -29
  87. package/dist/{heartbeat-6dakeb61.js → heartbeat-9q96p8v9.js} +31 -31
  88. package/dist/{hook-6v6cv6d5.js → hook-56qe10dp.js} +7 -7
  89. package/dist/{hook-sa5xye4y.js → hook-yy6rw42k.js} +8 -8
  90. package/dist/{http-r7f3eky0.js → http-r2dnb8f8.js} +963 -75
  91. package/dist/{index-7ep4gzdk.js → index-3gtmkhc6.js} +26 -26
  92. package/dist/{index-v1v6n7hg.js → index-3k5hkz7r.js} +31 -31
  93. package/dist/{index-rmpw9a8q.js → index-5e101240.js} +26 -26
  94. package/dist/{index-s2wxgzr0.js → index-5wyfjc2c.js} +392 -15
  95. package/dist/{index-zk96aq1k.js → index-vxvhxxbn.js} +7 -7
  96. package/dist/{keepalive-be9vwy5h.js → keepalive-psj6j54c.js} +9 -9
  97. package/dist/{lead-0861cwrf.js → lead-hajz4688.js} +43 -43
  98. package/dist/{maintenance-mzzgc8bn.js → maintenance-ps02sb5m.js} +7 -7
  99. package/dist/{node-mn1zs7an.js → node-mwb5r697.js} +1 -1
  100. package/dist/{oauth-refresh-sweep-cpdnfwdj.js → oauth-refresh-sweep-0284h93b.js} +8 -8
  101. package/dist/{onboard-fh8qs4r3.js → onboard-rbf6bg01.js} +2 -2
  102. package/dist/{opencode-adapter-nm42wwj8.js → opencode-adapter-13dcatgq.js} +8 -8
  103. package/dist/{otel-impl-x6c1c9g4.js → otel-impl-zvqcbhp7.js} +2 -2
  104. package/dist/{pi-mono-adapter-9n2ya240.js → pi-mono-adapter-xxy47q4v.js} +7 -7
  105. package/dist/{pricing-refresh-qb0rv0ap.js → pricing-refresh-kefx9v3t.js} +9 -9
  106. package/dist/{rbac-roles-1gzdwbh1.js → rbac-roles-3m36rxm2.js} +8 -8
  107. package/dist/{rbac-roles-3mraey52.js → rbac-roles-z76xv0hq.js} +9 -9
  108. package/dist/{render-v2-agpa45zz.js → render-v2-wz73bs25.js} +9 -9
  109. package/dist/{resume-x0ydy9s9.js → resume-k4wzge8w.js} +14 -14
  110. package/dist/{setup-as9ga0d6.js → setup-2fp2pypp.js} +2 -2
  111. package/dist/{work-objects-y5g1y7ev.js → work-objects-6k0ywz3m.js} +29 -29
  112. package/dist/{worker-860c3e38.js → worker-5v3rxsdh.js} +43 -43
  113. package/dist/{x-m477fr0h.js → x-4mtdeym1.js} +2 -2
  114. package/openapi.json +777 -6
  115. package/package.json +8 -7
  116. package/src/agentmail/handlers.ts +21 -11
  117. package/src/apps/definition.ts +16 -0
  118. package/src/azure-devops/api.ts +52 -2
  119. package/src/azure-devops/boards-sync.ts +50 -20
  120. package/src/azure-devops/index.ts +1 -0
  121. package/src/azure-devops/outbound.ts +95 -0
  122. package/src/be/boot-seeds.ts +11 -0
  123. package/src/be/db/model-profiles.ts +315 -0
  124. package/src/be/db/tasks/read.ts +7 -0
  125. package/src/be/db.ts +53 -47
  126. package/src/be/migrations/219_relabel_pi_context_compactions.sql +22 -0
  127. package/src/be/migrations/220_model_profiles.sql +137 -0
  128. package/src/be/model-tier-resolution.ts +145 -11
  129. package/src/be/model-validation.ts +8 -1
  130. package/src/be/scripts/typecheck.ts +12 -0
  131. package/src/be/secret-registry.ts +6 -2
  132. package/src/be/seed-model-profiles.ts +41 -0
  133. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +368 -0
  134. package/src/be/seed-scripts/index.ts +9 -0
  135. package/src/be/swarm-config-guard.ts +1 -0
  136. package/src/commands/runner.ts +3 -17
  137. package/src/extensions/catalog.generated.json +17 -0
  138. package/src/extensions/contract-types.generated.ts +1 -1
  139. package/src/extensions/loader.ts +10 -5
  140. package/src/heartbeat/heartbeat.ts +2 -1
  141. package/src/http/agents.ts +49 -2
  142. package/src/http/all-routes.ts +1 -0
  143. package/src/http/apps.ts +30 -3
  144. package/src/http/config.ts +26 -2
  145. package/src/http/core.ts +4 -0
  146. package/src/http/index.ts +11 -1
  147. package/src/http/model-profiles.ts +303 -0
  148. package/src/http/pages-public.ts +11 -5
  149. package/src/http/poll.ts +16 -5
  150. package/src/http/script-runs.ts +1 -1
  151. package/src/http/scripts.ts +25 -2
  152. package/src/http/tasks.ts +18 -0
  153. package/src/http/webhooks.ts +1 -2
  154. package/src/pages/feedback-overlay.ts +65 -6
  155. package/src/providers/acp-targets.ts +1 -1
  156. package/src/providers/amp-adapter.ts +1 -1
  157. package/src/providers/dsh-adapter.ts +1 -1
  158. package/src/providers/pi-mono-extension.ts +17 -3
  159. package/src/providers/reasoning-effort.ts +10 -0
  160. package/src/rbac/legacy-policy.ts +8 -0
  161. package/src/rbac/permissions.ts +5 -0
  162. package/src/scheduler/deferred-task-waits.ts +18 -5
  163. package/src/scheduler/scheduler.ts +7 -1
  164. package/src/scripts-runtime/executors/quickjs-prelude.ts +75 -6
  165. package/src/scripts-runtime/executors/quickjs-runner.ts +85 -8
  166. package/src/scripts-runtime/executors/quickjs-web-shims.ts +1041 -0
  167. package/src/scripts-runtime/extract-args-schema.ts +9 -8
  168. package/src/scripts-runtime/quickjs-compat.ts +464 -0
  169. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  170. package/src/scripts-runtime/types/stdlib.d.ts +12 -0
  171. package/src/scripts-runtime/types/swarm-extension.d.ts +3 -0
  172. package/src/scripts-runtime/types/swarm-sdk.d.ts +12 -0
  173. package/src/server.ts +10 -1
  174. package/src/slack/blocks.ts +4 -2
  175. package/src/slack/reaction-shortcode.ts +14 -0
  176. package/src/slack/render-v2.ts +12 -7
  177. package/src/slack/responses.ts +5 -4
  178. package/src/slack/thread-buffer.ts +41 -22
  179. package/src/tasks/worker-follow-up.ts +11 -2
  180. package/src/tests/agentmail-filters.test.ts +47 -0
  181. package/src/tests/apps-spike2.test.ts +35 -0
  182. package/src/tests/azure-devops-outbound.test.ts +420 -0
  183. package/src/tests/db-task-read-contract.test.ts +2 -0
  184. package/src/tests/db-task-write-contract.test.ts +1 -0
  185. package/src/tests/deferred-task-wake.test.ts +78 -1
  186. package/src/tests/docs-index-footprint.test.js +2 -2
  187. package/src/tests/dsh-adapter.test.ts +1 -1
  188. package/src/tests/env-flag.test.ts +1 -0
  189. package/src/tests/extensions-catalog.test.ts +1 -0
  190. package/src/tests/extensions-example-comms-adherence-gate.test.ts +413 -0
  191. package/src/tests/extensions-loader.test.ts +15 -0
  192. package/src/tests/heartbeat-jev-gate-credentials.test.ts +168 -0
  193. package/src/tests/heartbeat-jev-gate.test.ts +385 -0
  194. package/src/tests/model-profile-backcompat.test.ts +327 -0
  195. package/src/tests/model-profile-providers.test.ts +302 -0
  196. package/src/tests/model-profile-runtime-secrets.test.ts +223 -0
  197. package/src/tests/model-profiles-api.test.ts +614 -0
  198. package/src/tests/pages-feedback-keys.test.ts +121 -0
  199. package/src/tests/pages-feedback.test.ts +3 -0
  200. package/src/tests/pi-context-compaction.test.ts +193 -0
  201. package/src/tests/profile-claim.test.ts +269 -0
  202. package/src/tests/quickjs-compat.test.ts +213 -0
  203. package/src/tests/rbac-engine.test.ts +21 -0
  204. package/src/tests/script-connections.test.ts +4 -0
  205. package/src/tests/script-executor-conformance.test.ts +252 -1
  206. package/src/tests/script-executor-quickjs.test.ts +96 -0
  207. package/src/tests/script-runs-http.test.ts +89 -0
  208. package/src/tests/scripts-http.test.ts +16 -0
  209. package/src/tests/secret-scrubber.test.ts +10 -0
  210. package/src/tests/seed-model-profiles.test.ts +174 -0
  211. package/src/tests/seed-scripts.test.ts +2 -2
  212. package/src/tests/skill-invocations.test.ts +4 -2
  213. package/src/tests/slack-blocks.test.ts +36 -0
  214. package/src/tests/slack-inline-output.test.ts +14 -0
  215. package/src/tests/slack-reaction-config.test.ts +20 -0
  216. package/src/tests/slack-render-v2.test.ts +37 -0
  217. package/src/tests/slack-thread-buffer.test.ts +98 -1
  218. package/src/tests/workflow-llm.test.ts +61 -0
  219. package/src/tools/model-profiles/list-model-profiles.ts +54 -0
  220. package/src/tools/request-human-input.ts +11 -3
  221. package/src/tools/script-apis.ts +5 -1
  222. package/src/tools/script-upsert.ts +4 -1
  223. package/src/tools/send-task.ts +24 -1
  224. package/src/tools/system-one-decide.ts +104 -0
  225. package/src/tools/task-action.ts +22 -1
  226. package/src/tools/tool-config.ts +6 -0
  227. package/src/types.ts +36 -1
  228. package/src/utils/secret-scrubber.ts +19 -12
  229. package/src/utils/vcs-repo-match.ts +29 -0
  230. package/src/workflows/executors/raw-llm.ts +8 -3
  231. package/src/workflows/executors/system-one-decision.ts +21 -11
  232. package/src/workflows/executors/workflow-llm.ts +22 -0
  233. package/src/workflows/index.ts +21 -12
  234. package/templates/extensions/README.md +1 -0
  235. package/templates/extensions/comms-adherence-gate/README.md +88 -0
  236. package/templates/extensions/comms-adherence-gate/hooks.ts +489 -0
  237. package/templates/extensions/comms-adherence-gate/manifest.json +8 -0
  238. package/dist/cli-5t4f29ez.js +0 -33
@@ -24,6 +24,7 @@ import {
24
24
  clearVolatileSecretsForTesting,
25
25
  refreshSecretScrubberCache,
26
26
  } from "../utils/secret-scrubber";
27
+ import { executeRawLlm } from "../workflows/executors/raw-llm";
27
28
  import { randomToken } from "./synthetic-secret-helpers";
28
29
 
29
30
  const TEST_DB_PATH = "./test-script-runs-http.sqlite";
@@ -661,3 +662,91 @@ describe("durable launch source with an embedded secret", () => {
661
662
  }
662
663
  });
663
664
  });
665
+
666
+ describe("POST /api/internal/raw-llm failure", () => {
667
+ const KEY_VARS = ["NAN_API_KEY", "HELMCODE_API_NAN"] as const;
668
+
669
+ // The provider echoes the key it received; `selectCredential` sends it
670
+ // trimmed, so a padded config value must still be redacted.
671
+ async function withNanKey(
672
+ keyVar: (typeof KEY_VARS)[number],
673
+ pad: string,
674
+ fn: (dummy: string, calls: () => number) => Promise<void>,
675
+ dummy = `nan-${randomToken(32)}`,
676
+ ) {
677
+ const previous = Object.fromEntries(KEY_VARS.map((name) => [name, process.env[name]]));
678
+ const previousFetch = globalThis.fetch;
679
+ for (const name of KEY_VARS) delete process.env[name];
680
+ process.env[keyVar] = `${pad}${dummy}${pad}`;
681
+ refreshSecretScrubberCache();
682
+ let calls = 0;
683
+ globalThis.fetch = (async () => {
684
+ calls++;
685
+ return new Response(
686
+ JSON.stringify({ error: { message: `Invalid API key: ${dummy}`, type: "auth" } }),
687
+ { status: 401, headers: { "Content-Type": "application/json" } },
688
+ );
689
+ }) as unknown as typeof fetch;
690
+ try {
691
+ await fn(dummy, () => calls);
692
+ } finally {
693
+ globalThis.fetch = previousFetch;
694
+ for (const name of KEY_VARS) {
695
+ if (previous[name] === undefined) delete process.env[name];
696
+ else process.env[name] = previous[name];
697
+ }
698
+ refreshSecretScrubberCache();
699
+ }
700
+ }
701
+
702
+ for (const keyVar of KEY_VARS) {
703
+ for (const pad of ["", " "]) {
704
+ const label = pad ? "padded" : "unpadded";
705
+ test(`does not echo a provider error that carries the ${label} ${keyVar} key`, async () => {
706
+ await withNanKey(keyVar, pad, async (dummy, calls) => {
707
+ const response = await dispatch("/api/internal/raw-llm", {
708
+ method: "POST",
709
+ agentId,
710
+ body: JSON.stringify({ prompt: "hi", model: "nan/qwen3.8-flash" }),
711
+ });
712
+ expect(calls()).toBe(1);
713
+ expect(response.status).toBe(500);
714
+ expect(response.text).not.toContain(dummy);
715
+ const body = (await response.json()) as { error: string };
716
+ expect(body.error).toContain("LLM call failed");
717
+ expect(body.error).toContain(`[REDACTED:${keyVar}]`);
718
+ });
719
+ });
720
+ }
721
+
722
+ // A random key can contain "529"; that must not read as an overload.
723
+ test(`a ${keyVar} key containing 529 is not mistaken for a rate limit`, async () => {
724
+ await withNanKey(
725
+ keyVar,
726
+ "",
727
+ async (dummy, calls) => {
728
+ const result = await executeRawLlm({ prompt: "hi", model: "nan/qwen3.8-flash" });
729
+ expect(calls()).toBe(1);
730
+ expect(result.status).toBe("failed");
731
+ if (result.status === "failed") expect(result.error).not.toContain(dummy);
732
+ },
733
+ `nan-5L76Mxo2D529A9S0GrqOZWIpYdfO1aIM`,
734
+ );
735
+ });
736
+
737
+ test(`scrubs the fallback-port error for a padded ${keyVar} key`, async () => {
738
+ await withNanKey(keyVar, " ", async (dummy, calls) => {
739
+ const result = await executeRawLlm({
740
+ prompt: "hi",
741
+ model: "nan/qwen3.8-flash",
742
+ fallbackPort: "fallback",
743
+ });
744
+ expect(calls()).toBe(1);
745
+ expect(result.status).toBe("success");
746
+ expect(result.error).toContain("using fallback port");
747
+ expect(result.error).not.toContain(dummy);
748
+ expect(result.error).toContain(`[REDACTED:${keyVar}]`);
749
+ });
750
+ });
751
+ }
752
+ });
@@ -242,6 +242,22 @@ describe("/api/scripts HTTP", () => {
242
242
  expect(await second.json()).toEqual({ name: "double", version: 2, contentDeduped: false });
243
243
  });
244
244
 
245
+ test("upsert saves a quickjs-incompatible script and returns advisory warnings", async () => {
246
+ const res = await upsert({
247
+ name: "uses-process",
248
+ // The script typings omit process, but a cast still typechecks.
249
+ source:
250
+ "export default async (): Promise<string> => (globalThis as any).process.env.HOME ?? '';",
251
+ });
252
+ expect(res.status).toBe(200);
253
+ expect(await res.json()).toEqual({
254
+ name: "uses-process",
255
+ version: 1,
256
+ contentDeduped: false,
257
+ warnings: ["quickjs compat (process, line 1): process is not available under quickjs"],
258
+ });
259
+ });
260
+
245
261
  test("upsert typecheck failures return diagnostics and do not write rows", async () => {
246
262
  const res = await upsert({
247
263
  name: "bad-types",
@@ -438,6 +438,16 @@ describe("registerVolatileSecret", () => {
438
438
  expect(scrubSecrets("http://127.0.0.1:9/v1")).toBe("http://127.0.0.1:9/v1");
439
439
  });
440
440
 
441
+ test("padded singleton values redact their trimmed form", () => {
442
+ const fromEnv = `opaque${randomToken(20)}`;
443
+ const fromMap = `opaque${randomToken(20)}`;
444
+ process.env.PADDED_API_KEY = ` ${fromEnv}\n`;
445
+ refreshSecretScrubberCache();
446
+ registerEnvSecrets({ HELMCODE_API_NAN: ` ${fromMap} ` });
447
+ expect(scrubSecrets(`key: ${fromEnv}`)).toBe("key: [REDACTED:PADDED_API_KEY]");
448
+ expect(scrubSecrets(`key: ${fromMap}`)).toBe("key: [REDACTED:HELMCODE_API_NAN]");
449
+ });
450
+
441
451
  test("ignores values shorter than the minimum length", () => {
442
452
  registerVolatileSecret("short", "TOO_SHORT");
443
453
  const out = scrubSecrets("contains short somewhere");
@@ -0,0 +1,174 @@
1
+ // The shipped `<harness>-<tier>` seeds, and migration 220's profiles made from
2
+ // agents' MODEL_OVERRIDE + REASONING_EFFORT_OVERRIDE.
3
+
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { readFileSync } from "node:fs";
6
+ import { unlink } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import {
9
+ closeDb,
10
+ createAgent,
11
+ getDb,
12
+ getDbClient,
13
+ getSwarmConfigs,
14
+ initDb,
15
+ upsertSwarmConfig,
16
+ } from "../be/db";
17
+ import { getModelProfileByName, listModelProfiles } from "../be/db/model-profiles";
18
+ import { recordClaimModelResolution } from "../be/model-tier-resolution";
19
+ import { seedModelProfiles } from "../be/seed-model-profiles";
20
+ import { DEFAULT_MODEL_TIER_MAP, type MODEL_TIERS, type ProviderName } from "../types";
21
+
22
+ const TEST_DB_PATH = "./test-seed-model-profiles.sqlite";
23
+
24
+ async function removeDbFiles(path: string): Promise<void> {
25
+ for (const suffix of ["", "-wal", "-shm"]) {
26
+ try {
27
+ await unlink(path + suffix);
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
30
+ }
31
+ }
32
+ }
33
+
34
+ /** The part of migration 220 that turns agent overrides into profiles. */
35
+ function agentOverrideMigrationSql(): string {
36
+ const sql = readFileSync(
37
+ join(import.meta.dir, "../be/migrations/220_model_profiles.sql"),
38
+ "utf8",
39
+ );
40
+ return sql.slice(sql.indexOf("CREATE TEMP TABLE _model_profile_overrides"));
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ await removeDbFiles(TEST_DB_PATH);
45
+ initDb(TEST_DB_PATH);
46
+ });
47
+
48
+ afterAll(async () => {
49
+ closeDb();
50
+ await removeDbFiles(TEST_DB_PATH);
51
+ });
52
+
53
+ beforeEach(async () => {
54
+ const client = getDbClient();
55
+ // References first: migration 220's triggers refuse deleting a named profile.
56
+ await client.run("DELETE FROM agent_tasks");
57
+ await client.run("DELETE FROM swarm_config");
58
+ await client.run("DELETE FROM swarm_repos");
59
+ await client.run("DELETE FROM agents");
60
+ await client.run("DELETE FROM model_profiles");
61
+ });
62
+
63
+ describe("tier seeds", () => {
64
+ test("one profile per harness and tier, copied from the tier map; none for acp", async () => {
65
+ expect(await seedModelProfiles()).toBe(40);
66
+ const profiles = await listModelProfiles();
67
+ expect(profiles).toHaveLength(40);
68
+ expect(profiles.some((p) => p.harness === "acp")).toBe(false);
69
+ for (const profile of profiles) {
70
+ const tier = profile.seededFrom?.replace("tier:", "") as (typeof MODEL_TIERS)[number];
71
+ expect(profile.name).toBe(`${profile.harness}-${tier}`);
72
+ expect(profile.model).toBe(DEFAULT_MODEL_TIER_MAP[profile.harness as ProviderName][tier]);
73
+ expect(profile.effort).toBeNull();
74
+ }
75
+ expect((await getModelProfileByName("claude-smart"))?.model).toBe("opus");
76
+ expect((await getModelProfileByName("codex-ultra"))?.model).toBe("gpt-5.6-sol");
77
+ });
78
+
79
+ test("a global MODEL_TIER row overrides the seed's model; reseeding never overwrites", async () => {
80
+ await upsertSwarmConfig({
81
+ scope: "global",
82
+ key: "MODEL_TIER_CLAUDE_SMART",
83
+ value: "claude-opus-5-5",
84
+ });
85
+ await seedModelProfiles();
86
+ expect((await getModelProfileByName("claude-smart"))?.model).toBe("claude-opus-5-5");
87
+
88
+ await getDbClient().run(
89
+ "UPDATE model_profiles SET model = 'edited' WHERE name = 'claude-regular'",
90
+ );
91
+ expect(await seedModelProfiles()).toBe(0);
92
+ expect((await getModelProfileByName("claude-regular"))?.model).toBe("edited");
93
+ });
94
+ });
95
+
96
+ describe("migration 220: profiles from agent overrides", () => {
97
+ async function agentWith(
98
+ name: string,
99
+ harnessProvider: ProviderName,
100
+ model?: string,
101
+ effort?: string,
102
+ ): Promise<string> {
103
+ const agent = await createAgent({ name, isLead: false, status: "idle", harnessProvider });
104
+ if (model) {
105
+ await upsertSwarmConfig({
106
+ scope: "agent",
107
+ scopeId: agent.id,
108
+ key: "MODEL_OVERRIDE",
109
+ value: model,
110
+ });
111
+ }
112
+ if (effort) {
113
+ await upsertSwarmConfig({
114
+ scope: "agent",
115
+ scopeId: agent.id,
116
+ key: "REASONING_EFFORT_OVERRIDE",
117
+ value: effort,
118
+ });
119
+ }
120
+ return agent.id;
121
+ }
122
+
123
+ test("dedupes by (harness, model, effort) and makes each the agent's default", async () => {
124
+ const a = await agentWith("m-a", "claude", "claude-opus-5-5", "high");
125
+ const b = await agentWith("m-b", "claude", "claude-opus-5-5", "high");
126
+ const c = await agentWith("m-c", "codex", "gpt-6.1-sol", "xhigh");
127
+ const d = await agentWith("m-d", "claude", "openrouter/vendor/model:free");
128
+ const skipped = [
129
+ await agentWith("m-acp", "acp", "some-acp-model"),
130
+ await agentWith("m-alias", "claude", "latest:anthropic/opus"),
131
+ await agentWith("m-none", "claude"),
132
+ ];
133
+
134
+ getDb().exec(agentOverrideMigrationSql());
135
+
136
+ const profiles = await listModelProfiles();
137
+ expect(profiles.map((p) => [p.name, p.harness, p.model, p.effort])).toEqual([
138
+ ["claude-openrouter-vendor-model-free", "claude", "openrouter/vendor/model:free", null],
139
+ ["claude-opus-5-5-high", "claude", "claude-opus-5-5", "high"],
140
+ ["codex-gpt-6.1-sol-xhigh", "codex", "gpt-6.1-sol", "xhigh"],
141
+ ]);
142
+ expect(profiles.every((p) => p.seededFrom === "agent-config" && p.allowCustomModel)).toBe(true);
143
+
144
+ const defaults = await getSwarmConfigs({ scope: "agent", key: "MODEL_PROFILE" });
145
+ expect(Object.fromEntries(defaults.map((row) => [row.scopeId, row.value]))).toEqual({
146
+ [a]: "claude-opus-5-5-high",
147
+ [b]: "claude-opus-5-5-high",
148
+ [c]: "codex-gpt-6.1-sol-xhigh",
149
+ [d]: "claude-openrouter-vendor-model-free",
150
+ });
151
+ expect(defaults.every((row) => row.exportToEnv === false)).toBe(true);
152
+ expect(defaults.some((row) => skipped.includes(row.scopeId ?? ""))).toBe(false);
153
+ });
154
+
155
+ test("an overridden agent's untiered task resolves to the same model and effort as before", async () => {
156
+ const id = await agentWith("m-same", "codex", "gpt-6.1-sol", "xhigh");
157
+ getDb().exec(agentOverrideMigrationSql());
158
+ const task = await getDbClient().get<{ id: string }>(
159
+ `INSERT INTO agent_tasks (id, agentId, task, status, source, createdAt, lastUpdatedAt)
160
+ VALUES ('t-same', ?, 'work', 'pending', 'mcp', '2026-01-01', '2026-01-01') RETURNING id`,
161
+ [id],
162
+ );
163
+ const fields = await recordClaimModelResolution(
164
+ { id: task?.id ?? "" },
165
+ { id, harnessProvider: "codex" },
166
+ );
167
+ expect(fields).toMatchObject({
168
+ resolvedModel: "gpt-6.1-sol",
169
+ modelSource: "profile:agent-default",
170
+ resolvedProfile: "codex-gpt-6.1-sol-xhigh",
171
+ effort: "xhigh",
172
+ });
173
+ });
174
+ });
@@ -56,8 +56,8 @@ afterAll(async () => {
56
56
  });
57
57
 
58
58
  describe("seed-scripts catalog", () => {
59
- test("manifest holds 27 unique, well-described scripts", () => {
60
- expect(SEED_SCRIPTS.length).toBe(27);
59
+ test("manifest holds 28 unique, well-described scripts", () => {
60
+ expect(SEED_SCRIPTS.length).toBe(28);
61
61
  const names = SEED_SCRIPTS.map((s) => s.name);
62
62
  expect(new Set(names).size).toBe(names.length);
63
63
  for (const s of SEED_SCRIPTS) {
@@ -78,6 +78,7 @@ afterAll(async () => {
78
78
  });
79
79
 
80
80
  describe("migration 196 skill invocations", () => {
81
+ // The full migration chain on a fresh DB can be slow under CI shard load.
81
82
  test("fresh DB has the counter columns and the invocations table", () => {
82
83
  const db = new Database(MIGRATION_DB_PATH, { create: true });
83
84
  try {
@@ -109,8 +110,9 @@ describe("migration 196 skill invocations", () => {
109
110
  } finally {
110
111
  db.close();
111
112
  }
112
- });
113
+ }, 30_000);
113
114
 
115
+ // The full migration chain on a fresh DB can be slow under CI shard load.
114
116
  test("existing DB keeps its skills and starts them at zero", async () => {
115
117
  await removeDb(MIGRATION_DB_PATH);
116
118
  const db = new Database(MIGRATION_DB_PATH, { create: true });
@@ -143,7 +145,7 @@ describe("migration 196 skill invocations", () => {
143
145
  } finally {
144
146
  db.close();
145
147
  }
146
- });
148
+ }, 30_000);
147
149
  });
148
150
 
149
151
  describe("skill.invoke ingestion", () => {
@@ -192,6 +192,42 @@ describe("buildCompletedBlocks", () => {
192
192
  });
193
193
  });
194
194
 
195
+ describe("completed status icon override", () => {
196
+ test("buildCompletedBlocks and tree lines use SLACK_STATUS_ICON_COMPLETED", () => {
197
+ process.env.SLACK_STATUS_ICON_COMPLETED = "bee-lgtm";
198
+ try {
199
+ const card = buildCompletedBlocks({
200
+ agentName: "Alpha",
201
+ taskId: "abcdef12-3456-7890-abcd-ef1234567890",
202
+ body: "Task output here",
203
+ });
204
+ expect(card[0].text.text.startsWith(":bee-lgtm: *Alpha*")).toBe(true);
205
+
206
+ const tree = buildTreeBlocks([
207
+ {
208
+ taskId: makeTaskId("icon0001"),
209
+ agentName: "Lead",
210
+ status: "completed",
211
+ children: [
212
+ {
213
+ taskId: makeTaskId("icon0002"),
214
+ agentName: "Worker",
215
+ status: "failed",
216
+ children: [],
217
+ },
218
+ ],
219
+ },
220
+ ]);
221
+ const text = tree[0].text.text;
222
+ expect(text).toContain(":bee-lgtm: *Lead*");
223
+ expect(text).toContain("❌ *Worker*");
224
+ expect(text).not.toContain("✅");
225
+ } finally {
226
+ delete process.env.SLACK_STATUS_ICON_COMPLETED;
227
+ }
228
+ });
229
+ });
230
+
195
231
  describe("buildFailedBlocks", () => {
196
232
  test("returns single-line header + error section", () => {
197
233
  const blocks = buildFailedBlocks({
@@ -71,4 +71,18 @@ describe("Slack inline completion output", () => {
71
71
  expect(text).toContain("|`abcdef12`>");
72
72
  expect(text).toContain(finalMarker);
73
73
  });
74
+
75
+ test("the inline output header uses SLACK_STATUS_ICON_COMPLETED", () => {
76
+ process.env.SLACK_STATUS_ICON_COMPLETED = ":bee-lgtm:";
77
+ try {
78
+ const [first] = formatInlineCompletionOutputChunks({
79
+ agentName: "Analyst",
80
+ taskId: TASK_ID,
81
+ output: "Done.",
82
+ });
83
+ expect(first?.startsWith(":bee-lgtm: *Analyst* completed with output")).toBe(true);
84
+ } finally {
85
+ delete process.env.SLACK_STATUS_ICON_COMPLETED;
86
+ }
87
+ });
74
88
  });
@@ -2,6 +2,7 @@ import { afterAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import * as otelModule from "../otel";
3
3
  import { ackSlackMessage, finalizeSlackMessageReaction } from "../slack/ack";
4
4
  import {
5
+ completedStatusIcon,
5
6
  normalizeSlackReactionShortcode,
6
7
  reactionName,
7
8
  SLACK_REACTION_CONFIG_KEYS,
@@ -114,6 +115,25 @@ describe("reaction-shortcode.ts", () => {
114
115
  expect(normalizeSlackReactionShortcode("thumbsup::skin-tone-6::skin-tone-6")).toBeNull();
115
116
  });
116
117
 
118
+ test("completedStatusIcon defaults to ✅ and renders a configured name as :name:", () => {
119
+ const previous = process.env.SLACK_STATUS_ICON_COMPLETED;
120
+ try {
121
+ delete process.env.SLACK_STATUS_ICON_COMPLETED;
122
+ expect(completedStatusIcon()).toBe("✅");
123
+ for (const value of ["bee-lgtm", ":bee-lgtm:", " :Bee-LGTM: "]) {
124
+ process.env.SLACK_STATUS_ICON_COMPLETED = value;
125
+ expect(completedStatusIcon()).toBe(":bee-lgtm:");
126
+ }
127
+ for (const bad of ["", "::", "bee lgtm", "✅", "bee:lgtm"]) {
128
+ process.env.SLACK_STATUS_ICON_COMPLETED = bad;
129
+ expect(completedStatusIcon()).toBe("✅");
130
+ }
131
+ } finally {
132
+ if (previous === undefined) delete process.env.SLACK_STATUS_ICON_COMPLETED;
133
+ else process.env.SLACK_STATUS_ICON_COMPLETED = previous;
134
+ }
135
+ });
136
+
117
137
  test("no Slack source file outside ack.ts names a reaction shortcode or calls reactions.add", async () => {
118
138
  const glob = new Bun.Glob("*.ts");
119
139
  const dir = new URL("../slack/", import.meta.url);
@@ -1116,6 +1116,43 @@ describe("Slack renderer v2", () => {
1116
1116
  expect(doneText.startsWith("🧵 ✅ done")).toBe(true);
1117
1117
  });
1118
1118
 
1119
+ test("the completed glyph follows SLACK_STATUS_ICON_COMPLETED in tree, header and cards", async () => {
1120
+ const lead = await createAgent({ name: "Icon Lead", isLead: true, status: "idle" });
1121
+ const ask = await createTaskExtended("icon ask", { agentId: lead.id, source: "slack" });
1122
+ const now = new Date();
1123
+ const done = {
1124
+ ...ask,
1125
+ status: "completed" as const,
1126
+ output: "Shipped.",
1127
+ lastUpdatedAt: now.toISOString(),
1128
+ finishedAt: now.toISOString(),
1129
+ };
1130
+
1131
+ const defaultTree = await renderThreadTree([done], new Map(), now);
1132
+ expect(defaultTree.startsWith("🧵 ✅ done")).toBe(true);
1133
+ expect(defaultTree).toContain("↳ ✅ ");
1134
+ expect(await childOutcomeContent(done, false)).toBe("↳ ✅ Icon Lead — result: Shipped.");
1135
+
1136
+ process.env.SLACK_STATUS_ICON_COMPLETED = ":bee-lgtm:";
1137
+ try {
1138
+ const tree = await renderThreadTree([done], new Map(), now);
1139
+ expect(tree.startsWith("🧵 :bee-lgtm: done")).toBe(true);
1140
+ expect(tree).toContain("↳ :bee-lgtm: ");
1141
+ expect(tree).not.toContain("✅");
1142
+ expect(await childOutcomeContent(done, false)).toBe(
1143
+ "↳ :bee-lgtm: Icon Lead — result: Shipped.",
1144
+ );
1145
+ expect(await childOutcomeContent(done, true)).toBe("↳ :bee-lgtm: Icon Lead completed");
1146
+
1147
+ // Only the completed glyph moves; failure keeps its own icon.
1148
+ expect(
1149
+ await childOutcomeContent({ ...done, status: "failed", failureReason: "boom" }, false),
1150
+ ).toBe("↳ ❌ Icon Lead — failed: boom");
1151
+ } finally {
1152
+ delete process.env.SLACK_STATUS_ICON_COMPLETED;
1153
+ }
1154
+ });
1155
+
1119
1156
  test("a parked deferral reads waiting, and done once its wake-up settles", async () => {
1120
1157
  const lead = await createAgent({ name: "Parked Header Lead", isLead: true, status: "idle" });
1121
1158
  const ask = await createTaskExtended("parked header ask", {
@@ -1,16 +1,21 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { unlinkSync } from "node:fs";
3
3
  import {
4
4
  closeDb,
5
5
  createAgent,
6
6
  createTaskExtended,
7
7
  createUser,
8
+ getDbClient,
8
9
  getLatestActiveTaskInThread,
9
10
  getMostRecentTaskInThread,
10
11
  getSlackTasksInThread,
11
12
  initDb,
12
13
  } from "../be/db";
14
+ import { installExtension, setExtensionState } from "../be/extensions/db";
13
15
  import { type IdentityActor, linkIdentity } from "../be/users";
16
+ import { registerLoaded, unregister } from "../extensions/dispatcher";
17
+ import { ensureExtensionAgent } from "../extensions/identity";
18
+ import { cleanExtensionTmpRoot, loadExtension } from "../extensions/loader";
14
19
  import { registerMessageHandler } from "../slack/handlers";
15
20
  import {
16
21
  bufferThreadMessage,
@@ -18,6 +23,7 @@ import {
18
23
  instantFlush,
19
24
  isThreadBuffered,
20
25
  } from "../slack/thread-buffer";
26
+ import { loadBundleFixture } from "./fixtures/extensions/load";
21
27
 
22
28
  process.env.SLACK_RENDER_V2 = "false";
23
29
 
@@ -73,6 +79,27 @@ afterAll(() => {
73
79
  }
74
80
  });
75
81
 
82
+ async function loadBlockingExtension(reason: string) {
83
+ const bundle = await loadBundleFixture("minimal");
84
+ const name = "slack-buffer-block-test";
85
+ bundle.manifest = { ...bundle.manifest, name };
86
+ bundle.files[bundle.manifest.assets.hooks] = `
87
+ import { block, type SwarmExtension } from "swarm-extension";
88
+ const extension: SwarmExtension = (api) => {
89
+ api.on("pre.task.create", () => block(${JSON.stringify(reason)}));
90
+ };
91
+ export default extension;
92
+ `;
93
+ const installed = await installExtension(bundle);
94
+ const record = await setExtensionState(installed.extension.id, {
95
+ agentId: await ensureExtensionAgent(name),
96
+ });
97
+ if (!record) throw new Error("Failed to load the blocking Slack test extension");
98
+ const loaded = await loadExtension({ record, ...bundle });
99
+ registerLoaded(loaded);
100
+ return loaded;
101
+ }
102
+
76
103
  describe("Slack thread buffer", () => {
77
104
  describe("ADDITIVE_SLACK thread ingress", () => {
78
105
  const originalAdditiveSlack = process.env.ADDITIVE_SLACK;
@@ -612,4 +639,74 @@ describe("Slack thread buffer", () => {
612
639
  expect(task!.task).toContain("solo message");
613
640
  });
614
641
  });
642
+
643
+ test(
644
+ "logs a blocked pre-task.create once at info level with its reason scrubbed",
645
+ async () => {
646
+ const secret = `ghp_${"Z".repeat(36)}`;
647
+ const extensionName = "slack-buffer-block-test";
648
+ const loaded = await loadBlockingExtension(`Ticket required: ${secret}`);
649
+ const info = spyOn(console, "info").mockImplementation(() => {});
650
+ const error = spyOn(console, "error").mockImplementation(() => {});
651
+ const channelId = "C_BLOCKED_FLUSH";
652
+ const threadTs = "14000.0001";
653
+
654
+ try {
655
+ bufferThreadMessage(channelId, threadTs, "follow-up", "U1", "14000.0002");
656
+ await instantFlush(`${channelId}:${threadTs}`);
657
+
658
+ expect(info).toHaveBeenCalledTimes(1);
659
+ expect(info.mock.calls[0]?.[0]).toBe("[Slack] pre.task.create blocked:");
660
+ expect(info.mock.calls[0]?.[1]).toBe(extensionName);
661
+ expect(String(info.mock.calls[0]?.[2])).toContain("[REDACTED:");
662
+ expect(String(info.mock.calls[0]?.[2])).not.toContain(secret);
663
+ expect(error).not.toHaveBeenCalled();
664
+ expect(await getMostRecentTaskInThread(channelId, threadTs)).toBeNull();
665
+ } finally {
666
+ unregister(loaded.record.id);
667
+ await loaded.dispose();
668
+ info.mockRestore();
669
+ error.mockRestore();
670
+ await cleanExtensionTmpRoot();
671
+ }
672
+ },
673
+ { timeout: 15_000 },
674
+ );
675
+
676
+ test(
677
+ "does not treat unexpected task creation errors as blocked",
678
+ async () => {
679
+ const triggerName = "fail_slack_buffer_task_creation";
680
+ const failure = "simulated Slack task creation failure";
681
+ const channelId = "C_UNEXPECTED_FLUSH";
682
+ const threadTs = "14000.0003";
683
+ const info = spyOn(console, "info").mockImplementation(() => {});
684
+ const error = spyOn(console, "error").mockImplementation(() => {});
685
+
686
+ try {
687
+ await getDbClient().run(`DROP TRIGGER IF EXISTS ${triggerName}`);
688
+ await getDbClient().run(`
689
+ CREATE TRIGGER ${triggerName}
690
+ BEFORE INSERT ON agent_tasks
691
+ WHEN NEW.slackChannelId = '${channelId}'
692
+ BEGIN
693
+ SELECT RAISE(ABORT, '${failure}');
694
+ END
695
+ `);
696
+
697
+ bufferThreadMessage(channelId, threadTs, "follow-up", "U1", "14000.0004");
698
+ await instantFlush(`${channelId}:${threadTs}`);
699
+
700
+ expect(info).not.toHaveBeenCalled();
701
+ expect(error).toHaveBeenCalledTimes(1);
702
+ expect(String(error.mock.calls[0]?.[1])).toContain(failure);
703
+ expect(await getMostRecentTaskInThread(channelId, threadTs)).toBeNull();
704
+ } finally {
705
+ await getDbClient().run(`DROP TRIGGER IF EXISTS ${triggerName}`);
706
+ info.mockRestore();
707
+ error.mockRestore();
708
+ }
709
+ },
710
+ { timeout: 15_000 },
711
+ );
615
712
  });