@desplega.ai/agent-swarm 1.87.0 → 1.89.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 +5 -1
- package/openapi.json +53 -1
- package/package.json +6 -5
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +374 -9
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3825 -2417
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +179 -0
- package/src/cli.tsx +51 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +154 -22
- package/src/commands/x.ts +118 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +13 -1
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/skills.ts +53 -0
- package/src/http/tasks.ts +4 -1
- package/src/http/webhooks.ts +75 -0
- package/src/http/workflows.ts +5 -1
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +24 -1
- package/src/prompts/session-templates.ts +74 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +72 -7
- package/src/server.ts +10 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +76 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +116 -1
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +185 -30
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +122 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +121 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { ensureRepoForTask } from "../commands/runner";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
let tempRoot = "";
|
|
12
|
+
|
|
13
|
+
async function git(cwd: string, args: string[]): Promise<string> {
|
|
14
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args]);
|
|
15
|
+
return stdout;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function gitRaw(args: string[]): Promise<void> {
|
|
19
|
+
await execFileAsync("git", args);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function commitAll(cwd: string, message: string): Promise<void> {
|
|
23
|
+
await git(cwd, ["add", "."]);
|
|
24
|
+
await git(cwd, ["commit", "-m", message]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function configureIdentity(cwd: string): Promise<void> {
|
|
28
|
+
await git(cwd, ["config", "user.email", "test@example.com"]);
|
|
29
|
+
await git(cwd, ["config", "user.name", "Test User"]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("ensureRepoForTask auto-stash refresh", () => {
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
tempRoot = await mkdtemp(join(tmpdir(), "swarm-runner-autostash-"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
if (tempRoot) {
|
|
39
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("stashes dirty work with a swarm-autostash name before refreshing from origin", async () => {
|
|
44
|
+
const remotePath = join(tempRoot, "remote.git");
|
|
45
|
+
const upstreamPath = join(tempRoot, "upstream");
|
|
46
|
+
const clonePath = join(tempRoot, "clone");
|
|
47
|
+
|
|
48
|
+
await gitRaw(["init", "--bare", remotePath]);
|
|
49
|
+
await mkdir(upstreamPath);
|
|
50
|
+
await git(upstreamPath, ["init", "-b", "main"]);
|
|
51
|
+
await configureIdentity(upstreamPath);
|
|
52
|
+
await writeFile(join(upstreamPath, "README.md"), "initial\n");
|
|
53
|
+
await commitAll(upstreamPath, "initial commit");
|
|
54
|
+
await git(upstreamPath, ["remote", "add", "origin", remotePath]);
|
|
55
|
+
await git(upstreamPath, ["push", "-u", "origin", "main"]);
|
|
56
|
+
|
|
57
|
+
await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
|
|
58
|
+
await configureIdentity(clonePath);
|
|
59
|
+
await writeFile(join(clonePath, "README.md"), "local dirty change\n");
|
|
60
|
+
await writeFile(join(clonePath, "untracked.txt"), "local untracked\n");
|
|
61
|
+
|
|
62
|
+
await writeFile(join(upstreamPath, "remote.txt"), "remote change\n");
|
|
63
|
+
await commitAll(upstreamPath, "remote update");
|
|
64
|
+
await git(upstreamPath, ["push", "origin", "main"]);
|
|
65
|
+
|
|
66
|
+
const result = await ensureRepoForTask(
|
|
67
|
+
{ url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
|
|
68
|
+
"test",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(result.warning).toBeNull();
|
|
72
|
+
expect(result.autoStashes).toHaveLength(1);
|
|
73
|
+
expect(result.autoStashes[0]?.ref).toMatch(/^stash@\{\d+\}$/);
|
|
74
|
+
expect(result.autoStashes[0]?.message).toContain("swarm-autostash main ");
|
|
75
|
+
expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote change\n");
|
|
76
|
+
expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
|
|
77
|
+
|
|
78
|
+
const stashList = await git(clonePath, ["stash", "list"]);
|
|
79
|
+
expect(stashList).toContain("swarm-autostash main ");
|
|
80
|
+
expect(stashList).toContain("On main:");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("merges a clean divergent checkout with origin without hard reset", async () => {
|
|
84
|
+
const remotePath = join(tempRoot, "remote.git");
|
|
85
|
+
const upstreamPath = join(tempRoot, "upstream");
|
|
86
|
+
const clonePath = join(tempRoot, "clone");
|
|
87
|
+
|
|
88
|
+
await gitRaw(["init", "--bare", remotePath]);
|
|
89
|
+
await mkdir(upstreamPath);
|
|
90
|
+
await git(upstreamPath, ["init", "-b", "main"]);
|
|
91
|
+
await configureIdentity(upstreamPath);
|
|
92
|
+
await writeFile(join(upstreamPath, "README.md"), "initial\n");
|
|
93
|
+
await commitAll(upstreamPath, "initial commit");
|
|
94
|
+
await git(upstreamPath, ["remote", "add", "origin", remotePath]);
|
|
95
|
+
await git(upstreamPath, ["push", "-u", "origin", "main"]);
|
|
96
|
+
|
|
97
|
+
await gitRaw(["clone", "--branch", "main", remotePath, clonePath]);
|
|
98
|
+
await configureIdentity(clonePath);
|
|
99
|
+
await writeFile(join(clonePath, "local.txt"), "local commit\n");
|
|
100
|
+
await commitAll(clonePath, "local commit");
|
|
101
|
+
|
|
102
|
+
await writeFile(join(upstreamPath, "remote.txt"), "remote commit\n");
|
|
103
|
+
await commitAll(upstreamPath, "remote commit");
|
|
104
|
+
await git(upstreamPath, ["push", "origin", "main"]);
|
|
105
|
+
|
|
106
|
+
const result = await ensureRepoForTask(
|
|
107
|
+
{ url: remotePath, name: "repo", clonePath, defaultBranch: "main" },
|
|
108
|
+
"test",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(result.warning).toBeNull();
|
|
112
|
+
expect(result.autoStashes).toEqual([]);
|
|
113
|
+
expect(await readFile(join(clonePath, "local.txt"), "utf8")).toBe("local commit\n");
|
|
114
|
+
expect(await readFile(join(clonePath, "remote.txt"), "utf8")).toBe("remote commit\n");
|
|
115
|
+
expect((await git(clonePath, ["status", "--porcelain"])).trim()).toBe("");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildRequesterProfilePrompt } from "../commands/runner";
|
|
3
|
+
|
|
4
|
+
describe("runner requester profile prompt", () => {
|
|
5
|
+
test("omits requester profile when no role or notes are set", async () => {
|
|
6
|
+
await expect(
|
|
7
|
+
buildRequesterProfilePrompt({ name: "Taras", email: "t@example.com" }),
|
|
8
|
+
).resolves.toBe("");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("formats requester role and free-text notes", async () => {
|
|
12
|
+
const prompt = await buildRequesterProfilePrompt({
|
|
13
|
+
name: "Taras",
|
|
14
|
+
email: "t@example.com",
|
|
15
|
+
role: "CEO",
|
|
16
|
+
notes: "Lead with the answer; keep updates terse.",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(prompt).toContain("## Requester Profile");
|
|
20
|
+
expect(prompt).toContain("This task was requested by Taras (CEO).");
|
|
21
|
+
expect(prompt).toContain("Their stated notes for how you should respond and act:");
|
|
22
|
+
expect(prompt).toContain("Lead with the answer; keep updates terse.");
|
|
23
|
+
expect(prompt).toContain("where it doesn't conflict with correctness or your operating rules");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
initDb,
|
|
11
11
|
} from "../be/db";
|
|
12
12
|
import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
|
|
13
|
+
import {
|
|
14
|
+
isAutomaticOrRecurringTaskCompletion,
|
|
15
|
+
isScheduledTaskCompletion,
|
|
16
|
+
shouldPersistTaskCompletionMemory,
|
|
17
|
+
} from "../memory/automatic-task-gate";
|
|
13
18
|
import { getBasePrompt } from "../prompts/base-prompt";
|
|
14
19
|
|
|
15
20
|
const TEST_DB_PATH = "./test-self-improvement.sqlite";
|
|
@@ -143,6 +148,90 @@ describe("Self-Improvement Mechanisms", () => {
|
|
|
143
148
|
expect(shortContent.length).toBeLessThan(30);
|
|
144
149
|
// In store-progress, this would return early without creating memory
|
|
145
150
|
});
|
|
151
|
+
|
|
152
|
+
test("manual task completions persist memory by default", () => {
|
|
153
|
+
const task = createTaskExtended("Manual implementation task", {
|
|
154
|
+
agentId: workerId,
|
|
155
|
+
source: "mcp",
|
|
156
|
+
priority: 50,
|
|
157
|
+
tags: ["memory", "bug-fix"],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(isScheduledTaskCompletion(task)).toBe(false);
|
|
161
|
+
expect(shouldPersistTaskCompletionMemory(task)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("scheduled task completions skip memory by default", () => {
|
|
165
|
+
const task = createTaskExtended("Run heartbeat checklist", {
|
|
166
|
+
agentId: workerId,
|
|
167
|
+
source: "schedule",
|
|
168
|
+
priority: 50,
|
|
169
|
+
tags: ["scheduled", "schedule:heartbeat-checklist"],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(isScheduledTaskCompletion(task)).toBe(true);
|
|
173
|
+
expect(isAutomaticOrRecurringTaskCompletion(task)).toBe(true);
|
|
174
|
+
expect(shouldPersistTaskCompletionMemory(task)).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("heartbeat checklist completions skip memory without schedule tags", () => {
|
|
178
|
+
const task = createTaskExtended("Run heartbeat checklist", {
|
|
179
|
+
agentId: workerId,
|
|
180
|
+
source: "mcp",
|
|
181
|
+
priority: 60,
|
|
182
|
+
taskType: "heartbeat-checklist",
|
|
183
|
+
tags: ["checklist", "auto-generated"],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(isScheduledTaskCompletion(task)).toBe(false);
|
|
187
|
+
expect(isAutomaticOrRecurringTaskCompletion(task)).toBe(true);
|
|
188
|
+
expect(shouldPersistTaskCompletionMemory(task)).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("boot triage completions skip memory by default", () => {
|
|
192
|
+
const task = createTaskExtended("Triage reboot-interrupted work", {
|
|
193
|
+
agentId: workerId,
|
|
194
|
+
source: "mcp",
|
|
195
|
+
priority: 80,
|
|
196
|
+
taskType: "boot-triage",
|
|
197
|
+
tags: ["boot", "triage", "auto-generated"],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(isAutomaticOrRecurringTaskCompletion(task)).toBe(true);
|
|
201
|
+
expect(shouldPersistTaskCompletionMemory(task)).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("monitor and digest completions skip memory by default", () => {
|
|
205
|
+
const monitorTask = createTaskExtended("Check Claude Code changelog", {
|
|
206
|
+
agentId: workerId,
|
|
207
|
+
source: "schedule",
|
|
208
|
+
priority: 50,
|
|
209
|
+
taskType: "monitoring",
|
|
210
|
+
tags: ["health", "schedule:claude-code-changelog-monitor"],
|
|
211
|
+
});
|
|
212
|
+
const digestTask = createTaskExtended("Compile daily blocker digest", {
|
|
213
|
+
agentId: workerId,
|
|
214
|
+
source: "schedule",
|
|
215
|
+
priority: 50,
|
|
216
|
+
tags: ["scheduled", "schedule:daily-blocker-digest"],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(isAutomaticOrRecurringTaskCompletion(monitorTask)).toBe(true);
|
|
220
|
+
expect(shouldPersistTaskCompletionMemory(monitorTask)).toBe(false);
|
|
221
|
+
expect(isAutomaticOrRecurringTaskCompletion(digestTask)).toBe(true);
|
|
222
|
+
expect(shouldPersistTaskCompletionMemory(digestTask)).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("scheduled task completions can opt in to memory persistence", () => {
|
|
226
|
+
const task = createTaskExtended("Daily digest found reusable incident pattern", {
|
|
227
|
+
agentId: workerId,
|
|
228
|
+
source: "schedule",
|
|
229
|
+
priority: 50,
|
|
230
|
+
tags: ["scheduled", "schedule:daily-blocker-digest"],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(shouldPersistTaskCompletionMemory(task, true)).toBe(true);
|
|
234
|
+
});
|
|
146
235
|
});
|
|
147
236
|
|
|
148
237
|
// ==========================================================================
|
|
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { closeDb, createAgent, createSkill, getSkillById, initDb } from "../be/db";
|
|
5
|
+
import { registerSkillDeleteTool } from "../tools/skills/skill-delete";
|
|
5
6
|
import { registerSkillUpdateTool } from "../tools/skills/skill-update";
|
|
6
7
|
|
|
7
8
|
const TEST_DB_PATH = "./test-skill-update-scope.sqlite";
|
|
@@ -39,7 +40,30 @@ async function callSkillUpdate(
|
|
|
39
40
|
return result as { structuredContent: StructuredContent };
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
async function callSkillDelete(
|
|
44
|
+
server: McpServer,
|
|
45
|
+
callerAgentId: string | undefined,
|
|
46
|
+
args: Record<string, unknown>,
|
|
47
|
+
): Promise<{ structuredContent: StructuredContent }> {
|
|
48
|
+
// biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
|
|
49
|
+
const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
|
|
50
|
+
._registeredTools;
|
|
51
|
+
const handler = tools["skill-delete"].handler;
|
|
52
|
+
|
|
53
|
+
const extra = {
|
|
54
|
+
sessionId: "test-session",
|
|
55
|
+
requestInfo: {
|
|
56
|
+
headers: {
|
|
57
|
+
"x-agent-id": callerAgentId ?? "",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const result = await handler(args, extra);
|
|
63
|
+
return result as { structuredContent: StructuredContent };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("skill mutation tools", () => {
|
|
43
67
|
let server: McpServer;
|
|
44
68
|
|
|
45
69
|
beforeAll(async () => {
|
|
@@ -59,6 +83,7 @@ describe("skill-update scope promotion", () => {
|
|
|
59
83
|
|
|
60
84
|
server = new McpServer({ name: "test-skill-update-scope", version: "1.0.0" });
|
|
61
85
|
registerSkillUpdateTool(server);
|
|
86
|
+
registerSkillDeleteTool(server);
|
|
62
87
|
});
|
|
63
88
|
|
|
64
89
|
afterAll(async () => {
|
|
@@ -162,4 +187,66 @@ describe("skill-update scope promotion", () => {
|
|
|
162
187
|
expect(stored?.scope).toBe("agent");
|
|
163
188
|
expect(stored?.isEnabled).toBe(false);
|
|
164
189
|
});
|
|
190
|
+
|
|
191
|
+
test("system-default skill content updates are rejected", async () => {
|
|
192
|
+
const skill = createSkill({
|
|
193
|
+
name: "system-content-locked",
|
|
194
|
+
description: "System content lock",
|
|
195
|
+
content: "---\nname: system-content-locked\ndescription: System content lock\n---\n\nBody.",
|
|
196
|
+
type: "personal",
|
|
197
|
+
scope: "swarm",
|
|
198
|
+
ownerAgentId: WORKER_ID,
|
|
199
|
+
systemDefault: true,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await callSkillUpdate(server, LEAD_ID, {
|
|
203
|
+
skillId: skill.id,
|
|
204
|
+
content: "---\nname: system-content-locked\ndescription: Changed\n---\n\nChanged.",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.structuredContent.success).toBe(false);
|
|
208
|
+
expect(result.structuredContent.message).toContain("system-managed");
|
|
209
|
+
|
|
210
|
+
const stored = getSkillById(skill.id);
|
|
211
|
+
expect(stored?.description).toBe("System content lock");
|
|
212
|
+
expect(stored?.version).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("system-default skill enable toggle remains allowed", async () => {
|
|
216
|
+
const skill = createSkill({
|
|
217
|
+
name: "system-toggle-allowed",
|
|
218
|
+
description: "System toggle",
|
|
219
|
+
content: "---\nname: system-toggle-allowed\ndescription: System toggle\n---\n\nBody.",
|
|
220
|
+
type: "personal",
|
|
221
|
+
scope: "swarm",
|
|
222
|
+
ownerAgentId: WORKER_ID,
|
|
223
|
+
systemDefault: true,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const result = await callSkillUpdate(server, LEAD_ID, {
|
|
227
|
+
skillId: skill.id,
|
|
228
|
+
isEnabled: false,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result.structuredContent.success).toBe(true);
|
|
232
|
+
expect(getSkillById(skill.id)?.isEnabled).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("system-default skill deletes are rejected", async () => {
|
|
236
|
+
const skill = createSkill({
|
|
237
|
+
name: "system-delete-locked",
|
|
238
|
+
description: "System delete lock",
|
|
239
|
+
content: "---\nname: system-delete-locked\ndescription: System delete lock\n---\n\nBody.",
|
|
240
|
+
type: "personal",
|
|
241
|
+
scope: "swarm",
|
|
242
|
+
ownerAgentId: WORKER_ID,
|
|
243
|
+
systemDefault: true,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const result = await callSkillDelete(server, LEAD_ID, { skillId: skill.id });
|
|
247
|
+
|
|
248
|
+
expect(result.structuredContent.success).toBe(false);
|
|
249
|
+
expect(result.structuredContent.message).toContain("system-managed");
|
|
250
|
+
expect(getSkillById(skill.id)).not.toBeNull();
|
|
251
|
+
});
|
|
165
252
|
});
|
|
@@ -821,6 +821,21 @@ describe("buildTreeBlocks", () => {
|
|
|
821
821
|
expect(blocks.length).toBe(2);
|
|
822
822
|
});
|
|
823
823
|
|
|
824
|
+
test("offered root shows offered icon without undefined text", () => {
|
|
825
|
+
const root: TreeNode = {
|
|
826
|
+
taskId: makeTaskId("loff0001"),
|
|
827
|
+
agentName: "OfferedAgent",
|
|
828
|
+
status: "offered",
|
|
829
|
+
children: [],
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const blocks = buildTreeBlocks([root]);
|
|
833
|
+
const text = blocks[0].text.text;
|
|
834
|
+
|
|
835
|
+
expect(text).toContain("📨 *OfferedAgent*");
|
|
836
|
+
expect(text).not.toContain("undefined");
|
|
837
|
+
});
|
|
838
|
+
|
|
824
839
|
test("cancelled root shows cancel icon with no cancel button", () => {
|
|
825
840
|
const root: TreeNode = {
|
|
826
841
|
taskId: makeTaskId("mmmm0001"),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { registerSwarmXTool } from "../tools/swarm-x";
|
|
4
|
+
import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
|
|
5
|
+
|
|
6
|
+
type RegisteredTool = {
|
|
7
|
+
handler: (args: unknown, extra: unknown) => Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
const originalComposioKey = process.env.COMPOSIO_API_KEY;
|
|
12
|
+
|
|
13
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildTool() {
|
|
21
|
+
const server = new McpServer({ name: "swarm-x-test", version: "1.0.0" });
|
|
22
|
+
registerSwarmXTool(server);
|
|
23
|
+
const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
24
|
+
._registeredTools;
|
|
25
|
+
const tool = registered.swarm_x;
|
|
26
|
+
if (!tool) throw new Error("swarm_x tool not registered");
|
|
27
|
+
return tool;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
globalThis.fetch = originalFetch;
|
|
32
|
+
if (originalComposioKey === undefined) delete process.env.COMPOSIO_API_KEY;
|
|
33
|
+
else process.env.COMPOSIO_API_KEY = originalComposioKey;
|
|
34
|
+
clearVolatileSecretsForTesting();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("swarm_x MCP tool", () => {
|
|
38
|
+
test("routes composio requests with server-side auth and scrubbed output", async () => {
|
|
39
|
+
process.env.COMPOSIO_API_KEY = "ck_tool_secret_value";
|
|
40
|
+
const fetchMock = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
41
|
+
expect(String(url)).toBe("https://backend.composio.dev/api/v3.1/tools?limit=1");
|
|
42
|
+
expect(init?.method).toBe("GET");
|
|
43
|
+
expect((init?.headers as Record<string, string>)["x-api-key"]).toBe("ck_tool_secret_value");
|
|
44
|
+
return jsonResponse({ ok: true, token: "ck_tool_secret_value" });
|
|
45
|
+
});
|
|
46
|
+
globalThis.fetch = fetchMock;
|
|
47
|
+
|
|
48
|
+
const tool = buildTool();
|
|
49
|
+
const result = (await tool.handler(
|
|
50
|
+
{
|
|
51
|
+
target: "composio",
|
|
52
|
+
method: "GET",
|
|
53
|
+
path: "/tools",
|
|
54
|
+
query: { limit: 1 },
|
|
55
|
+
},
|
|
56
|
+
{ sessionId: "s", requestInfo: { headers: {} } },
|
|
57
|
+
)) as {
|
|
58
|
+
isError?: boolean;
|
|
59
|
+
structuredContent: { ok: boolean; response: unknown; responseText: string };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
63
|
+
expect(result.isError).toBe(false);
|
|
64
|
+
expect(result.structuredContent.ok).toBe(true);
|
|
65
|
+
expect(JSON.stringify(result.structuredContent.response)).toContain(
|
|
66
|
+
"[REDACTED:COMPOSIO_API_KEY]",
|
|
67
|
+
);
|
|
68
|
+
expect(result.structuredContent.responseText).not.toContain("ck_tool_secret_value");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("rejects absolute composio paths", async () => {
|
|
72
|
+
process.env.COMPOSIO_API_KEY = "ck_tool_secret_value";
|
|
73
|
+
const fetchMock = mock(async () => jsonResponse({ ok: true }));
|
|
74
|
+
globalThis.fetch = fetchMock;
|
|
75
|
+
|
|
76
|
+
const tool = buildTool();
|
|
77
|
+
const result = (await tool.handler(
|
|
78
|
+
{
|
|
79
|
+
target: "composio",
|
|
80
|
+
method: "GET",
|
|
81
|
+
path: "https://evil.example/tools",
|
|
82
|
+
},
|
|
83
|
+
{ sessionId: "s", requestInfo: { headers: {} } },
|
|
84
|
+
)) as { isError?: boolean; structuredContent: { message: string } };
|
|
85
|
+
|
|
86
|
+
expect(result.isError).toBe(true);
|
|
87
|
+
expect(result.structuredContent.message).toContain("endpoint must be a Composio API path");
|
|
88
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createSkill,
|
|
7
|
+
getAgentSkills,
|
|
8
|
+
getDb,
|
|
9
|
+
getSystemDefaultSkills,
|
|
10
|
+
initDb,
|
|
11
|
+
toggleAgentSkill,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { runSeeder } from "../be/seed";
|
|
14
|
+
import { loadSeedSkills, skillsSeeder } from "../be/seed-skills";
|
|
15
|
+
|
|
16
|
+
const TEST_DB_PATH = `./test-system-default-skills-${process.pid}.sqlite`;
|
|
17
|
+
|
|
18
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
19
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
20
|
+
await unlink(path + suffix).catch(() => {});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("system-default skills", () => {
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
27
|
+
initDb(TEST_DB_PATH);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
closeDb();
|
|
32
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("seed catalog includes swarm-scripts and marks built-in defaults", async () => {
|
|
36
|
+
const skills = loadSeedSkills();
|
|
37
|
+
const names = skills.map((skill) => skill.name);
|
|
38
|
+
|
|
39
|
+
expect(names).toContain("attio-interaction");
|
|
40
|
+
expect(names).toContain("swarm-scripts");
|
|
41
|
+
expect(skills.find((skill) => skill.name === "attio-interaction")?.systemDefault).toBe(true);
|
|
42
|
+
expect(skills.find((skill) => skill.name === "swarm-scripts")?.systemDefault).toBe(true);
|
|
43
|
+
expect(skills.find((skill) => skill.name === "kv-storage")?.systemDefault).toBe(true);
|
|
44
|
+
expect(skills.find((skill) => skill.name === "pages")?.systemDefault).toBe(true);
|
|
45
|
+
|
|
46
|
+
const result = await runSeeder(skillsSeeder, { quiet: true });
|
|
47
|
+
expect(result.failed).toEqual([]);
|
|
48
|
+
|
|
49
|
+
const defaults = getSystemDefaultSkills().map((skill) => skill.name);
|
|
50
|
+
expect(defaults).toContain("attio-interaction");
|
|
51
|
+
expect(defaults).toContain("swarm-scripts");
|
|
52
|
+
expect(defaults).toContain("kv-storage");
|
|
53
|
+
expect(defaults).toContain("pages");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("existing agents see system-default skills through the self-healing view", () => {
|
|
57
|
+
const existingAgent = createAgent({
|
|
58
|
+
name: "Existing Default Skill Worker",
|
|
59
|
+
description: "Created after seeded defaults",
|
|
60
|
+
role: "worker",
|
|
61
|
+
isLead: false,
|
|
62
|
+
status: "idle",
|
|
63
|
+
maxTasks: 1,
|
|
64
|
+
capabilities: [],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const manualDefault = createSkill({
|
|
68
|
+
name: "manual-system-default",
|
|
69
|
+
description: "Manual default",
|
|
70
|
+
content: "---\nname: manual-system-default\ndescription: Manual default\n---\nBody.",
|
|
71
|
+
type: "personal",
|
|
72
|
+
scope: "swarm",
|
|
73
|
+
systemDefault: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const skills = getAgentSkills(existingAgent.id);
|
|
77
|
+
expect(skills.map((skill) => skill.name)).toContain("manual-system-default");
|
|
78
|
+
expect(skills.find((skill) => skill.id === manualDefault.id)?.isActive).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("new agents get concrete agent_skills rows for system defaults", () => {
|
|
82
|
+
const beforeAgent = createAgent({
|
|
83
|
+
name: "Concrete Install Worker",
|
|
84
|
+
description: "Created with defaults present",
|
|
85
|
+
role: "worker",
|
|
86
|
+
isLead: false,
|
|
87
|
+
status: "idle",
|
|
88
|
+
maxTasks: 1,
|
|
89
|
+
capabilities: [],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const row = getDb()
|
|
93
|
+
.prepare<{ count: number }, [string]>(
|
|
94
|
+
`SELECT COUNT(*) AS count
|
|
95
|
+
FROM agent_skills
|
|
96
|
+
WHERE agentId = ?
|
|
97
|
+
AND skillId IN (SELECT id FROM skills WHERE systemDefault = 1)`,
|
|
98
|
+
)
|
|
99
|
+
.get(beforeAgent.id);
|
|
100
|
+
|
|
101
|
+
expect(row?.count ?? 0).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("system-default skills remain visible even if an install row is toggled inactive", () => {
|
|
105
|
+
const agent = createAgent({
|
|
106
|
+
name: "Inactive Default Worker",
|
|
107
|
+
description: "Tests self-healing union",
|
|
108
|
+
role: "worker",
|
|
109
|
+
isLead: false,
|
|
110
|
+
status: "idle",
|
|
111
|
+
maxTasks: 1,
|
|
112
|
+
capabilities: [],
|
|
113
|
+
});
|
|
114
|
+
const skill = getSystemDefaultSkills().find((entry) => entry.name === "swarm-scripts");
|
|
115
|
+
expect(skill).toBeDefined();
|
|
116
|
+
|
|
117
|
+
toggleAgentSkill(agent.id, skill!.id, false);
|
|
118
|
+
const skills = getAgentSkills(agent.id);
|
|
119
|
+
|
|
120
|
+
expect(skills.find((entry) => entry.id === skill!.id)?.isActive).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|