@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.
- package/README.md +13 -1
- package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
- package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
- package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
- package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
- package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
- package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
- package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
- package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
- package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
- package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
- package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
- package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
- package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
- package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
- package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
- package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
- package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
- package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
- package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
- package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
- package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
- package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
- package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
- package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
- package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
- package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
- package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
- package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
- package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
- package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
- package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
- package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
- package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
- package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
- package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
- package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
- package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
- package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
- package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
- package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
- package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
- package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
- package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
- package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
- package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
- package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
- package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
- package/dist/cli.js +16 -14
- package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
- package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
- package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
- package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
- package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
- package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
- package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
- package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
- package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
- package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
- package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
- package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
- package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
- package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
- package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
- package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
- package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
- package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
- package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
- package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
- package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
- package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
- package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
- package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
- package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
- package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
- package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
- package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
- package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
- package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
- package/openapi.json +46 -4
- package/package.json +3 -1
- package/src/agentmail/handlers.ts +5 -0
- package/src/automation-preflight-alert.ts +41 -0
- package/src/be/automation-preflight.ts +3 -3
- package/src/be/budget-refusal-notify.ts +1 -0
- package/src/be/db/tasks/read.ts +5 -0
- package/src/be/db.ts +132 -2
- package/src/be/migrations/150_deferred_task_waits.sql +18 -0
- package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
- package/src/be/migrations/152_routing_decisions.sql +44 -0
- package/src/be/scripts/typecheck.ts +33 -22
- package/src/be/seed-scripts/catalog/delegate.ts +5 -1
- package/src/be/seed-skills/bundled-files.generated.json +10 -0
- package/src/be/seed-skills/index.ts +3 -0
- package/src/be/steering.ts +1 -0
- package/src/be/swarm-config-guard.ts +11 -0
- package/src/commands/onboard/steps/post-task.tsx +1 -0
- package/src/github/handlers.ts +9 -0
- package/src/gitlab/handlers.ts +4 -0
- package/src/heartbeat/heartbeat.ts +8 -0
- package/src/http/approval-requests.ts +1 -0
- package/src/http/apps.ts +1 -0
- package/src/http/config.ts +15 -6
- package/src/http/mcp.ts +31 -4
- package/src/http/script-runs.ts +6 -0
- package/src/http/tasks.ts +52 -30
- package/src/integrations/kapso/inbound.ts +1 -0
- package/src/jira/sync.ts +2 -0
- package/src/linear/sync.ts +2 -0
- package/src/prompts/session-templates.ts +29 -15
- package/src/scheduler/deferred-task-waits.ts +123 -0
- package/src/scheduler/schedule-task.ts +42 -0
- package/src/scheduler/scheduler.ts +22 -31
- package/src/script-workflows/workflow-ctx.ts +2 -0
- package/src/scripts-runtime/types/stdlib.d.ts +38 -24
- package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
- package/src/server.ts +10 -1
- package/src/slack/actions.ts +1 -0
- package/src/slack/assistant.ts +2 -0
- package/src/slack/handlers.ts +3 -0
- package/src/slack/thread-buffer.ts +2 -0
- package/src/tasks/worker-follow-up.ts +9 -0
- package/src/tests/acp-adapter.test.ts +35 -6
- package/src/tests/acp-session-token.test.ts +67 -0
- package/src/tests/asset-key-api.test.ts +15 -2
- package/src/tests/asset-key-mcp.test.ts +2 -0
- package/src/tests/automation-preflight-alert.test.ts +91 -0
- package/src/tests/claude-worker-parity.test.ts +3 -0
- package/src/tests/config-session-auth.test.ts +143 -0
- package/src/tests/defer-task.test.ts +117 -6
- package/src/tests/deferred-task-wake.test.ts +456 -0
- package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
- package/src/tests/http-api-integration.test.ts +33 -4
- package/src/tests/mcp-input-ergonomics.test.ts +386 -0
- package/src/tests/model-control.test.ts +18 -5
- package/src/tests/opencode-adapter.test.ts +2 -2
- package/src/tests/promote-draft-task-route.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +13 -4
- package/src/tests/rbac-wire-e2e.test.ts +2 -2
- package/src/tests/routing-decision-persistence.test.ts +520 -0
- package/src/tests/routing-reason-contract.test.ts +89 -0
- package/src/tests/routing-reason-inventory.test.ts +55 -0
- package/src/tests/schedule-target-type.test.ts +48 -1
- package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
- package/src/tests/scheduled-tasks.test.ts +9 -5
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/script-runs-http.test.ts +31 -0
- package/src/tests/scripts-typecheck.test.ts +11 -0
- package/src/tests/secret-scrubber.test.ts +12 -0
- package/src/tests/send-task-output-schema.test.ts +66 -1
- package/src/tests/send-task-requested-by.test.ts +1 -0
- package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
- package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
- package/src/tests/swarm-tool-result-gate.test.ts +24 -0
- package/src/tests/task-tool-manifest.test.ts +47 -0
- package/src/tests/task-tool-preload-mcp.test.ts +125 -0
- package/src/tests/task-tools-ctx.test.ts +2 -0
- package/src/tests/tool-output-agent-id.test.ts +1 -0
- package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
- package/src/tests/workflow-agent-task.test.ts +24 -1
- package/src/tests/workflow-engine-v2.test.ts +58 -2
- package/src/tools/accept-steer.ts +0 -1
- package/src/tools/defer-task.ts +73 -21
- package/src/tools/memory-get.ts +10 -3
- package/src/tools/memory-rate.ts +19 -7
- package/src/tools/memory-search.ts +2 -1
- package/src/tools/memory-store.ts +25 -5
- package/src/tools/send-task.ts +40 -2
- package/src/tools/skills/skill-publish.ts +1 -0
- package/src/tools/store-progress.ts +78 -4
- package/src/tools/templates.ts +2 -1
- package/src/tools/utils.ts +33 -2
- package/src/types.ts +14 -1
- package/src/utils/acp-session-token.ts +14 -4
- package/src/utils/secret-scrubber.ts +2 -0
- package/src/utils/task-tool-manifest.ts +33 -0
- package/src/workflows/engine.ts +4 -1
- package/src/workflows/executors/agent-task.ts +7 -1
- package/templates/ai-toolbox.manifest.json +6 -1
- package/templates/skills/comms/config.json +18 -0
- package/templates/skills/comms/content.md +74 -0
- package/templates/skills/comms/files/references/ste-rules.md +73 -0
- package/templates/skills/comms/files/references/visual-shapes.md +112 -0
- package/templates/skills/swarm-scripts/SKILL.md +3 -2
- 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
|
-
{
|
|
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 {
|
|
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
|
});
|