@desplega.ai/agent-swarm 1.147.0 → 1.149.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 (185) hide show
  1. package/README.md +13 -1
  2. package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
  3. package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
  4. package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
  5. package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
  6. package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
  7. package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
  8. package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
  9. package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
  10. package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
  11. package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
  12. package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
  13. package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
  14. package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
  15. package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
  16. package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
  17. package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
  18. package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
  19. package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
  20. package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
  21. package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
  22. package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
  23. package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
  24. package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
  25. package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
  26. package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
  27. package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
  28. package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
  29. package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
  30. package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
  31. package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
  32. package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
  33. package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
  34. package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
  35. package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
  36. package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
  37. package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
  38. package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
  39. package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
  40. package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
  41. package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
  42. package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
  43. package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
  44. package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
  45. package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
  46. package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
  47. package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
  48. package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
  49. package/dist/cli.js +16 -14
  50. package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
  51. package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
  52. package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
  53. package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
  54. package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
  55. package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
  56. package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
  57. package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
  58. package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
  59. package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
  60. package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
  61. package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
  62. package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
  63. package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
  64. package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
  65. package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
  66. package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
  67. package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
  68. package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
  69. package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
  70. package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
  71. package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
  72. package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
  73. package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
  74. package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
  75. package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
  76. package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
  77. package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
  78. package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
  79. package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
  80. package/openapi.json +46 -4
  81. package/package.json +3 -1
  82. package/src/agentmail/handlers.ts +5 -0
  83. package/src/automation-preflight-alert.ts +41 -0
  84. package/src/be/automation-preflight.ts +3 -3
  85. package/src/be/budget-refusal-notify.ts +1 -0
  86. package/src/be/db/tasks/read.ts +5 -0
  87. package/src/be/db.ts +132 -2
  88. package/src/be/migrations/150_deferred_task_waits.sql +18 -0
  89. package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
  90. package/src/be/migrations/152_routing_decisions.sql +44 -0
  91. package/src/be/scripts/typecheck.ts +33 -22
  92. package/src/be/seed-scripts/catalog/delegate.ts +5 -1
  93. package/src/be/seed-skills/bundled-files.generated.json +10 -0
  94. package/src/be/seed-skills/index.ts +3 -0
  95. package/src/be/steering.ts +1 -0
  96. package/src/be/swarm-config-guard.ts +11 -0
  97. package/src/commands/onboard/steps/post-task.tsx +1 -0
  98. package/src/github/handlers.ts +9 -0
  99. package/src/gitlab/handlers.ts +4 -0
  100. package/src/heartbeat/heartbeat.ts +8 -0
  101. package/src/http/approval-requests.ts +1 -0
  102. package/src/http/apps.ts +1 -0
  103. package/src/http/config.ts +15 -6
  104. package/src/http/mcp.ts +31 -4
  105. package/src/http/script-runs.ts +6 -0
  106. package/src/http/tasks.ts +52 -30
  107. package/src/integrations/kapso/inbound.ts +1 -0
  108. package/src/jira/sync.ts +2 -0
  109. package/src/linear/sync.ts +2 -0
  110. package/src/prompts/session-templates.ts +29 -15
  111. package/src/scheduler/deferred-task-waits.ts +123 -0
  112. package/src/scheduler/schedule-task.ts +42 -0
  113. package/src/scheduler/scheduler.ts +22 -31
  114. package/src/script-workflows/workflow-ctx.ts +2 -0
  115. package/src/scripts-runtime/types/stdlib.d.ts +38 -24
  116. package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
  117. package/src/server.ts +10 -1
  118. package/src/slack/actions.ts +1 -0
  119. package/src/slack/assistant.ts +2 -0
  120. package/src/slack/handlers.ts +3 -0
  121. package/src/slack/thread-buffer.ts +2 -0
  122. package/src/tasks/worker-follow-up.ts +9 -0
  123. package/src/tests/acp-adapter.test.ts +35 -6
  124. package/src/tests/acp-session-token.test.ts +67 -0
  125. package/src/tests/asset-key-api.test.ts +15 -2
  126. package/src/tests/asset-key-mcp.test.ts +2 -0
  127. package/src/tests/automation-preflight-alert.test.ts +91 -0
  128. package/src/tests/claude-worker-parity.test.ts +3 -0
  129. package/src/tests/config-session-auth.test.ts +143 -0
  130. package/src/tests/defer-task.test.ts +117 -6
  131. package/src/tests/deferred-task-wake.test.ts +456 -0
  132. package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
  133. package/src/tests/http-api-integration.test.ts +33 -4
  134. package/src/tests/mcp-input-ergonomics.test.ts +386 -0
  135. package/src/tests/model-control.test.ts +18 -5
  136. package/src/tests/opencode-adapter.test.ts +2 -2
  137. package/src/tests/promote-draft-task-route.test.ts +4 -0
  138. package/src/tests/prompt-template-session.test.ts +13 -4
  139. package/src/tests/rbac-wire-e2e.test.ts +2 -2
  140. package/src/tests/routing-decision-persistence.test.ts +520 -0
  141. package/src/tests/routing-reason-contract.test.ts +89 -0
  142. package/src/tests/routing-reason-inventory.test.ts +55 -0
  143. package/src/tests/schedule-target-type.test.ts +48 -1
  144. package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
  145. package/src/tests/scheduled-tasks.test.ts +9 -5
  146. package/src/tests/script-connections.test.ts +4 -0
  147. package/src/tests/script-runs-http.test.ts +31 -0
  148. package/src/tests/scripts-typecheck.test.ts +11 -0
  149. package/src/tests/secret-scrubber.test.ts +12 -0
  150. package/src/tests/send-task-output-schema.test.ts +66 -1
  151. package/src/tests/send-task-requested-by.test.ts +1 -0
  152. package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
  153. package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
  154. package/src/tests/swarm-tool-result-gate.test.ts +24 -0
  155. package/src/tests/task-tool-manifest.test.ts +47 -0
  156. package/src/tests/task-tool-preload-mcp.test.ts +125 -0
  157. package/src/tests/task-tools-ctx.test.ts +2 -0
  158. package/src/tests/tool-output-agent-id.test.ts +1 -0
  159. package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
  160. package/src/tests/workflow-agent-task.test.ts +24 -1
  161. package/src/tests/workflow-engine-v2.test.ts +58 -2
  162. package/src/tools/accept-steer.ts +0 -1
  163. package/src/tools/defer-task.ts +73 -21
  164. package/src/tools/memory-get.ts +10 -3
  165. package/src/tools/memory-rate.ts +19 -7
  166. package/src/tools/memory-search.ts +2 -1
  167. package/src/tools/memory-store.ts +25 -5
  168. package/src/tools/send-task.ts +40 -2
  169. package/src/tools/skills/skill-publish.ts +1 -0
  170. package/src/tools/store-progress.ts +78 -4
  171. package/src/tools/templates.ts +2 -1
  172. package/src/tools/utils.ts +33 -2
  173. package/src/types.ts +14 -1
  174. package/src/utils/acp-session-token.ts +14 -4
  175. package/src/utils/secret-scrubber.ts +2 -0
  176. package/src/utils/task-tool-manifest.ts +33 -0
  177. package/src/workflows/engine.ts +4 -1
  178. package/src/workflows/executors/agent-task.ts +7 -1
  179. package/templates/ai-toolbox.manifest.json +6 -1
  180. package/templates/skills/comms/config.json +18 -0
  181. package/templates/skills/comms/content.md +74 -0
  182. package/templates/skills/comms/files/references/ste-rules.md +73 -0
  183. package/templates/skills/comms/files/references/visual-shapes.md +112 -0
  184. package/templates/skills/swarm-scripts/SKILL.md +3 -2
  185. package/templates/skills/swarm-scripts/content.md +3 -2
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Regression coverage for the `store-progress` blocked-waiting nudge gate
3
+ * (src/tools/store-progress.ts). Two swarm-review findings on PR #1472:
4
+ *
5
+ * 1. The terminal gate only checked `existingTask.status` (the state BEFORE
6
+ * this call), never the incoming `status` argument. A completing call
7
+ * (`status: "completed"`) whose progress text happened to match the
8
+ * blocked-waiting pattern (e.g. "awaiting review") still set
9
+ * `blockedWaitingElapsedMs`, nudging the agent to `defer-task` on the very
10
+ * turn it finished.
11
+ * 2. `blockedWaitingElapsedMs` had no floor, so two check-ins seconds apart
12
+ * could fire "under a minute since your last update — call defer-task",
13
+ * which buys nothing over just checking in again shortly.
14
+ */
15
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import { unlink } from "node:fs/promises";
17
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
+ import { closeDb, createAgent, createTaskExtended, getDbClient, initDb, startTask } from "../be/db";
19
+ import { registerStoreProgressTool } from "../tools/store-progress";
20
+
21
+ const TEST_DB_PATH = "./test-store-progress-blocked-waiting-gate.sqlite";
22
+
23
+ type RegisteredTool = {
24
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
25
+ };
26
+
27
+ type StoreProgressResult = {
28
+ structuredContent: {
29
+ success: boolean;
30
+ blockedWaitingElapsedMs?: number;
31
+ };
32
+ };
33
+
34
+ function buildServer() {
35
+ const server = new McpServer({
36
+ name: "store-progress-blocked-waiting-gate-test",
37
+ version: "1.0.0",
38
+ });
39
+ registerStoreProgressTool(server);
40
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
41
+ ._registeredTools;
42
+ const tool = registered["store-progress"];
43
+ if (!tool) throw new Error("store-progress tool not registered");
44
+ return tool;
45
+ }
46
+
47
+ describe("store-progress handler — blocked-waiting nudge gate", () => {
48
+ let agentId: string;
49
+
50
+ beforeAll(async () => {
51
+ for (const suffix of ["", "-wal", "-shm"]) {
52
+ try {
53
+ await unlink(`${TEST_DB_PATH}${suffix}`);
54
+ } catch {}
55
+ }
56
+ initDb(TEST_DB_PATH);
57
+ const agent = await createAgent({
58
+ name: "Blocked Waiting Gate Worker",
59
+ description: "Agent for blocked-waiting gate regression tests",
60
+ role: "worker",
61
+ isLead: false,
62
+ status: "busy",
63
+ maxTasks: 1,
64
+ capabilities: [],
65
+ });
66
+ agentId = agent.id;
67
+ });
68
+
69
+ afterAll(async () => {
70
+ closeDb();
71
+ for (const suffix of ["", "-wal", "-shm"]) {
72
+ try {
73
+ await unlink(`${TEST_DB_PATH}${suffix}`);
74
+ } catch {}
75
+ }
76
+ });
77
+
78
+ function buildMeta() {
79
+ return { requestInfo: { headers: { "x-agent-id": agentId } } };
80
+ }
81
+
82
+ async function ageLastUpdate(taskId: string, msAgo: number) {
83
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
84
+ new Date(Date.now() - msAgo).toISOString(),
85
+ taskId,
86
+ ]);
87
+ }
88
+
89
+ test("a completing call with blocked-waiting-shaped text does not nudge", async () => {
90
+ const task = await createTaskExtended("completing call with awaiting text", {
91
+ agentId,
92
+ source: "mcp",
93
+ priority: 50,
94
+ });
95
+ await startTask(task.id);
96
+ // Old enough that, absent the fix, this would clear the elapsed-time floor.
97
+ await ageLastUpdate(task.id, 10 * 60_000);
98
+
99
+ const tool = buildServer();
100
+ const result = (await tool.handler(
101
+ {
102
+ taskId: task.id,
103
+ status: "completed",
104
+ output: "done",
105
+ progress: "awaiting review",
106
+ },
107
+ buildMeta(),
108
+ )) as StoreProgressResult;
109
+
110
+ expect(result.structuredContent.success).toBe(true);
111
+ expect(result.structuredContent.blockedWaitingElapsedMs).toBeUndefined();
112
+ });
113
+
114
+ test("elapsed time below the 3-minute floor does not nudge", async () => {
115
+ const task = await createTaskExtended("progress call under the floor", {
116
+ agentId,
117
+ source: "mcp",
118
+ priority: 50,
119
+ });
120
+ await startTask(task.id);
121
+ await ageLastUpdate(task.id, 30_000);
122
+
123
+ const tool = buildServer();
124
+ const result = (await tool.handler(
125
+ { taskId: task.id, progress: "still waiting on the API team" },
126
+ buildMeta(),
127
+ )) as StoreProgressResult;
128
+
129
+ expect(result.structuredContent.success).toBe(true);
130
+ expect(result.structuredContent.blockedWaitingElapsedMs).toBeUndefined();
131
+ });
132
+
133
+ test("a non-terminal call past the floor with matching text still nudges (baseline)", async () => {
134
+ const task = await createTaskExtended("progress call past the floor", {
135
+ agentId,
136
+ source: "mcp",
137
+ priority: 50,
138
+ });
139
+ await startTask(task.id);
140
+ await ageLastUpdate(task.id, 5 * 60_000);
141
+
142
+ const tool = buildServer();
143
+ const result = (await tool.handler(
144
+ { taskId: task.id, progress: "still waiting on the API team" },
145
+ buildMeta(),
146
+ )) as StoreProgressResult;
147
+
148
+ expect(result.structuredContent.success).toBe(true);
149
+ expect(result.structuredContent.blockedWaitingElapsedMs).toBeGreaterThanOrEqual(3 * 60_000);
150
+ });
151
+ });
@@ -317,6 +317,30 @@ describe("finalizeSwarmToolResult", () => {
317
317
  expect((withoutHint.structuredContent as { nudge?: string }).nudge).toBeUndefined();
318
318
  });
