@desplega.ai/agent-swarm 1.91.0 → 1.92.1
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 +3 -2
- package/openapi.json +1005 -152
- package/package.json +6 -6
- package/plugin/skills/pages/SKILL.md +5 -2
- package/src/be/db.ts +662 -19
- package/src/be/memory/constants.ts +2 -1
- package/src/be/memory/providers/openai-embedding.ts +2 -5
- package/src/be/memory/providers/sqlite-store.ts +293 -76
- package/src/be/memory/types.ts +35 -0
- package/src/be/migrations/083_script_workflows.sql +51 -0
- package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
- package/src/be/migrations/085_script_runs_kind.sql +9 -0
- package/src/be/migrations/086_pages_default_authed.sql +64 -0
- package/src/be/migrations/087_skill_files.sql +19 -0
- package/src/be/modelsdev-cache.json +42310 -38617
- package/src/be/scripts/typecheck.ts +49 -0
- package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
- package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
- package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
- package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
- package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
- package/src/be/seed-scripts/index.ts +51 -5
- package/src/be/seed-skills/index.ts +3 -3
- package/src/be/skill-sync.ts +91 -7
- package/src/be/swarm-config-guard.ts +17 -0
- package/src/commands/runner.ts +49 -4
- package/src/heartbeat/templates.ts +20 -16
- package/src/http/db-query.ts +20 -5
- package/src/http/index.ts +51 -7
- package/src/http/mcp-user.ts +23 -0
- package/src/http/mcp.ts +58 -0
- package/src/http/memory.ts +58 -0
- package/src/http/pages.ts +1 -1
- package/src/http/script-runs.ts +557 -0
- package/src/http/scripts.ts +39 -2
- package/src/http/skills.ts +225 -0
- package/src/prompts/session-templates.ts +24 -4
- package/src/providers/claude-adapter.ts +107 -28
- package/src/script-workflows/executor.ts +110 -0
- package/src/script-workflows/harness.ts +73 -0
- package/src/script-workflows/label-lint.ts +51 -0
- package/src/script-workflows/limits.ts +22 -0
- package/src/script-workflows/supervisor.ts +139 -0
- package/src/script-workflows/workflow-ctx.ts +209 -0
- package/src/scripts-runtime/sdk-allowlist.ts +4 -0
- package/src/scripts-runtime/swarm-sdk.ts +13 -0
- package/src/scripts-runtime/types/stdlib.d.ts +61 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
- package/src/server.ts +4 -0
- package/src/slack/handlers.ts +11 -4
- package/src/slack/message-text.ts +98 -0
- package/src/slack/thread-buffer.ts +5 -3
- package/src/tests/claude-adapter-binary.test.ts +271 -74
- package/src/tests/create-page-tool.test.ts +19 -2
- package/src/tests/db-query.test.ts +28 -0
- package/src/tests/error-tracker.test.ts +121 -0
- package/src/tests/harness-provider-resolution.test.ts +33 -0
- package/src/tests/heartbeat-checklist.test.ts +36 -0
- package/src/tests/mcp-tools.test.ts +6 -0
- package/src/tests/mcp-transport-gc.test.ts +58 -0
- package/src/tests/memory-health-endpoint.test.ts +78 -0
- package/src/tests/memory-store.test.ts +221 -1
- package/src/tests/pages-http.test.ts +20 -2
- package/src/tests/pages-storage.test.ts +26 -0
- package/src/tests/prompt-template-session.test.ts +34 -5
- package/src/tests/script-runs-http.test.ts +278 -0
- package/src/tests/script-workflows-label-lint.test.ts +43 -0
- package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
- package/src/tests/scripts-mcp-e2e.test.ts +102 -2
- package/src/tests/seed-scripts.test.ts +468 -3
- package/src/tests/skill-files-http.test.ts +171 -0
- package/src/tests/skill-files.test.ts +162 -0
- package/src/tests/skill-get-file-tool.test.ts +110 -0
- package/src/tests/skill-sync.test.ts +125 -6
- package/src/tests/slack-message-text.test.ts +250 -0
- package/src/tests/system-default-skills.test.ts +40 -0
- package/src/tools/create-page.ts +2 -2
- package/src/tools/db-query.ts +16 -6
- package/src/tools/script-runs.ts +123 -0
- package/src/tools/skills/index.ts +1 -0
- package/src/tools/skills/skill-get-file.ts +80 -0
- package/src/tools/slack-read.ts +12 -3
- package/src/tools/tool-config.ts +6 -2
- package/src/types.ts +72 -0
- package/src/utils/error-tracker.ts +40 -1
- package/src/utils/internal-ai/complete-structured.ts +10 -4
- package/src/workflows/executors/raw-llm.ts +76 -59
- package/templates/schedules/daily-blocker-digest/content.md +68 -54
- package/templates/schedules/daily-compounding-reflection/content.md +4 -4
- package/templates/schedules/daily-hn-briefing/content.md +5 -5
- package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
- package/templates/schedules/gtm-weekly-review/content.md +9 -9
- package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
- package/templates/skills/agentmail-sending/content.md +6 -7
- package/templates/skills/desloppify/content.md +8 -9
- package/templates/skills/jira-interaction/content.md +25 -33
- package/templates/skills/kapso-whatsapp/content.md +29 -30
- package/templates/skills/linear-interaction/content.md +8 -9
- package/templates/skills/pages/content.md +205 -55
- package/templates/skills/profile-corruption-escalation/content.md +44 -85
- package/templates/skills/script-workflows/config.json +14 -0
- package/templates/skills/script-workflows/content.md +68 -0
- package/templates/skills/sprite-cli/content.md +4 -5
- package/templates/skills/swarm-scripts/content.md +2 -3
- package/templates/skills/turso-interaction/content.md +14 -17
- package/templates/skills/workflow-iterate/content.md +38 -391
- package/templates/skills/x-api-interactions/content.md +4 -6
- package/templates/skills/scheduled-task-resilience/config.json +0 -14
- package/templates/skills/scheduled-task-resilience/content.md +0 -95
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createSkill,
|
|
6
|
+
deleteSkill,
|
|
7
|
+
deleteSkillFile,
|
|
8
|
+
getDb,
|
|
9
|
+
getSkillFile,
|
|
10
|
+
initDb,
|
|
11
|
+
listSkillFileManifest,
|
|
12
|
+
normalizeSkillFilePath,
|
|
13
|
+
upsertSkillFile,
|
|
14
|
+
upsertSkillFiles,
|
|
15
|
+
} from "../be/db";
|
|
16
|
+
|
|
17
|
+
const TEST_DB_PATH = `./test-skill-files-${process.pid}.sqlite`;
|
|
18
|
+
|
|
19
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
20
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
21
|
+
await unlink(path + suffix).catch(() => {});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("skill_files storage", () => {
|
|
26
|
+
let skillId: string;
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
30
|
+
initDb(TEST_DB_PATH);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
getDb().run("DELETE FROM skill_files");
|
|
35
|
+
getDb().run("DELETE FROM skills");
|
|
36
|
+
const skill = createSkill({
|
|
37
|
+
name: `file-skill-${crypto.randomUUID()}`,
|
|
38
|
+
description: "Skill with bundled files",
|
|
39
|
+
content: "---\nname: file-skill\ndescription: Skill with bundled files\n---\n\nBody.",
|
|
40
|
+
type: "personal",
|
|
41
|
+
scope: "agent",
|
|
42
|
+
isComplex: true,
|
|
43
|
+
});
|
|
44
|
+
skillId = skill.id;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
closeDb();
|
|
49
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("migration creates skill_files on a fresh DB", () => {
|
|
53
|
+
const table = getDb()
|
|
54
|
+
.prepare<{ name: string }, []>(
|
|
55
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'skill_files'",
|
|
56
|
+
)
|
|
57
|
+
.get();
|
|
58
|
+
expect(table?.name).toBe("skill_files");
|
|
59
|
+
|
|
60
|
+
const migration = getDb()
|
|
61
|
+
.prepare<{ version: number; name: string }, []>(
|
|
62
|
+
"SELECT version, name FROM _migrations WHERE version = 87",
|
|
63
|
+
)
|
|
64
|
+
.get();
|
|
65
|
+
expect(migration?.name).toBe("087_skill_files");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("re-opening an existing DB keeps skill_files available", () => {
|
|
69
|
+
closeDb();
|
|
70
|
+
initDb(TEST_DB_PATH);
|
|
71
|
+
|
|
72
|
+
const columns = getDb().prepare<{ name: string }, []>("PRAGMA table_info(skill_files)").all();
|
|
73
|
+
expect(columns.map((column) => column.name)).toEqual([
|
|
74
|
+
"id",
|
|
75
|
+
"skillId",
|
|
76
|
+
"path",
|
|
77
|
+
"content",
|
|
78
|
+
"mimeType",
|
|
79
|
+
"isBinary",
|
|
80
|
+
"size",
|
|
81
|
+
"createdAt",
|
|
82
|
+
"lastUpdatedAt",
|
|
83
|
+
"created_by",
|
|
84
|
+
"updated_by",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("upserts, lists manifest without content, fetches, and deletes files", () => {
|
|
89
|
+
const beforeVersion = getDb()
|
|
90
|
+
.prepare<{ version: number }, [string]>("SELECT version FROM skills WHERE id = ?")
|
|
91
|
+
.get(skillId)!.version;
|
|
92
|
+
|
|
93
|
+
const file = upsertSkillFile(skillId, {
|
|
94
|
+
path: "references/guide.md",
|
|
95
|
+
content: "# Guide",
|
|
96
|
+
mimeType: "text/markdown",
|
|
97
|
+
});
|
|
98
|
+
expect(file.path).toBe("references/guide.md");
|
|
99
|
+
expect(file.size).toBe(Buffer.byteLength("# Guide"));
|
|
100
|
+
|
|
101
|
+
const manifest = listSkillFileManifest(skillId);
|
|
102
|
+
expect(manifest).toHaveLength(1);
|
|
103
|
+
expect(manifest[0]).not.toHaveProperty("content");
|
|
104
|
+
|
|
105
|
+
expect(getSkillFile(skillId, "references/guide.md")?.content).toBe("# Guide");
|
|
106
|
+
|
|
107
|
+
const afterVersion = getDb()
|
|
108
|
+
.prepare<{ version: number }, [string]>("SELECT version FROM skills WHERE id = ?")
|
|
109
|
+
.get(skillId)!.version;
|
|
110
|
+
expect(afterVersion).toBeGreaterThan(beforeVersion);
|
|
111
|
+
|
|
112
|
+
expect(deleteSkillFile(skillId, "references/guide.md")).toBe(true);
|
|
113
|
+
expect(getSkillFile(skillId, "references/guide.md")).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("bulk upsert enforces path normalization and stores binary placeholders", () => {
|
|
117
|
+
const files = upsertSkillFiles(skillId, [
|
|
118
|
+
{
|
|
119
|
+
path: "references//nested.md",
|
|
120
|
+
content: "nested",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
path: "assets/logo.png",
|
|
124
|
+
content: "",
|
|
125
|
+
mimeType: "image/png",
|
|
126
|
+
isBinary: true,
|
|
127
|
+
size: 1234,
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
expect(files.map((file) => file.path)).toEqual(["references/nested.md", "assets/logo.png"]);
|
|
132
|
+
const binary = getSkillFile(skillId, "assets/logo.png");
|
|
133
|
+
expect(binary?.isBinary).toBe(true);
|
|
134
|
+
expect(binary?.content).toBe("[binary file - not synced]");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rejects traversal and SKILL.md rows", () => {
|
|
138
|
+
expect(() => normalizeSkillFilePath("../secret.md")).toThrow("traversal");
|
|
139
|
+
expect(() =>
|
|
140
|
+
upsertSkillFile(skillId, {
|
|
141
|
+
path: "SKILL.md",
|
|
142
|
+
content: "nope",
|
|
143
|
+
}),
|
|
144
|
+
).toThrow("SKILL.md");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("deleting a skill cascades bundled files", () => {
|
|
148
|
+
const skill = createSkill({
|
|
149
|
+
name: "cascade-file-skill",
|
|
150
|
+
description: "Cascade test",
|
|
151
|
+
content: "---\nname: cascade-file-skill\ndescription: Cascade test\n---\n\nBody.",
|
|
152
|
+
type: "personal",
|
|
153
|
+
scope: "agent",
|
|
154
|
+
isComplex: true,
|
|
155
|
+
});
|
|
156
|
+
upsertSkillFile(skill.id, { path: "references/a.md", content: "a" });
|
|
157
|
+
|
|
158
|
+
expect(listSkillFileManifest(skill.id)).toHaveLength(1);
|
|
159
|
+
expect(deleteSkill(skill.id)).toBe(true);
|
|
160
|
+
expect(listSkillFileManifest(skill.id)).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { closeDb, createSkill, initDb, upsertSkillFile } from "../be/db";
|
|
5
|
+
import { registerSkillGetFileTool } from "../tools/skills/skill-get-file";
|
|
6
|
+
|
|
7
|
+
const TEST_DB_PATH = `./test-skill-get-file-tool-${process.pid}.sqlite`;
|
|
8
|
+
const CALLER_AGENT_ID = "bbbb0000-0000-4000-8000-000000000020";
|
|
9
|
+
|
|
10
|
+
type StructuredContent = {
|
|
11
|
+
yourAgentId?: string;
|
|
12
|
+
success: boolean;
|
|
13
|
+
message: string;
|
|
14
|
+
file?: { skillId: string; path: string; content: string };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
18
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
19
|
+
await unlink(path + suffix).catch(() => {});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function callSkillGetFile(
|
|
24
|
+
server: McpServer,
|
|
25
|
+
args: Record<string, unknown>,
|
|
26
|
+
): Promise<{
|
|
27
|
+
structuredContent: StructuredContent;
|
|
28
|
+
content: Array<{ type: string; text: string }>;
|
|
29
|
+
}> {
|
|
30
|
+
// biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
|
|
31
|
+
const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
|
|
32
|
+
._registeredTools;
|
|
33
|
+
const handler = tools["skill-get-file"].handler;
|
|
34
|
+
|
|
35
|
+
const result = await handler(args, {
|
|
36
|
+
sessionId: "test-session",
|
|
37
|
+
requestInfo: {
|
|
38
|
+
headers: {
|
|
39
|
+
"x-agent-id": CALLER_AGENT_ID,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
return result as {
|
|
44
|
+
structuredContent: StructuredContent;
|
|
45
|
+
content: Array<{ type: string; text: string }>;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("skill-get-file tool", () => {
|
|
50
|
+
let server: McpServer;
|
|
51
|
+
let skillId: string;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
55
|
+
initDb(TEST_DB_PATH);
|
|
56
|
+
|
|
57
|
+
server = new McpServer({ name: "skill-get-file-test", version: "1.0.0" });
|
|
58
|
+
registerSkillGetFileTool(server);
|
|
59
|
+
|
|
60
|
+
const skill = createSkill({
|
|
61
|
+
name: "tool-file-skill",
|
|
62
|
+
description: "Tool file skill",
|
|
63
|
+
content: "---\nname: tool-file-skill\ndescription: Tool file skill\n---\n\nBody.",
|
|
64
|
+
type: "personal",
|
|
65
|
+
scope: "agent",
|
|
66
|
+
isComplex: true,
|
|
67
|
+
});
|
|
68
|
+
skillId = skill.id;
|
|
69
|
+
upsertSkillFile(skill.id, {
|
|
70
|
+
path: "references/guide.md",
|
|
71
|
+
content: "# Guide",
|
|
72
|
+
mimeType: "text/markdown",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
closeDb();
|
|
78
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("fetches a bundled skill file by skillId and path", async () => {
|
|
82
|
+
const result = await callSkillGetFile(server, {
|
|
83
|
+
skillId,
|
|
84
|
+
path: "references/guide.md",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result.structuredContent).toMatchObject({
|
|
88
|
+
yourAgentId: CALLER_AGENT_ID,
|
|
89
|
+
success: true,
|
|
90
|
+
file: {
|
|
91
|
+
skillId,
|
|
92
|
+
path: "references/guide.md",
|
|
93
|
+
content: "# Guide",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(result.content[0].text).toContain("# Guide");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns structured failure for missing file", async () => {
|
|
100
|
+
const result = await callSkillGetFile(server, {
|
|
101
|
+
skillId,
|
|
102
|
+
path: "references/missing.md",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.structuredContent).toMatchObject({
|
|
106
|
+
success: false,
|
|
107
|
+
message: "Skill file not found.",
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -2,8 +2,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { closeDb, createAgent, createSkill, initDb, installSkill } from "../be/db";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { closeDb, createAgent, createSkill, initDb, installSkill, upsertSkillFile } from "../be/db";
|
|
7
7
|
import { syncSkillsToFilesystem } from "../be/skill-sync";
|
|
8
8
|
|
|
9
9
|
const SWARM_MARKER = ".swarm-managed";
|
|
@@ -38,7 +38,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
38
38
|
});
|
|
39
39
|
installSkill(agentId, skill.id);
|
|
40
40
|
|
|
41
|
-
// Create a complex skill (should be skipped)
|
|
41
|
+
// Create a legacy complex skill with no stored files (should be skipped)
|
|
42
42
|
const complexSkill = createSkill({
|
|
43
43
|
name: "complex-skill",
|
|
44
44
|
description: "A complex skill",
|
|
@@ -49,6 +49,28 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
49
49
|
});
|
|
50
50
|
installSkill(agentId, complexSkill.id);
|
|
51
51
|
|
|
52
|
+
const dbBackedComplexSkill = createSkill({
|
|
53
|
+
name: "complex-db-skill",
|
|
54
|
+
description: "A DB-backed complex skill",
|
|
55
|
+
content: "---\nname: complex-db-skill\ndescription: A DB-backed complex skill\n---\n\nBody.",
|
|
56
|
+
type: "remote",
|
|
57
|
+
scope: "global",
|
|
58
|
+
isComplex: true,
|
|
59
|
+
});
|
|
60
|
+
installSkill(agentId, dbBackedComplexSkill.id);
|
|
61
|
+
upsertSkillFile(dbBackedComplexSkill.id, {
|
|
62
|
+
path: "references/guide.md",
|
|
63
|
+
content: "# Guide\n\nBundled reference.",
|
|
64
|
+
mimeType: "text/markdown",
|
|
65
|
+
});
|
|
66
|
+
upsertSkillFile(dbBackedComplexSkill.id, {
|
|
67
|
+
path: "assets/logo.png",
|
|
68
|
+
content: "[binary file - not synced]",
|
|
69
|
+
mimeType: "image/png",
|
|
70
|
+
isBinary: true,
|
|
71
|
+
size: 2048,
|
|
72
|
+
});
|
|
73
|
+
|
|
52
74
|
mkdirSync(FAKE_HOME, { recursive: true });
|
|
53
75
|
});
|
|
54
76
|
|
|
@@ -108,7 +130,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
108
130
|
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
109
131
|
|
|
110
132
|
expect(result.errors).toHaveLength(0);
|
|
111
|
-
expect(result.synced).toBe(
|
|
133
|
+
expect(result.synced).toBe(6); // 2 DB-backed skills × 3 dirs
|
|
112
134
|
|
|
113
135
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
114
136
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
@@ -118,13 +140,110 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
118
140
|
expect(existsSync(codexFile)).toBe(true);
|
|
119
141
|
});
|
|
120
142
|
|
|
121
|
-
test("
|
|
143
|
+
test("syncs DB-backed complex skill files and skips binary placeholders", () => {
|
|
144
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
145
|
+
|
|
146
|
+
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
147
|
+
|
|
148
|
+
expect(result.errors).toHaveLength(0);
|
|
149
|
+
|
|
150
|
+
const skillFile = join(FAKE_HOME, ".claude", "skills", "complex-db-skill", "SKILL.md");
|
|
151
|
+
const bundledFile = join(
|
|
152
|
+
FAKE_HOME,
|
|
153
|
+
".claude",
|
|
154
|
+
"skills",
|
|
155
|
+
"complex-db-skill",
|
|
156
|
+
"references",
|
|
157
|
+
"guide.md",
|
|
158
|
+
);
|
|
159
|
+
const binaryFile = join(
|
|
160
|
+
FAKE_HOME,
|
|
161
|
+
".claude",
|
|
162
|
+
"skills",
|
|
163
|
+
"complex-db-skill",
|
|
164
|
+
"assets",
|
|
165
|
+
"logo.png",
|
|
166
|
+
);
|
|
167
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
168
|
+
expect(readFileSync(bundledFile, "utf-8")).toContain("Bundled reference.");
|
|
169
|
+
expect(existsSync(binaryFile)).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("removes stale bundled files from swarm-managed skill directories", () => {
|
|
173
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
174
|
+
|
|
175
|
+
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
176
|
+
expect(result.errors).toHaveLength(0);
|
|
177
|
+
|
|
178
|
+
const skillDir = join(FAKE_HOME, ".claude", "skills", "complex-db-skill");
|
|
179
|
+
const staleFile = join(skillDir, "references", "old-guide.md");
|
|
180
|
+
const currentFile = join(skillDir, "references", "guide.md");
|
|
181
|
+
const staleBinary = join(skillDir, "assets", "logo.png");
|
|
182
|
+
mkdirSync(dirname(staleFile), { recursive: true });
|
|
183
|
+
mkdirSync(dirname(staleBinary), { recursive: true });
|
|
184
|
+
writeFileSync(staleFile, "stale");
|
|
185
|
+
writeFileSync(staleBinary, "previous binary payload");
|
|
186
|
+
|
|
187
|
+
const nextResult = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
188
|
+
|
|
189
|
+
expect(nextResult.errors).toHaveLength(0);
|
|
190
|
+
expect(nextResult.removed).toBeGreaterThanOrEqual(2);
|
|
191
|
+
expect(existsSync(staleFile)).toBe(false);
|
|
192
|
+
expect(existsSync(staleBinary)).toBe(false);
|
|
193
|
+
expect(readFileSync(currentFile, "utf-8")).toContain("Bundled reference.");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("skips legacy complex skills without stored files", () => {
|
|
122
197
|
const _result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
123
198
|
|
|
124
199
|
const complexDir = join(FAKE_HOME, ".claude", "skills", "complex-skill");
|
|
125
200
|
expect(existsSync(complexDir)).toBe(false);
|
|
126
201
|
});
|
|
127
202
|
|
|
203
|
+
test("continues syncing bundled files after one file write fails", () => {
|
|
204
|
+
const failSkill = createSkill({
|
|
205
|
+
name: "complex-fail-safe",
|
|
206
|
+
description: "Complex skill with one blocked file",
|
|
207
|
+
content:
|
|
208
|
+
"---\nname: complex-fail-safe\ndescription: Complex skill with one blocked file\n---\n\nBody.",
|
|
209
|
+
type: "remote",
|
|
210
|
+
scope: "global",
|
|
211
|
+
isComplex: true,
|
|
212
|
+
});
|
|
213
|
+
installSkill(agentId, failSkill.id);
|
|
214
|
+
upsertSkillFile(failSkill.id, {
|
|
215
|
+
path: "references/blocked.md",
|
|
216
|
+
content: "blocked",
|
|
217
|
+
});
|
|
218
|
+
upsertSkillFile(failSkill.id, {
|
|
219
|
+
path: "references/ok.md",
|
|
220
|
+
content: "ok",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
224
|
+
const blockedTarget = join(
|
|
225
|
+
FAKE_HOME,
|
|
226
|
+
".claude",
|
|
227
|
+
"skills",
|
|
228
|
+
"complex-fail-safe",
|
|
229
|
+
"references",
|
|
230
|
+
"blocked.md",
|
|
231
|
+
);
|
|
232
|
+
mkdirSync(blockedTarget, { recursive: true });
|
|
233
|
+
|
|
234
|
+
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
235
|
+
|
|
236
|
+
expect(result.errors.some((error) => error.includes("references/blocked.md"))).toBe(true);
|
|
237
|
+
expect(
|
|
238
|
+
readFileSync(
|
|
239
|
+
join(FAKE_HOME, ".claude", "skills", "complex-fail-safe", "references", "ok.md"),
|
|
240
|
+
"utf-8",
|
|
241
|
+
),
|
|
242
|
+
).toBe("ok");
|
|
243
|
+
|
|
244
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
245
|
+
});
|
|
246
|
+
|
|
128
247
|
test("removes stale swarm-managed skill directories", () => {
|
|
129
248
|
// Mark this stale dir as swarm-managed (mirrors what an earlier sync would have done)
|
|
130
249
|
const staleDir = join(FAKE_HOME, ".claude", "skills", "old-removed-skill");
|
|
@@ -185,7 +304,7 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
185
304
|
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
186
305
|
|
|
187
306
|
expect(result.errors).toHaveLength(0);
|
|
188
|
-
expect(result.synced).
|
|
307
|
+
expect(result.synced).toBeGreaterThanOrEqual(6);
|
|
189
308
|
|
|
190
309
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
191
310
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { extractSlackMessageText } from "../slack/message-text";
|
|
3
|
+
|
|
4
|
+
describe("extractSlackMessageText", () => {
|
|
5
|
+
test("returns top-level text when present", () => {
|
|
6
|
+
expect(extractSlackMessageText({ text: "hello world" })).toBe("hello world");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("returns empty string when all fields are absent", () => {
|
|
10
|
+
expect(extractSlackMessageText({})).toBe("");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns empty string when text is empty and no attachments/blocks", () => {
|
|
14
|
+
expect(extractSlackMessageText({ text: "" })).toBe("");
|
|
15
|
+
expect(extractSlackMessageText({ text: " " })).toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("legacy attachments fallback (Datadog / PagerDuty / GitHub alert shape)", () => {
|
|
19
|
+
test("uses fallback when text is empty", () => {
|
|
20
|
+
const msg = {
|
|
21
|
+
text: "",
|
|
22
|
+
attachments: [{ fallback: "Triggered: [P3] A Bull job process-inscribe-webhook failed" }],
|
|
23
|
+
};
|
|
24
|
+
expect(extractSlackMessageText(msg)).toBe(
|
|
25
|
+
"Triggered: [P3] A Bull job process-inscribe-webhook failed",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("uses attachment text when fallback is absent", () => {
|
|
30
|
+
const msg = {
|
|
31
|
+
text: "",
|
|
32
|
+
attachments: [{ text: "Job failed in queue document_fraud_check" }],
|
|
33
|
+
};
|
|
34
|
+
expect(extractSlackMessageText(msg)).toBe("Job failed in queue document_fraud_check");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("uses attachment title as tertiary fallback", () => {
|
|
38
|
+
const msg = { text: "", attachments: [{ title: "Alert Title" }] };
|
|
39
|
+
expect(extractSlackMessageText(msg)).toBe("Alert Title");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("uses pretext as last attachment fallback", () => {
|
|
43
|
+
const msg = { text: "", attachments: [{ pretext: "Some pretext" }] };
|
|
44
|
+
expect(extractSlackMessageText(msg)).toBe("Some pretext");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("joins multiple attachment texts with newline", () => {
|
|
48
|
+
const msg = {
|
|
49
|
+
text: "",
|
|
50
|
+
attachments: [{ fallback: "Alert 1" }, { fallback: "Alert 2" }],
|
|
51
|
+
};
|
|
52
|
+
expect(extractSlackMessageText(msg)).toBe("Alert 1\nAlert 2");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("skips empty attachments, uses non-empty ones", () => {
|
|
56
|
+
const msg = {
|
|
57
|
+
text: "",
|
|
58
|
+
attachments: [{}, { fallback: "real content" }, {}],
|
|
59
|
+
};
|
|
60
|
+
expect(extractSlackMessageText(msg)).toBe("real content");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("top-level text wins over attachments", () => {
|
|
64
|
+
const msg = { text: "top text", attachments: [{ fallback: "ignored" }] };
|
|
65
|
+
expect(extractSlackMessageText(msg)).toBe("top text");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("Block Kit blocks fallback", () => {
|
|
70
|
+
test("extracts text from a section block", () => {
|
|
71
|
+
const msg = {
|
|
72
|
+
text: "",
|
|
73
|
+
blocks: [
|
|
74
|
+
{ type: "section", text: { type: "mrkdwn", text: "*Alert*: CPU spike detected" } },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
expect(extractSlackMessageText(msg)).toBe("*Alert*: CPU spike detected");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("extracts text from section.fields when section has no top-level text", () => {
|
|
81
|
+
const msg = {
|
|
82
|
+
text: "",
|
|
83
|
+
blocks: [
|
|
84
|
+
{
|
|
85
|
+
type: "section",
|
|
86
|
+
fields: [
|
|
87
|
+
{ type: "mrkdwn", text: "*Status:* resolved" },
|
|
88
|
+
{ type: "mrkdwn", text: "*Priority:* P2" },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
expect(extractSlackMessageText(msg)).toBe("*Status:* resolved\n*Priority:* P2");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("extracts text from rich_text block elements", () => {
|
|
97
|
+
const msg = {
|
|
98
|
+
text: "",
|
|
99
|
+
blocks: [
|
|
100
|
+
{
|
|
101
|
+
type: "rich_text",
|
|
102
|
+
elements: [
|
|
103
|
+
{
|
|
104
|
+
type: "rich_text_section",
|
|
105
|
+
elements: [{ type: "text", text: "Rich text content" }],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
expect(extractSlackMessageText(msg)).toBe("Rich text content");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("extracts text from rich_text_list -> rich_text_section -> text (nested list)", () => {
|
|
115
|
+
const msg = {
|
|
116
|
+
text: "",
|
|
117
|
+
blocks: [
|
|
118
|
+
{
|
|
119
|
+
type: "rich_text",
|
|
120
|
+
elements: [
|
|
121
|
+
{
|
|
122
|
+
type: "rich_text_list",
|
|
123
|
+
style: "bullet",
|
|
124
|
+
elements: [
|
|
125
|
+
{
|
|
126
|
+
type: "rich_text_section",
|
|
127
|
+
elements: [{ type: "text", text: "item one" }],
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
type: "rich_text_section",
|
|
131
|
+
elements: [{ type: "text", text: "item two" }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
expect(extractSlackMessageText(msg)).toBe("item one\nitem two");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("joins multiple section blocks with newline", () => {
|
|
143
|
+
const msg = {
|
|
144
|
+
text: "",
|
|
145
|
+
blocks: [
|
|
146
|
+
{ type: "section", text: { type: "plain_text", text: "Line 1" } },
|
|
147
|
+
{ type: "section", text: { type: "plain_text", text: "Line 2" } },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
expect(extractSlackMessageText(msg)).toBe("Line 1\nLine 2");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("attachments take priority over blocks", () => {
|
|
154
|
+
const msg = {
|
|
155
|
+
text: "",
|
|
156
|
+
attachments: [{ fallback: "from attachment" }],
|
|
157
|
+
blocks: [{ type: "section", text: { type: "plain_text", text: "from block" } }],
|
|
158
|
+
};
|
|
159
|
+
expect(extractSlackMessageText(msg)).toBe("from attachment");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("returns empty string when blocks have no extractable text", () => {
|
|
163
|
+
const msg = {
|
|
164
|
+
text: "",
|
|
165
|
+
blocks: [{ type: "divider" }, { type: "image" }],
|
|
166
|
+
};
|
|
167
|
+
expect(extractSlackMessageText(msg)).toBe("");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("malformed input — never throws", () => {
|
|
172
|
+
test("blocks: [null] — skips null entry, returns empty string", () => {
|
|
173
|
+
expect(() => extractSlackMessageText({ text: "", blocks: [null] } as any)).not.toThrow();
|
|
174
|
+
expect(extractSlackMessageText({ text: "", blocks: [null] } as any)).toBe("");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("attachments: [null] — skips null entry, returns empty string", () => {
|
|
178
|
+
expect(() => extractSlackMessageText({ text: "", attachments: [null] } as any)).not.toThrow();
|
|
179
|
+
expect(extractSlackMessageText({ text: "", attachments: [null] } as any)).toBe("");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("attachments: 'oops' (non-array) — returns empty string without throwing", () => {
|
|
183
|
+
expect(() => extractSlackMessageText({ text: "", attachments: "oops" } as any)).not.toThrow();
|
|
184
|
+
expect(extractSlackMessageText({ text: "", attachments: "oops" } as any)).toBe("");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("blocks: 'x' (non-array) — returns empty string without throwing", () => {
|
|
188
|
+
expect(() => extractSlackMessageText({ text: "", blocks: "x" } as any)).not.toThrow();
|
|
189
|
+
expect(extractSlackMessageText({ text: "", blocks: "x" } as any)).toBe("");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("blocks: [null, { type: 'section', text: { text: 'ok' } }] — skips null, returns valid text", () => {
|
|
193
|
+
const msg = {
|
|
194
|
+
text: "",
|
|
195
|
+
blocks: [null, { type: "section", text: { type: "plain_text", text: "ok" } }],
|
|
196
|
+
} as any;
|
|
197
|
+
expect(() => extractSlackMessageText(msg)).not.toThrow();
|
|
198
|
+
expect(extractSlackMessageText(msg)).toBe("ok");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rich_text block with null element in elements array — skips null inner", () => {
|
|
202
|
+
const msg = {
|
|
203
|
+
text: "",
|
|
204
|
+
blocks: [
|
|
205
|
+
{
|
|
206
|
+
type: "rich_text",
|
|
207
|
+
elements: [
|
|
208
|
+
null,
|
|
209
|
+
{ type: "rich_text_section", elements: [{ type: "text", text: "hi" }] },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
} as any;
|
|
214
|
+
expect(() => extractSlackMessageText(msg)).not.toThrow();
|
|
215
|
+
expect(extractSlackMessageText(msg)).toBe("hi");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("rich_text block where elements is not an array — skips block", () => {
|
|
219
|
+
const msg = {
|
|
220
|
+
text: "",
|
|
221
|
+
blocks: [{ type: "rich_text", elements: "not-an-array" }],
|
|
222
|
+
} as any;
|
|
223
|
+
expect(() => extractSlackMessageText(msg)).not.toThrow();
|
|
224
|
+
expect(extractSlackMessageText(msg)).toBe("");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("rich_text inner elements non-array — skips safely", () => {
|
|
228
|
+
const msg = {
|
|
229
|
+
text: "",
|
|
230
|
+
blocks: [
|
|
231
|
+
{
|
|
232
|
+
type: "rich_text",
|
|
233
|
+
elements: [{ type: "rich_text_section", elements: "oops" }],
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
} as any;
|
|
237
|
+
expect(() => extractSlackMessageText(msg)).not.toThrow();
|
|
238
|
+
expect(extractSlackMessageText(msg)).toBe("");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("attachments mixed null and valid entries — returns valid text only", () => {
|
|
242
|
+
const msg = {
|
|
243
|
+
text: "",
|
|
244
|
+
attachments: [null, { fallback: "real" }, null],
|
|
245
|
+
} as any;
|
|
246
|
+
expect(() => extractSlackMessageText(msg)).not.toThrow();
|
|
247
|
+
expect(extractSlackMessageText(msg)).toBe("real");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|