@desplega.ai/agent-swarm 1.157.0 → 1.158.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 (256) hide show
  1. package/dist/{acp-adapter-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
  2. package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
  3. package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
  4. package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
  5. package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
  6. package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
  7. package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
  8. package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
  9. package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
  10. package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
  11. package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
  12. package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
  13. package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
  14. package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
  15. package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
  16. package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
  17. package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
  18. package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
  19. package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
  20. package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
  21. package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
  22. package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
  23. package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
  24. package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
  25. package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
  26. package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
  27. package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
  28. package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
  29. package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
  30. package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
  31. package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
  32. package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
  33. package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
  34. package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
  35. package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
  36. package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
  37. package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
  38. package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
  39. package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
  40. package/dist/cli-t1ze64h3.js +811 -0
  41. package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
  42. package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
  43. package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
  44. package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
  45. package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
  46. package/dist/cli.js +29 -15
  47. package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
  48. package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
  49. package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
  50. package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
  51. package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
  52. package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
  53. package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
  54. package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
  55. package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
  56. package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
  57. package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
  58. package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
  59. package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
  60. package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
  61. package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
  62. package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
  63. package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
  64. package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
  65. package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
  66. package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
  67. package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
  68. package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
  69. package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
  70. package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
  71. package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
  72. package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
  73. package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
  74. package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
  75. package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
  76. package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
  77. package/openapi.json +1626 -629
  78. package/package.json +2 -1
  79. package/src/apps/catalog.generated.json +229 -0
  80. package/src/apps/page-validator.ts +71 -16
  81. package/src/apps/row-store.ts +66 -11
  82. package/src/apps/sync.ts +59 -17
  83. package/src/artifact-sdk/browser-sdk.ts +436 -2
  84. package/src/be/approval-sweeps.ts +109 -0
  85. package/src/be/boot-seeds.ts +40 -0
  86. package/src/be/chunking.ts +1 -1
  87. package/src/be/db/agents.ts +20 -0
  88. package/src/be/db/approvals.ts +78 -0
  89. package/src/be/db/tasks/read.ts +197 -39
  90. package/src/be/db/tasks/write.ts +41 -9
  91. package/src/be/db-client.ts +107 -48
  92. package/src/be/db.ts +219 -76
  93. package/src/be/events.ts +33 -1
  94. package/src/be/memory/graph-expansion.ts +10 -3
  95. package/src/be/memory/providers/sqlite-store.ts +55 -13
  96. package/src/be/memory/raters/retrieval.ts +5 -2
  97. package/src/be/memory/reranker.ts +26 -2
  98. package/src/be/memory/types.ts +2 -0
  99. package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
  100. package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
  101. package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
  102. package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
  103. package/src/be/models-catalog.ts +2 -0
  104. package/src/be/modelsdev-cache.json +1857 -0
  105. package/src/be/modelsdev-cache.ts +14 -1
  106. package/src/be/pricing-normalize.ts +2 -0
  107. package/src/be/scripts/typecheck.ts +1 -0
  108. package/src/be/seed-pricing.ts +3 -1
  109. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  110. package/src/be/steering.ts +53 -3
  111. package/src/be/swarm-config-guard.ts +3 -0
  112. package/src/be/task-citations.ts +29 -0
  113. package/src/cli.tsx +14 -1
  114. package/src/commands/onboard/compose-generator.ts +8 -3
  115. package/src/commands/onboard/container-engine.ts +220 -0
  116. package/src/commands/onboard/manifest.ts +1 -0
  117. package/src/commands/onboard/steps/health-check.tsx +4 -1
  118. package/src/commands/onboard/steps/prereq-check.tsx +50 -54
  119. package/src/commands/onboard/steps/start.tsx +31 -16
  120. package/src/commands/onboard/types.ts +7 -2
  121. package/src/commands/onboard.tsx +39 -4
  122. package/src/commands/profile-sync.ts +79 -26
  123. package/src/commands/runner.ts +8 -1
  124. package/src/extensions/catalog.generated.json +17 -0
  125. package/src/extensions/contract-types.generated.ts +1 -1
  126. package/src/heartbeat/heartbeat.ts +263 -103
  127. package/src/hooks/hook.ts +21 -12
  128. package/src/http/agents.ts +35 -0
  129. package/src/http/approval-requests.ts +136 -64
  130. package/src/http/apps.ts +14 -3
  131. package/src/http/config.ts +15 -1
  132. package/src/http/events.ts +62 -2
  133. package/src/http/index.ts +6 -4
  134. package/src/http/memory.ts +21 -7
  135. package/src/http/schedules.ts +1 -1
  136. package/src/http/session-data.ts +9 -4
  137. package/src/http/sessions.ts +25 -18
  138. package/src/http/stats.ts +19 -7
  139. package/src/http/status.ts +11 -1
  140. package/src/http/tasks.ts +40 -8
  141. package/src/http/webhooks.ts +67 -46
  142. package/src/http/workflows.ts +15 -20
  143. package/src/linear/outbound.ts +37 -15
  144. package/src/otel-impl.ts +151 -1
  145. package/src/otel.ts +14 -3
  146. package/src/prompts/memories.ts +19 -2
  147. package/src/providers/claude-managed-models.ts +8 -0
  148. package/src/providers/modelsdev-reasoning.json +551 -0
  149. package/src/rbac/legacy-policy.ts +16 -0
  150. package/src/rbac/permissions.ts +4 -0
  151. package/src/scheduler/schedule-task.ts +3 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/swarm-sdk.ts +9 -0
  154. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  155. package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
  156. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  157. package/src/server.ts +12 -7
  158. package/src/slack/app.ts +35 -4
  159. package/src/slack/connection-state.ts +17 -0
  160. package/src/tasks/context-key.ts +1 -0
  161. package/src/tasks/worker-follow-up.ts +6 -2
  162. package/src/tests/agent-task-activity.test.ts +74 -0
  163. package/src/tests/agents-list-model-display.test.ts +6 -1
  164. package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
  165. package/src/tests/approval-requests.test.ts +395 -0
  166. package/src/tests/approval-sweeps-config.test.ts +47 -0
  167. package/src/tests/approval-sweeps.test.ts +508 -0
  168. package/src/tests/apps-kibo-blocks.test.ts +77 -0
  169. package/src/tests/apps-spike.test.ts +56 -1
  170. package/src/tests/apps-sync-engine.test.ts +20 -9
  171. package/src/tests/boot-seeds.test.ts +170 -0
  172. package/src/tests/cancel-approval-request-tool.test.ts +115 -0
  173. package/src/tests/claude-managed-adapter.test.ts +6 -0
  174. package/src/tests/config-key-filter.test.ts +80 -0
  175. package/src/tests/context-window.test.ts +1 -0
  176. package/src/tests/db-client-busy-retry.test.ts +96 -0
  177. package/src/tests/db-client.test.ts +60 -1
  178. package/src/tests/db-task-read-contract.test.ts +10 -0
  179. package/src/tests/db-task-write-contract.test.ts +9 -0
  180. package/src/tests/docs-index-footprint.test.js +3 -3
  181. package/src/tests/events-http.test.ts +103 -0
  182. package/src/tests/extensions-catalog.test.ts +22 -17
  183. package/src/tests/extensions-pre-task.test.ts +107 -0
  184. package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
  185. package/src/tests/follow-up-config-inheritance.test.ts +260 -0
  186. package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
  187. package/src/tests/heartbeat-reboot-live.test.ts +95 -0
  188. package/src/tests/heartbeat-stall-cas.test.ts +80 -0
  189. package/src/tests/heartbeat.test.ts +99 -3
  190. package/src/tests/hook-mcp-config.test.ts +29 -0
  191. package/src/tests/http-api-integration.test.ts +16 -2
  192. package/src/tests/kv-storage.test.ts +62 -0
  193. package/src/tests/linear-outbound-sync.test.ts +120 -5
  194. package/src/tests/list-endpoint-slimming.test.ts +91 -0
  195. package/src/tests/memory-graph-organic-route.test.ts +155 -0
  196. package/src/tests/memory-http-recall-gating.test.ts +14 -4
  197. package/src/tests/memory-hybrid.test.ts +161 -1
  198. package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
  199. package/src/tests/memory.test.ts +22 -0
  200. package/src/tests/onboard-container-engine.test.ts +240 -0
  201. package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
  202. package/src/tests/pricing-routes.test.ts +14 -0
  203. package/src/tests/profile-sync-warning.test.ts +8 -4
  204. package/src/tests/promote-draft-task-route.test.ts +19 -0
  205. package/src/tests/provider-registration.test.ts +296 -42
  206. package/src/tests/rbac-engine.test.ts +30 -0
  207. package/src/tests/reasoning-effort.test.ts +8 -0
  208. package/src/tests/script-connections.test.ts +7 -0
  209. package/src/tests/sdk-allowlist.test.ts +39 -0
  210. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  211. package/src/tests/sessions-detail-batch.test.ts +250 -0
  212. package/src/tests/sessions.test.ts +118 -0
  213. package/src/tests/slack-config.test.ts +22 -0
  214. package/src/tests/sonnet-5-5-registry.test.ts +44 -0
  215. package/src/tests/status.test.ts +36 -1
  216. package/src/tests/swarm-diff.test.ts +316 -10
  217. package/src/tests/task-completion-idempotency.test.ts +2 -1
  218. package/src/tests/tasks-requested-by-filter.test.ts +3 -1
  219. package/src/tests/tool-annotations.test.ts +1 -1
  220. package/src/tests/ui-list-slim-projections.test.ts +158 -0
  221. package/src/tests/webhooks-task-blocked.test.ts +127 -0
  222. package/src/tests/workflow-approval-recovery.test.ts +203 -0
  223. package/src/tests/workflow-hitl-routing.test.ts +22 -1
  224. package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
  225. package/src/tests/workflow-http-v2.test.ts +99 -8
  226. package/src/tests/workflow-retry-validation.test.ts +56 -0
  227. package/src/tests/workflow-tla-races.test.ts +389 -0
  228. package/src/tests/workflow-validation-port-routing.test.ts +113 -1
  229. package/src/tools/cancel-approval-request.ts +57 -0
  230. package/src/tools/memory-search.ts +1 -0
  231. package/src/tools/request-human-input.ts +4 -2
  232. package/src/tools/send-task.ts +4 -0
  233. package/src/tools/templates.ts +19 -0
  234. package/src/tools/tool-config.ts +2 -1
  235. package/src/tools/workflows/list-workflow-runs.ts +1 -0
  236. package/src/types.ts +38 -0
  237. package/src/utils/context-window.ts +1 -0
  238. package/src/workflows/approval-cancel.ts +152 -0
  239. package/src/workflows/approval-notifications.ts +84 -0
  240. package/src/workflows/checkpoint.ts +10 -5
  241. package/src/workflows/definition.ts +21 -0
  242. package/src/workflows/engine.ts +152 -21
  243. package/src/workflows/executors/human-in-the-loop.ts +1 -1
  244. package/src/workflows/recovery.ts +100 -45
  245. package/src/workflows/resume.ts +49 -18
  246. package/src/workflows/retry-poller.ts +71 -21
  247. package/templates/extensions/README.md +1 -0
  248. package/templates/extensions/github-sender-allowlist/README.md +18 -0
  249. package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
  250. package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
  251. package/templates/skills/apps/content.md +20 -1
  252. package/templates/skills/code-quality/content.md +26 -1
  253. package/templates/skills/pages/content.md +16 -4
  254. package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
  255. package/dist/cli-2daksqps.js +0 -378
  256. package/dist/rbac-roles-3gxmnb20.js +0 -37
@@ -0,0 +1,260 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4
+ import {
5
+ closeDb,
6
+ completeTask,
7
+ createAgent,
8
+ createTaskExtended,
9
+ getDbClient,
10
+ getScheduledTaskById,
11
+ getTaskById,
12
+ initDb,
13
+ startTask,
14
+ } from "../be/db";
15
+ import { getRebootAffectedTasks, runRebootSweep } from "../heartbeat/heartbeat";
16
+ import { createStandaloneScheduleTask } from "../scheduler/schedule-task";
17
+ import { createResumeFollowUp, createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
18
+ import { registerDeferTaskTool } from "../tools/defer-task";
19
+ import { registerSendTaskTool } from "../tools/send-task";
20
+ import type { FollowUpConfig } from "../types";
21
+
22
+ // followUpConfig follows one piece of work, not the thread: continuations of
23
+ // the same work inherit it, new work created with a parentTaskId does not.
24
+
25
+ type Tool = { handler: (args: unknown, extra: unknown) => Promise<CallToolResult> };
26
+
27
+ const ON_COMPLETED: FollowUpConfig = { onCompleted: "Switch the cron and DM the requester." };
28
+
29
+ let leadId: string;
30
+ let workerId: string;
31
+ let sendTask: Tool;
32
+ let deferTask: Tool;
33
+
34
+ function callTool(tool: Tool, args: Record<string, unknown>, agentId: string) {
35
+ return tool.handler(args, {
36
+ sessionId: crypto.randomUUID(),
37
+ requestInfo: { headers: { "x-agent-id": agentId } },
38
+ });
39
+ }
40
+
41
+ function sentTaskId(result: CallToolResult): string {
42
+ const structured = result.structuredContent as { success: boolean; task?: { id: string } };
43
+ expect(structured.success).toBe(true);
44
+ return structured.task!.id;
45
+ }
46
+
47
+ async function followUpConfigOf(taskId: string) {
48
+ return (await getTaskById(taskId))?.followUpConfig;
49
+ }
50
+
51
+ async function workerTaskWithOnCompleted(description: string) {
52
+ const task = await createTaskExtended(description, {
53
+ agentId: workerId,
54
+ followUpConfig: ON_COMPLETED,
55
+ });
56
+ await startTask(task.id);
57
+ return task;
58
+ }
59
+
60
+ beforeAll(async () => {
61
+ initDb(":memory:");
62
+ const server = new McpServer({ name: "follow-up-config-inheritance", version: "1.0.0" });
63
+ registerSendTaskTool(server);
64
+ registerDeferTaskTool(server);
65
+ const tools = (server as unknown as { _registeredTools: Record<string, Tool> })._registeredTools;
66
+ sendTask = tools["send-task"]!;
67
+ deferTask = tools["defer-task"]!;
68
+ });
69
+
70
+ beforeEach(async () => {
71
+ const db = getDbClient();
72
+ await db.run("DELETE FROM agent_tasks");
73
+ await db.run("DELETE FROM agents");
74
+ await db.run("DELETE FROM active_sessions");
75
+ leadId = (
76
+ await createAgent({
77
+ name: "Lead",
78
+ isLead: true,
79
+ status: "idle",
80
+ capabilities: [],
81
+ maxTasks: 50,
82
+ })
83
+ ).id;
84
+ workerId = (
85
+ await createAgent({
86
+ name: "Worker",
87
+ isLead: false,
88
+ status: "idle",
89
+ capabilities: [],
90
+ maxTasks: 50,
91
+ })
92
+ ).id;
93
+ });
94
+
95
+ afterAll(() => closeDb());
96
+
97
+ describe("createTaskExtended followUpConfig inheritance", () => {
98
+ test("a plain child with parentTaskId does not inherit", async () => {
99
+ const parent = await workerTaskWithOnCompleted("parent work");
100
+ const child = await createTaskExtended("unrelated child", { parentTaskId: parent.id });
101
+ expect(child.followUpConfig).toBeUndefined();
102
+ });
103
+
104
+ test("an explicit followUpConfig wins over the parent's", async () => {
105
+ const parent = await workerTaskWithOnCompleted("parent work");
106
+ const own: FollowUpConfig = { onFailed: "Retry once." };
107
+ const child = await createTaskExtended("continuation", {
108
+ parentTaskId: parent.id,
109
+ inheritParentFollowUpConfig: true,
110
+ followUpConfig: own,
111
+ });
112
+ expect(child.followUpConfig).toEqual(own);
113
+ });
114
+ });
115
+
116
+ describe("kept: continuations of the same work inherit followUpConfig", () => {
117
+ test("interrupted-task resume (createResumeFollowUp)", async () => {
118
+ const parent = await workerTaskWithOnCompleted("interrupted work");
119
+ const resume = await createResumeFollowUp({ parentId: parent.id, reason: "crash_recovery" });
120
+ expect(resume.kind).toBe("created");
121
+ if (resume.kind !== "created") return;
122
+ expect(resume.task.followUpConfig).toEqual(ON_COMPLETED);
123
+ });
124
+
125
+ test("reboot-sweep retry", async () => {
126
+ const parent = await workerTaskWithOnCompleted("work cut by a reboot");
127
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
128
+ new Date(Date.now() - 1000).toISOString(),
129
+ parent.id,
130
+ ]);
131
+
132
+ await runRebootSweep();
133
+
134
+ const retryTaskId = getRebootAffectedTasks().find(
135
+ (a) => a.original.id === parent.id,
136
+ )?.retryTaskId;
137
+ expect(retryTaskId).toBeTruthy();
138
+ expect(await followUpConfigOf(retryTaskId!)).toEqual(ON_COMPLETED);
139
+ });
140
+
141
+ test("defer-task wake-up continuation", async () => {
142
+ const parent = await workerTaskWithOnCompleted("work that waits");
143
+ const result = await callTool(
144
+ deferTask,
145
+ { taskId: parent.id, delayMs: 3_600_000, summary: "Waiting", note: "Check back" },
146
+ workerId,
147
+ );
148
+ const { success, scheduleId } = result.structuredContent as {
149
+ success: boolean;
150
+ scheduleId?: string;
151
+ };
152
+ expect(success).toBe(true);
153
+ const schedule = await getScheduledTaskById(scheduleId!);
154
+
155
+ const wake = await createStandaloneScheduleTask(schedule!);
156
+ expect(wake.parentTaskId).toBe(parent.id);
157
+ expect(wake.followUpConfig).toEqual(ON_COMPLETED);
158
+ });
159
+
160
+ test("send-task resume re-delegation (reroute-decision template)", async () => {
161
+ const parent = await workerTaskWithOnCompleted("crashed work");
162
+ const childId = sentTaskId(
163
+ await callTool(
164
+ sendTask,
165
+ {
166
+ task: "resume the crashed work",
167
+ agentId: workerId,
168
+ routingReason: "reroute_fault",
169
+ routingNote: "same specialization as the crashed worker",
170
+ taskType: "resume",
171
+ parentTaskId: parent.id,
172
+ },
173
+ leadId,
174
+ ),
175
+ );
176
+ expect(await followUpConfigOf(childId)).toEqual(ON_COMPLETED);
177
+ });
178
+ });
179
+
180
+ describe("stopped: new work does not inherit followUpConfig", () => {
181
+ test("Lead follow-up from createWorkerTaskFollowUp", async () => {
182
+ const task = await workerTaskWithOnCompleted("worker build task");
183
+ const completed = await completeTask(task.id, "done");
184
+ const followUp = await createWorkerTaskFollowUp({
185
+ task: completed!,
186
+ status: "completed",
187
+ output: "done",
188
+ });
189
+ expect(followUp).not.toBeNull();
190
+ // The instruction is delivered in the follow-up's text, once.
191
+ expect(followUp!.task).toContain(ON_COMPLETED.onCompleted!);
192
+ expect(followUp!.followUpConfig).toBeUndefined();
193
+ });
194
+
195
+ test("send-task child with parentTaskId", async () => {
196
+ const parent = await workerTaskWithOnCompleted("original request");
197
+ const childId = sentTaskId(
198
+ await callTool(
199
+ sendTask,
200
+ {
201
+ task: "a new delegation in the same thread",
202
+ agentId: workerId,
203
+ routingReason: "skill",
204
+ routingNote: "worker owns this area of the codebase",
205
+ parentTaskId: parent.id,
206
+ },
207
+ leadId,
208
+ ),
209
+ );
210
+ expect(await followUpConfigOf(childId)).toBeUndefined();
211
+ });
212
+
213
+ test("send-task child keeps an explicit followUpConfig", async () => {
214
+ const parent = await workerTaskWithOnCompleted("original request 2");
215
+ const own: FollowUpConfig = { disabled: true };
216
+ const childId = sentTaskId(
217
+ await callTool(
218
+ sendTask,
219
+ {
220
+ task: "delegation that is awaited inline",
221
+ agentId: workerId,
222
+ routingReason: "skill",
223
+ routingNote: "worker owns this area of the codebase",
224
+ parentTaskId: parent.id,
225
+ followUpConfig: own,
226
+ },
227
+ leadId,
228
+ ),
229
+ );
230
+ expect(await followUpConfigOf(childId)).toEqual(own);
231
+ });
232
+
233
+ test("chain regression: worker task -> Lead follow-up -> send-task child", async () => {
234
+ const task = await workerTaskWithOnCompleted("tla-spec-sync build");
235
+ const completed = await completeTask(task.id, "built");
236
+ const followUp = await createWorkerTaskFollowUp({
237
+ task: completed!,
238
+ status: "completed",
239
+ output: "built",
240
+ });
241
+ expect(followUp).not.toBeNull();
242
+
243
+ const childId = sentTaskId(
244
+ await callTool(
245
+ sendTask,
246
+ {
247
+ task: "render the launch video",
248
+ agentId: workerId,
249
+ routingReason: "skill",
250
+ routingNote: "worker owns the video pipeline",
251
+ parentTaskId: followUp!.id,
252
+ },
253
+ leadId,
254
+ ),
255
+ );
256
+
257
+ expect(await followUpConfigOf(followUp!.id)).toBeUndefined();
258
+ expect(await followUpConfigOf(childId)).toBeUndefined();
259
+ });
260
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Counterexamples from the TLA+ heartbeat model (specs/tla/heartbeat/).
3
+ *
4
+ * Each test replays one TLC trace against a real temp SQLite DB, calling the
5
+ * functions ACTIONS.md maps each trace step to. A test that passes on main
6
+ * means the trace was model drift, not a bug.
7
+ */
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { unlink } from "node:fs/promises";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTaskExtended,
15
+ getChildTasks,
16
+ getDbClient,
17
+ getTaskById,
18
+ initDb,
19
+ startTask,
20
+ supersedeTask,
21
+ } from "../be/db";
22
+ import {
23
+ codeLevelTriage,
24
+ runRebootSweep,
25
+ setBeforeHeartbeatSupersedeForTests,
26
+ } from "../heartbeat/heartbeat";
27
+
28
+ const TEST_DB_PATH = "./test-heartbeat-orphan-resume.sqlite";
29
+
30
+ describe("Heartbeat orphaned supersede repair (TLA+ counterexample)", () => {
31
+ beforeAll(async () => {
32
+ try {
33
+ await unlink(TEST_DB_PATH);
34
+ } catch {
35
+ // File doesn't exist
36
+ }
37
+ closeDb();
38
+ initDb(TEST_DB_PATH);
39
+ });
40
+
41
+ afterAll(async () => {
42
+ closeDb();
43
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
44
+ try {
45
+ await unlink(path);
46
+ } catch {
47
+ // Files may not exist
48
+ }
49
+ }
50
+ });
51
+
52
+ beforeEach(async () => {
53
+ setBeforeHeartbeatSupersedeForTests(null);
54
+ const db = getDbClient();
55
+ await db.run("DELETE FROM agent_tasks");
56
+ await db.run("DELETE FROM agents");
57
+ await db.run("DELETE FROM active_sessions");
58
+ });
59
+
60
+ // Trace: PollStart -> Age -> HbRead -> HbWrite -> ApiCrash -> ApiBoot (SupersededGetsResume).
61
+ test("a task superseded without a resume (crash before resume creation) gets a resume on the next sweep", async () => {
62
+ const agent = await createAgent({ name: "crashed-worker", isLead: false, status: "idle" });
63
+ const task = await createTaskExtended("Work interrupted mid-recovery", { agentId: agent.id });
64
+ await startTask(task.id);
65
+
66
+ // State the API process leaves behind if it dies between supersedeTask
67
+ // and createResumeFollowUp in remediateCrashedWorkerTask.
68
+ await supersedeTask(task.id, {
69
+ reason: "Auto-superseded by heartbeat: worker session not found (no active session for task)",
70
+ resumeTaskId: null,
71
+ });
72
+ await getDbClient().run("UPDATE agent_tasks SET finishedAt = ? WHERE id = ?", [
73
+ new Date(Date.now() - 10 * 60 * 1000).toISOString(),
74
+ task.id,
75
+ ]);
76
+
77
+ // Restarted API: boot sweep, then a regular sweep.
78
+ await runRebootSweep();
79
+ await codeLevelTriage();
80
+
81
+ expect((await getTaskById(task.id))?.status).toBe("superseded");
82
+ expect(await getChildTasks(task.id)).toHaveLength(1);
83
+ });
84
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Counterexamples from the TLA+ heartbeat model (specs/tla/heartbeat/).
3
+ *
4
+ * Each test replays one TLC trace against a real temp SQLite DB, calling the
5
+ * functions ACTIONS.md maps each trace step to. A test that passes on main
6
+ * means the trace was model drift, not a bug.
7
+ */
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { unlink } from "node:fs/promises";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTaskExtended,
15
+ getActiveSessionForTask,
16
+ getDbClient,
17
+ getTaskById,
18
+ initDb,
19
+ insertActiveSession,
20
+ startTask,
21
+ } from "../be/db";
22
+ import { runRebootSweep, setBeforeHeartbeatSupersedeForTests } from "../heartbeat/heartbeat";
23
+
24
+ const TEST_DB_PATH = "./test-heartbeat-reboot-live.sqlite";
25
+
26
+ async function backdateTask(taskId: string, isoTime: string): Promise<void> {
27
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
28
+ isoTime,
29
+ taskId,
30
+ ]);
31
+ }
32
+
33
+ describe("Reboot sweep live sessions (TLA+ counterexample)", () => {
34
+ beforeAll(async () => {
35
+ try {
36
+ await unlink(TEST_DB_PATH);
37
+ } catch {
38
+ // File doesn't exist
39
+ }
40
+ closeDb();
41
+ initDb(TEST_DB_PATH);
42
+ });
43
+
44
+ afterAll(async () => {
45
+ closeDb();
46
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
47
+ try {
48
+ await unlink(path);
49
+ } catch {
50
+ // Files may not exist
51
+ }
52
+ }
53
+ });
54
+
55
+ beforeEach(async () => {
56
+ setBeforeHeartbeatSupersedeForTests(null);
57
+ const db = getDbClient();
58
+ await db.run("DELETE FROM agent_tasks");
59
+ await db.run("DELETE FROM agents");
60
+ await db.run("DELETE FROM active_sessions");
61
+ });
62
+
63
+ // Trace: PollStart -> RegisterSession -> ApiCrash -> ApiBoot -> RebootFail (NoLiveKill).
64
+ test("reboot sweep does not fail a live worker whose last tool call was shortly before the API restart", async () => {
65
+ const gs = globalThis as typeof globalThis & { __runId?: string };
66
+ const original = gs.__runId;
67
+ const bootTime = Date.now();
68
+ gs.__runId = `run_${bootTime}`;
69
+ try {
70
+ const agent = await createAgent({ name: "live-worker", isLead: false, status: "busy" });
71
+ const task = await createTaskExtended("Long model call", { agentId: agent.id });
72
+ await startTask(task.id);
73
+ await backdateTask(task.id, new Date(bootTime - 60_000).toISOString());
74
+
75
+ // Worker container kept running through the API deploy. Its last
76
+ // PostToolUse heartbeat landed 30s before the new API process booted.
77
+ await insertActiveSession({
78
+ agentId: agent.id,
79
+ taskId: task.id,
80
+ triggerType: "task_assigned",
81
+ });
82
+ await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
83
+ new Date(bootTime - 30_000).toISOString(),
84
+ task.id,
85
+ ]);
86
+
87
+ await runRebootSweep();
88
+
89
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
90
+ expect(await getActiveSessionForTask(task.id)).not.toBeNull();
91
+ } finally {
92
+ gs.__runId = original;
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Counterexamples from the TLA+ heartbeat model (specs/tla/heartbeat/).
3
+ *
4
+ * Each test replays one TLC trace against a real temp SQLite DB, calling the
5
+ * functions ACTIONS.md maps each trace step to. A test that passes on main
6
+ * means the trace was model drift, not a bug.
7
+ */
8
+
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { unlink } from "node:fs/promises";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTaskExtended,
15
+ getChildTasks,
16
+ getDbClient,
17
+ getTaskById,
18
+ initDb,
19
+ startTask,
20
+ updateTaskProgress,
21
+ } from "../be/db";
22
+ import { codeLevelTriage, setBeforeHeartbeatSupersedeForTests } from "../heartbeat/heartbeat";
23
+
24
+ const TEST_DB_PATH = "./test-heartbeat-stall-cas.sqlite";
25
+
26
+ async function backdateTask(taskId: string, isoTime: string): Promise<void> {
27
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
28
+ isoTime,
29
+ taskId,
30
+ ]);
31
+ }
32
+
33
+ describe("Heartbeat stall classifier CAS (TLA+ counterexample)", () => {
34
+ beforeAll(async () => {
35
+ try {
36
+ await unlink(TEST_DB_PATH);
37
+ } catch {
38
+ // File doesn't exist
39
+ }
40
+ closeDb();
41
+ initDb(TEST_DB_PATH);
42
+ });
43
+
44
+ afterAll(async () => {
45
+ closeDb();
46
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
47
+ try {
48
+ await unlink(path);
49
+ } catch {
50
+ // Files may not exist
51
+ }
52
+ }
53
+ });
54
+
55
+ beforeEach(async () => {
56
+ setBeforeHeartbeatSupersedeForTests(null);
57
+ const db = getDbClient();
58
+ await db.run("DELETE FROM agent_tasks");
59
+ await db.run("DELETE FROM agents");
60
+ await db.run("DELETE FROM active_sessions");
61
+ });
62
+
63
+ // Trace: PollStart -> Age -> HbRead -> Progress -> HbWrite (NoLiveKill).
64
+ test("stall classifier does not supersede a task that made progress after the candidate read", async () => {
65
+ const agent = await createAgent({ name: "quiet-worker", isLead: false, status: "busy" });
66
+ const task = await createTaskExtended("Long quiet work", { agentId: agent.id });
67
+ await startTask(task.id);
68
+ await backdateTask(task.id, new Date(Date.now() - 10 * 60 * 1000).toISOString());
69
+
70
+ // The worker reports progress after the sweep read the candidate list.
71
+ setBeforeHeartbeatSupersedeForTests(async (candidate) => {
72
+ await updateTaskProgress(candidate.id, "still working");
73
+ });
74
+
75
+ await codeLevelTriage();
76
+
77
+ expect((await getTaskById(task.id))?.status).toBe("in_progress");
78
+ expect(await getChildTasks(task.id)).toHaveLength(0);
79
+ });
80
+ });
@@ -1,10 +1,12 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import {
4
+ cancelTask,
4
5
  claimTask,
5
6
  closeDb,
6
7
  createAgent,
7
8
  createTaskExtended,
9
+ failTask,
8
10
  getActiveSessionForTask,
9
11
  getDbClient,
10
12
  getIdleWorkersWithCapacity,
@@ -280,6 +282,13 @@ describe("Heartbeat Triage", () => {
280
282
  // ==========================================================================
281
283
 
282
284
  describe("Code-Level Triage", () => {
285
+ test("reports approval sweep counts in staleCleanup", async () => {
286
+ const findings = await codeLevelTriage();
287
+
288
+ expect(typeof findings.staleCleanup.approvalAutoCancelled).toBe("number");
289
+ expect(typeof findings.staleCleanup.approvalTimedOut).toBe("number");
290
+ });
291
+
283
292
  test("auto-supersedes stalled task with no active session (DES-523)", async () => {
284
293
  const agent = await createAgent({ name: "dead-worker", isLead: false, status: "busy" });
285
294
  const task = await createTaskExtended("Stalled task", { agentId: agent.id });
@@ -1037,6 +1046,93 @@ describe("Heartbeat Triage", () => {
1037
1046
  expect(retryTask!.priority).toBe(90);
1038
1047
  expect(retryTask!.source).toBe("slack");
1039
1048
  });
1049
+
1050
+ async function sweptTaskWithDependent(taskType?: string) {
1051
+ const agent = await createAgent({ name: "dead-worker", isLead: false, status: "busy" });
1052
+ const other = await createAgent({ name: "dependent-owner", isLead: false, status: "idle" });
1053
+ const parent = await createTaskExtended("Thread root", { agentId: other.id });
1054
+ const swept = await createTaskExtended("Research step", { agentId: agent.id, taskType });
1055
+ await startTask(swept.id);
1056
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
1057
+ new Date(Date.now() - 1000).toISOString(),
1058
+ swept.id,
1059
+ ]);
1060
+ const done = await createTaskExtended("Already done", { agentId: other.id });
1061
+ await getDbClient().run("UPDATE agent_tasks SET status = 'completed' WHERE id = ?", [
1062
+ done.id,
1063
+ ]);
1064
+ const dependent = await createTaskExtended("Act on the research", {
1065
+ agentId: other.id,
1066
+ dependsOn: [swept.id, done.id],
1067
+ parentTaskId: parent.id,
1068
+ priority: 70,
1069
+ slackChannelId: "C0TEST",
1070
+ slackThreadTs: "1700000000.000100",
1071
+ slackUserId: "U0TEST",
1072
+ followUpConfig: { onCompleted: "Tell the requester" },
1073
+ });
1074
+ return { swept, done, dependent, parent, other };
1075
+ }
1076
+
1077
+ test("reboot sweep re-points a never-started dependent to the retry child instead of failing it", async () => {
1078
+ const { swept, done, dependent, parent, other } = await sweptTaskWithDependent();
1079
+ const cancelled = await createTaskExtended("Cancelled for another reason", {
1080
+ agentId: other.id,
1081
+ dependsOn: [swept.id],
1082
+ });
1083
+ await cancelTask(cancelled.id, "not needed");
1084
+
1085
+ await runRebootSweep();
1086
+
1087
+ expect((await getTaskById(swept.id))?.status).toBe("failed");
1088
+ const retryTaskId = getRebootAffectedTasks()[0]!.retryTaskId;
1089
+ expect(retryTaskId).not.toBeNull();
1090
+
1091
+ const survivor = await getTaskById(dependent.id);
1092
+ expect(survivor?.status).toBe("pending");
1093
+ expect(survivor?.failureReason).toBeFalsy();
1094
+ expect(survivor?.dependsOn).toEqual([retryTaskId!, done.id]);
1095
+ expect(survivor?.agentId).toBe(other.id);
1096
+ expect(survivor?.parentTaskId).toBe(parent.id);
1097
+ expect(survivor?.priority).toBe(70);
1098
+ expect(survivor?.slackChannelId).toBe("C0TEST");
1099
+ expect(survivor?.slackThreadTs).toBe("1700000000.000100");
1100
+ expect(survivor?.slackUserId).toBe("U0TEST");
1101
+ expect(survivor?.followUpConfig).toEqual({ onCompleted: "Tell the requester" });
1102
+
1103
+ const log = await getDbClient().get<{ oldValue: string; newValue: string }>(
1104
+ "SELECT oldValue, newValue FROM agent_log WHERE taskId = ? AND eventType = 'task_dependency_repointed'",
1105
+ [dependent.id],
1106
+ );
1107
+ expect(log).toEqual({ oldValue: swept.id, newValue: retryTaskId! });
1108
+
1109
+ // Already terminal for another reason: untouched.
1110
+ const stillCancelled = await getTaskById(cancelled.id);
1111
+ expect(stillCancelled?.status).toBe("cancelled");
1112
+ expect(stillCancelled?.dependsOn).toEqual([swept.id]);
1113
+ });
1114
+
1115
+ test("reboot sweep without a retry child still cascade-fails dependents", async () => {
1116
+ const { swept, dependent } = await sweptTaskWithDependent("heartbeat-checklist");
1117
+
1118
+ await runRebootSweep();
1119
+
1120
+ expect(getRebootAffectedTasks()[0]!.retryTaskId).toBeNull();
1121
+ const dead = await getTaskById(dependent.id);
1122
+ expect(dead?.status).toBe("failed");
1123
+ expect(dead?.failureReason).toBe(`Blocked dependency ${swept.id.slice(0, 8)} was failed`);
1124
+ });
1125
+
1126
+ test("a non-reboot failure still cascade-fails dependents", async () => {
1127
+ const { swept, dependent } = await sweptTaskWithDependent();
1128
+
1129
+ await failTask(swept.id, "Worker reported an error");
1130
+
1131
+ const dead = await getTaskById(dependent.id);
1132
+ expect(dead?.status).toBe("failed");
1133
+ expect(dead?.failureReason).toBe(`Blocked dependency ${swept.id.slice(0, 8)} was failed`);
1134
+ expect(dead?.dependsOn).toContain(swept.id);
1135
+ });
1040
1136
  });
1041
1137
 
1042
1138
  // ==========================================================================
@@ -1083,13 +1179,13 @@ describe("Heartbeat Triage", () => {
1083
1179
  task.id,
1084
1180
  ]);
1085
1181
 
1086
- // Session with pre-boot heartbeat (stale)
1182
+ // Session with pre-boot heartbeat, stale past the 15 min threshold
1087
1183
  await insertActiveSession({
1088
1184
  agentId: agent.id,
1089
1185
  taskId: task.id,
1090
1186
  triggerType: "task_assigned",
1091
1187
  });
1092
- const preBootHb = new Date(bootTime - 30_000).toISOString();
1188
+ const preBootHb = new Date(bootTime - 20 * 60_000).toISOString();
1093
1189
  await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
1094
1190
  preBootHb,
1095
1191
  task.id,
@@ -1189,7 +1285,7 @@ describe("Heartbeat Triage", () => {
1189
1285
  taskId: staleTask.id,
1190
1286
  triggerType: "task_assigned",
1191
1287
  });
1192
- const preBootHb = new Date(bootTime - 30_000).toISOString();
1288
+ const preBootHb = new Date(bootTime - 20 * 60_000).toISOString();
1193
1289
  await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
1194
1290
  preBootHb,
1195
1291
  staleTask.id,
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { loadHookMcpConfig } from "../hooks/hook";
3
+
4
+ describe("loadHookMcpConfig", () => {
5
+ test("falls back to the workspace config when the project has no .mcp.json", async () => {
6
+ const tempDir = `/tmp/hook-mcp-fallback-${crypto.randomUUID()}`;
7
+ const projectDir = `${tempDir}/repo`;
8
+ const workspaceDir = `${tempDir}/workspace`;
9
+ const expected = {
10
+ url: "http://localhost:3013/mcp",
11
+ headers: {
12
+ Authorization: "Bearer example-test-key",
13
+ "X-Agent-ID": "agent-test-id",
14
+ },
15
+ };
16
+
17
+ await Bun.$`mkdir -p ${projectDir} ${workspaceDir}`.quiet();
18
+ try {
19
+ await Bun.write(
20
+ `${workspaceDir}/.mcp.json`,
21
+ JSON.stringify({ mcpServers: { "agent-swarm": expected } }),
22
+ );
23
+
24
+ expect(await loadHookMcpConfig(projectDir, workspaceDir)).toEqual(expected);
25
+ } finally {
26
+ await Bun.$`rm -rf ${tempDir}`.quiet();
27
+ }
28
+ });
29
+ });