319
319
 
320
+ test("NUDGES map: store-progress steers to defer-task only when blocked-waiting was detected", async () => {
321
+ const blocked = await finalizeSwarmToolResult("store-progress", {
322
+ ok: true,
323
+ message: 'Progress stored for task "t-1".',
324
+ data: { task: { id: "t-1", status: "in_progress" }, blockedWaitingElapsedMs: 5 * 60_000 },
325
+ });
326
+ const nudge = (blocked.structuredContent as { nudge?: string }).nudge;
327
+ expect(nudge).toContain("defer-task");
328
+ expect(nudge).toContain("5m");
329
+
330
+ const ordinary = await finalizeSwarmToolResult("store-progress", {
331
+ ok: true,
332
+ message: 'Progress stored for task "t-1".',
333
+ data: { task: { id: "t-1", status: "in_progress" } },
334
+ });
335
+ expect((ordinary.structuredContent as { nudge?: string }).nudge).toBeUndefined();
336
+
337
+ const failed = await finalizeSwarmToolResult("store-progress", {
338
+ ok: false,
339
+ message: "Task not found.",
340
+ });
341
+ expect((failed.structuredContent as { nudge?: string }).nudge).toBeUndefined();
342
+ });
343
+
320
344
  test("data with no details auto-renders into the text channel (completeness guarantee)", async () => {
321
345
  const result = await finalizeSwarmToolResult("some-tool", {
322
346
  ok: true,
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateConfigValue } from "../be/swarm-config-guard";
3
+ import { parseTaskToolManifest, selectTaskTools } from "../utils/task-tool-manifest";
4
+
5
+ const scheduleId = "12345678-1234-4234-8234-123456789abc";
6
+
7
+ describe("task tool manifests", () => {
8
+ test("matches exact keys and lets an empty schedule opt out of its type", () => {
9
+ const manifest = parseTaskToolManifest(
10
+ JSON.stringify({
11
+ taskTypes: { review: ["script-run", "script-run", "get-tasks"] },
12
+ schedules: { [scheduleId]: [] },
13
+ }),
14
+ );
15
+ expect(selectTaskTools(manifest, { taskType: "review" })).toEqual(["script-run", "get-tasks"]);
16
+ expect(selectTaskTools(manifest, { taskType: "review", scheduleId })).toEqual([]);
17
+ expect(selectTaskTools(manifest, { taskType: "review-follow-up" })).toEqual([]);
18
+ expect(selectTaskTools(manifest, { taskType: "toString" })).toEqual([]);
19
+ });
20
+
21
+ test("Slack tools only preload for a task carrying Slack context", () => {
22
+ const manifest = parseTaskToolManifest('{"taskTypes":{"review":["slack-read","script-run"]}}');
23
+ expect(selectTaskTools(manifest, { taskType: "review" })).toEqual(["script-run"]);
24
+ expect(selectTaskTools(manifest, { taskType: "review", slackChannelId: "C123" })).toEqual([
25
+ "slack-read",
26
+ "script-run",
27
+ ]);
28
+ });
29
+
30
+ test("config writes reject unknown tools, invalid shapes, and oversized selections", () => {
31
+ for (const value of [
32
+ "not json",
33
+ "null",
34
+ "[]",
35
+ '{"taskTypes":{"review":["not-a-tool"]}}',
36
+ '{"taskTypes":{"review":"script-run"}}',
37
+ '{"schedules":{"not-a-uuid":["script-run"]}}',
38
+ '{"taskType":{"review":["script-run"]}}',
39
+ JSON.stringify({ taskTypes: { review: Array(17).fill("script-run") } }),
40
+ ]) {
41
+ expect(validateConfigValue("TASK_TOOL_MANIFESTS", value)).toContain("Invalid");
42
+ }
43
+ expect(validateConfigValue("TASK_TOOL_MANIFESTS", "{}")).toBeNull();
44
+ expect(validateConfigValue("TASK_TOOL_PRELOAD_ENABLED", "false")).toBeNull();
45
+ expect(validateConfigValue("TASK_TOOL_PRELOAD_ENABLED", "treu")).toContain("Invalid");
46
+ });
47
+ });
@@ -0,0 +1,125 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { rm } from "node:fs/promises";
3
+ import { createServer as createHttpServer } from "node:http";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { closeDb, createAgent, createTaskExtended, initDb, upsertSwarmConfig } from "../be/db";
8
+ import { handleMcp } from "../http/mcp";
9
+ import { listenOnFreePort } from "./test-net";
10
+
11
+ const dbPath = "./test-task-tool-preload.sqlite";
12
+ const transports: Record<string, StreamableHTTPServerTransport> = {};
13
+ const agents: Record<string, string> = {};
14
+ const activity: Record<string, number> = {};
15
+ const clients: Client[] = [];
16
+ const server = createHttpServer(async (req, res) => {
17
+ if (await handleMcp(req, res, transports, activity, agents)) return;
18
+ res.writeHead(404).end();
19
+ });
20
+ let url: URL;
21
+ let agentId: string;
22
+ const savedEnv = {
23
+ TASK_TOOL_PRELOAD_ENABLED: process.env.TASK_TOOL_PRELOAD_ENABLED,
24
+ TASK_TOOL_MANIFESTS: process.env.TASK_TOOL_MANIFESTS,
25
+ SCRIPTS_ONLY_MCP: process.env.SCRIPTS_ONLY_MCP,
26
+ };
27
+
28
+ beforeAll(async () => {
29
+ delete process.env.TASK_TOOL_PRELOAD_ENABLED;
30
+ delete process.env.TASK_TOOL_MANIFESTS;
31
+ delete process.env.SCRIPTS_ONLY_MCP;
32
+ initDb(dbPath);
33
+ agentId = (await createAgent({ name: "manifest-worker", isLead: false, status: "idle" })).id;
34
+ url = new URL(`http://127.0.0.1:${await listenOnFreePort(server, "127.0.0.1")}/mcp`);
35
+ });
36
+
37
+ afterAll(async () => {
38
+ await Promise.all(clients.map((client) => client.close()));
39
+ await Promise.all(Object.values(transports).map((transport) => transport.close()));
40
+ await new Promise<void>((resolve) => server.close(() => resolve()));
41
+ closeDb();
42
+ for (const suffix of ["", "-wal", "-shm"]) await rm(dbPath + suffix, { force: true });
43
+ for (const [key, value] of Object.entries(savedEnv)) {
44
+ if (value === undefined) delete process.env[key];
45
+ else process.env[key] = value;
46
+ }
47
+ });
48
+
49
+ async function connect(taskId?: string): Promise<Client> {
50
+ const client = new Client({ name: "manifest-test", version: "1" });
51
+ clients.push(client);
52
+ await client.connect(
53
+ new StreamableHTTPClientTransport(url, {
54
+ requestInit: {
55
+ headers: {
56
+ "X-Agent-ID": agentId,
57
+ ...(taskId ? { "X-Source-Task-Id": taskId } : {}),
58
+ },
59
+ },
60
+ }),
61
+ );
62
+ return client;
63
+ }
64
+
65
+ async function configure(key: string, value: string) {
66
+ await upsertSwarmConfig({ scope: "agent", scopeId: agentId, key, value });
67
+ }
68
+
69
+ async function pins(client: Client): Promise<string[]> {
70
+ return (await client.listTools()).tools
71
+ .filter((tool) => tool._meta?.["anthropic/alwaysLoad"] === true)
72
+ .map((tool) => tool.name)
73
+ .sort();
74
+ }
75
+
76
+ test("wire response is default-off, per-task, additive, and fixed for one MCP session", async () => {
77
+ const task = await createTaskExtended("review fixture", { agentId, taskType: "review" });
78
+ const other = await createTaskExtended("other fixture", { agentId, taskType: "other" });
79
+ const foreign = await createTaskExtended("unowned", { taskType: "review" });
80
+ await configure("TASK_TOOL_MANIFESTS", '{"taskTypes":{"review":["script-run","get-tasks"]}}');
81
+ const off = await connect(task.id);
82
+ const baseline = (await off.listTools()).tools;
83
+ expect(baseline.some((tool) => tool.name === "script-run")).toBe(true);
84
+ expect(await pins(off)).toEqual([]);
85
+ expect(off.getInstructions()).toBeUndefined();
86
+
87
+ await configure("TASK_TOOL_PRELOAD_ENABLED", "true");
88
+ const [on, unmatched, unowned, missing] = await Promise.all([
89
+ connect(task.id),
90
+ connect(other.id),
91
+ connect(foreign.id),
92
+ connect(),
93
+ ]);
94
+ expect(await pins(on)).toEqual(["get-tasks", "script-run"]);
95
+ expect(on.getInstructions()).toContain("call it directly");
96
+ expect(await pins(unmatched)).toEqual([]);
97
+ expect(await pins(unowned)).toEqual([]);
98
+ expect(await pins(missing)).toEqual([]);
99
+ expect(await pins(off)).toEqual([]);
100
+ expect((await on.listTools()).tools.map((tool) => tool.name)).toEqual(
101
+ baseline.map((tool) => tool.name),
102
+ );
103
+ const result = await on.callTool({ name: "get-tasks", arguments: { mineOnly: true } });
104
+ expect(result.isError).toBe(false);
105
+
106
+ // Explicit per-agent off wins even when the deployment default is on.
107
+ process.env.TASK_TOOL_PRELOAD_ENABLED = "true";
108
+ await configure("TASK_TOOL_PRELOAD_ENABLED", "false");
109
+ expect(await pins(await connect(task.id))).toEqual([]);
110
+ expect(await pins(on)).toEqual(["get-tasks", "script-run"]);
111
+
112
+ await configure("TASK_TOOL_PRELOAD_ENABLED", "true");
113
+ await configure("TASK_TOOL_MANIFESTS", "invalid deployment JSON");
114
+ expect(await pins(await connect(task.id))).toEqual([]);
115
+ });
116
+
117
+ test("a manifest cannot expand the scripts-only capability surface", async () => {
118
+ const task = await createTaskExtended("script fixture", { agentId, taskType: "script" });
119
+ await configure("TASK_TOOL_PRELOAD_ENABLED", "true");
120
+ await configure("TASK_TOOL_MANIFESTS", '{"taskTypes":{"script":["script-run","get-tasks"]}}');
121
+ await configure("SCRIPTS_ONLY_MCP", "true");
122
+ const client = await connect(task.id);
123
+ expect(await pins(client)).toEqual(["script-run"]);
124
+ expect((await client.listTools()).tools.some((tool) => tool.name === "get-tasks")).toBe(false);
125
+ });
@@ -85,6 +85,7 @@ describe("task tool ctx", () => {
85
85
  const result = await sendTaskHandler(ownerCtx({ agentId: sender.id }), {
86
86
  task: "ordinary direct assignment",
87
87
  agentId: lead.id,
88
+ routingReason: "skill",
88
89
  offerMode: false,
89
90
  allowDuplicate: false,
90
91
  });
@@ -106,6 +107,7 @@ describe("task tool ctx", () => {
106
107
  const result = await sendTaskHandler(ownerCtx({ agentId: sender.id }), {
107
108
  task: "ordinary offer",
108
109
  agentId: lead.id,
110
+ routingReason: "skill",
109
111
  offerMode: true,
110
112
  allowDuplicate: false,
111
113
  });
@@ -164,6 +164,7 @@ describe("agent-id fields accept non-UUID agent ids", () => {
164
164
  test("send-task input accepts a slug agentId target", () => {
165
165
  const parsed = sendTaskInputSchema.parse({
166
166
  agentId: SLUG_WORKER_ID,
167
+ routingReason: "skill",
167
168
  task: "do the thing",
168
169
  });
169
170
  expect(parsed.agentId).toBe(SLUG_WORKER_ID);
@@ -165,7 +165,12 @@ describe("session-UI follow-up → Lead → onward delegation", () => {
165
165
  // provenance, not a requirement.
166
166
  const delegated = await callSendTask(
167
167
  mcpServer,
168
- { task: "please pick this up", agentId: differentWorkerId, allowDuplicate: true },
168
+ {
169
+ task: "please pick this up",
170
+ agentId: differentWorkerId,
171
+ routingReason: "human_pinned",
172
+ allowDuplicate: true,
173
+ },
169
174
  leadAgentId,
170
175
  followUp.body.id,
171
176
  );
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import {
4
4
  closeDb,
5
+ createAgent,
5
6
  createWorkflow,
6
7
  createWorkflowRun,
7
8
  createWorkflowRunStep,
@@ -9,7 +10,7 @@ import {
9
10
  initDb,
10
11
  } from "../be/db";
11
12
  import type { ExecutorMeta } from "../types";
12
- import { AgentTaskExecutor } from "../workflows/executors/agent-task";
13
+ import { AgentTaskConfigSchema, AgentTaskExecutor } from "../workflows/executors/agent-task";
13
14
  import type { ExecutorDependencies } from "../workflows/executors/base";
14
15
 
15
16
  const TEST_DB_PATH = "./test-workflow-agent-task.sqlite";
@@ -94,6 +95,20 @@ afterAll(async () => {
94
95
  // ─── Tests ───────────────────────────────────────────────────
95
96
 
96
97
  describe("AgentTaskExecutor — workspace scoping", () => {
98
+ test("existing configured agent pins accept optional routing metadata", () => {
99
+ expect(AgentTaskConfigSchema.safeParse({ template: "work", agentId: "worker" }).success).toBe(
100
+ true,
101
+ );
102
+ expect(
103
+ AgentTaskConfigSchema.safeParse({
104
+ template: "work",
105
+ agentId: "worker",
106
+ routingReason: "human_pinned",
107
+ routingNote: "workflow author chose this worker",
108
+ }).success,
109
+ ).toBe(true);
110
+ });
111
+
97
112
  test("config schema accepts dir, vcsRepo, model, modelTier, parentTaskId", () => {
98
113
  const executor = new AgentTaskExecutor(mockDeps);
99
114
  const config = {
@@ -146,7 +161,13 @@ describe("AgentTaskExecutor — workspace scoping", () => {
146
161
 
147
162
  test("execute() creates task with workspace fields forwarded", async () => {
148
163
  const executor = new AgentTaskExecutor(mockDeps);
164
+ const worker = await createAgent({
165
+ name: "Pinned workflow worker",
166
+ isLead: false,
167
+ status: "idle",
168
+ });
149
169
  const config = {
170
+ agentId: worker.id,
150
171
  template: "List files in workspace",
151
172
  dir: "/workspace/repos/agent-swarm",
152
173
  vcsRepo: "desplega-ai/agent-swarm",
@@ -173,6 +194,8 @@ describe("AgentTaskExecutor — workspace scoping", () => {
173
194
  expect(task!.model).toBeUndefined();
174
195
  expect(task!.modelTier).toBe("regular");
175
196
  expect(task!.source).toBe("workflow");
197
+ expect(task!.agentId).toBe(worker.id);
198
+ expect(task!.routingReason).toBe("human_pinned");
176
199
  });
177
200
 
178
201
  test("execute() creates task without workspace fields (backward compat)", async () => {
@@ -1,6 +1,17 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ setSystemTime,
9
+ spyOn,
10
+ test,
11
+ } from "bun:test";
2
12
  import { unlink } from "node:fs/promises";
3
13
  import { z } from "zod";
14
+ import * as preflightAlerts from "../automation-preflight-alert";
4
15
  import {
5
16
  closeDb,
6
17
  createWorkflow,
@@ -264,6 +275,21 @@ describe("Workflow Engine v2 (Phase 3)", () => {
264
275
  // ─── Linear Workflow ──────────────────────────────────────
265
276
 
266
277
  describe("Automation preflight", () => {
278
+ let notify: ReturnType<
279
+ typeof spyOn<typeof preflightAlerts, "notifyAutomationPreflightFailure">
280
+ >;
281
+
282
+ beforeEach(() => {
283
+ notify = spyOn(preflightAlerts, "notifyAutomationPreflightFailure").mockResolvedValue(
284
+ undefined,
285
+ );
286
+ });
287
+
288
+ afterEach(() => {
289
+ notify.mockRestore();
290
+ setSystemTime();
291
+ });
292
+
267
293
  test("records one failed run per workflow/day despite reason changes and creates no tasks", async () => {
268
294
  const registry = createTestRegistry();
269
295
  const workflow = await makeWorkflow(
@@ -285,7 +311,13 @@ describe("Workflow Engine v2 (Phase 3)", () => {
285
311
  finishedAt: new Date().toISOString(),
286
312
  });
287
313
 
288
- const firstRunId = await startWorkflowExecution(workflow, {}, registry);
314
+ const firstRunId = await startWorkflowExecution(workflow, {}, registry, {
315
+ triggerType: "schedule",
316
+ });
317
+ expect(notify).toHaveBeenCalledTimes(1);
318
+ expect(notify).toHaveBeenCalledWith(
319
+ expect.objectContaining({ kind: "workflow", name: workflow.name, state: "needs_setup" }),
320
+ );
289
321
  const secondRunId = await startWorkflowExecution(
290
322
  { ...workflow, requiredParams: ["OTHER_PARAM"] },
291
323
  {},
@@ -297,6 +329,7 @@ describe("Workflow Engine v2 (Phase 3)", () => {
297
329
  );
298
330
 
299
331
  expect(secondRunId).toBe(firstRunId);
332
+ expect(notify).toHaveBeenCalledTimes(1);
300
333
  expect(firstRunId).not.toBe(ordinaryRunId);
301
334
  expect(run?.status).toBe("failed");
302
335
  expect(run?.error).toBe("needs_setup: params=[REPO_URL] integrations=[]");
@@ -312,6 +345,29 @@ describe("Workflow Engine v2 (Phase 3)", () => {
312
345
  ).toEqual({ total: 2, preflight: 1 });
313
346
  });
314
347
 
348
+ test("alerts once for concurrent workflow refusals and again after UTC midnight", async () => {
349
+ setSystemTime(new Date("2026-09-14T23:59:59Z"));
350
+ const registry = createTestRegistry();
351
+ const workflow = await makeWorkflow(
352
+ { nodes: [{ id: "start", type: "echo", config: { message: "{{REPO_URL}}" } }] },
353
+ { requiredParams: ["REPO_URL"] },
354
+ );
355
+ const [firstRunId, repeatedRunId] = await Promise.all([
356
+ startWorkflowExecution(workflow, {}, registry, { triggerType: "schedule" }),
357
+ startWorkflowExecution(workflow, {}, registry, { triggerType: "schedule" }),
358
+ ]);
359
+ expect(repeatedRunId).toBe(firstRunId);
360
+ expect(notify).toHaveBeenCalledTimes(1);
361
+
362
+ setSystemTime(new Date("2026-09-15T00:00:00Z"));
363
+ const nextRunId = await startWorkflowExecution(workflow, {}, registry, {
364
+ triggerType: "schedule",
365
+ });
366
+ expect(nextRunId).not.toBe(firstRunId);
367
+ expect(notify).toHaveBeenCalledTimes(2);
368
+ expect((await getWorkflowRun(nextRunId))?.error).toContain("needs_setup:");
369
+ });
370
+
315
371
  test("renders parameters across workflow runtime fields and preserves input value types", async () => {
316
372
  const registry = createTestRegistry();
317
373
  const workflow = await makeWorkflow(
@@ -16,7 +16,6 @@ export const acceptSteerInputSchema = z.object({
16
16
  steeringMessageId: z.uuid().describe("The steering message ID to acknowledge."),
17
17
  note: z
18
18
  .string()
19
- .max(500)
20
19
  .optional()
21
20
  .describe("Optional short note describing how the steering was incorporated."),
22
21
  });