@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.
Files changed (114) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +1005 -152
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +662 -19
  6. package/src/be/memory/constants.ts +2 -1
  7. package/src/be/memory/providers/openai-embedding.ts +2 -5
  8. package/src/be/memory/providers/sqlite-store.ts +293 -76
  9. package/src/be/memory/types.ts +35 -0
  10. package/src/be/migrations/083_script_workflows.sql +51 -0
  11. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  12. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  13. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  14. package/src/be/migrations/087_skill_files.sql +19 -0
  15. package/src/be/modelsdev-cache.json +42310 -38617
  16. package/src/be/scripts/typecheck.ts +49 -0
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +310 -6
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +506 -0
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  25. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  26. package/src/be/seed-scripts/catalog/tool-usage.ts +6 -3
  27. package/src/be/seed-scripts/index.ts +51 -5
  28. package/src/be/seed-skills/index.ts +3 -3
  29. package/src/be/skill-sync.ts +91 -7
  30. package/src/be/swarm-config-guard.ts +17 -0
  31. package/src/commands/runner.ts +49 -4
  32. package/src/heartbeat/templates.ts +20 -16
  33. package/src/http/db-query.ts +20 -5
  34. package/src/http/index.ts +51 -7
  35. package/src/http/mcp-user.ts +23 -0
  36. package/src/http/mcp.ts +58 -0
  37. package/src/http/memory.ts +58 -0
  38. package/src/http/pages.ts +1 -1
  39. package/src/http/script-runs.ts +557 -0
  40. package/src/http/scripts.ts +39 -2
  41. package/src/http/skills.ts +225 -0
  42. package/src/prompts/session-templates.ts +24 -4
  43. package/src/providers/claude-adapter.ts +107 -28
  44. package/src/script-workflows/executor.ts +110 -0
  45. package/src/script-workflows/harness.ts +73 -0
  46. package/src/script-workflows/label-lint.ts +51 -0
  47. package/src/script-workflows/limits.ts +22 -0
  48. package/src/script-workflows/supervisor.ts +139 -0
  49. package/src/script-workflows/workflow-ctx.ts +209 -0
  50. package/src/scripts-runtime/sdk-allowlist.ts +4 -0
  51. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  52. package/src/scripts-runtime/types/stdlib.d.ts +61 -0
  53. package/src/scripts-runtime/types/swarm-sdk.d.ts +61 -0
  54. package/src/server.ts +4 -0
  55. package/src/slack/handlers.ts +11 -4
  56. package/src/slack/message-text.ts +98 -0
  57. package/src/slack/thread-buffer.ts +5 -3
  58. package/src/tests/claude-adapter-binary.test.ts +271 -74
  59. package/src/tests/create-page-tool.test.ts +19 -2
  60. package/src/tests/db-query.test.ts +28 -0
  61. package/src/tests/error-tracker.test.ts +121 -0
  62. package/src/tests/harness-provider-resolution.test.ts +33 -0
  63. package/src/tests/heartbeat-checklist.test.ts +36 -0
  64. package/src/tests/mcp-tools.test.ts +6 -0
  65. package/src/tests/mcp-transport-gc.test.ts +58 -0
  66. package/src/tests/memory-health-endpoint.test.ts +78 -0
  67. package/src/tests/memory-store.test.ts +221 -1
  68. package/src/tests/pages-http.test.ts +20 -2
  69. package/src/tests/pages-storage.test.ts +26 -0
  70. package/src/tests/prompt-template-session.test.ts +34 -5
  71. package/src/tests/script-runs-http.test.ts +278 -0
  72. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  73. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  74. package/src/tests/scripts-mcp-e2e.test.ts +102 -2
  75. package/src/tests/seed-scripts.test.ts +468 -3
  76. package/src/tests/skill-files-http.test.ts +171 -0
  77. package/src/tests/skill-files.test.ts +162 -0
  78. package/src/tests/skill-get-file-tool.test.ts +110 -0
  79. package/src/tests/skill-sync.test.ts +125 -6
  80. package/src/tests/slack-message-text.test.ts +250 -0
  81. package/src/tests/system-default-skills.test.ts +40 -0
  82. package/src/tools/create-page.ts +2 -2
  83. package/src/tools/db-query.ts +16 -6
  84. package/src/tools/script-runs.ts +123 -0
  85. package/src/tools/skills/index.ts +1 -0
  86. package/src/tools/skills/skill-get-file.ts +80 -0
  87. package/src/tools/slack-read.ts +12 -3
  88. package/src/tools/tool-config.ts +6 -2
  89. package/src/types.ts +72 -0
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  94. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  95. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  96. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  97. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  98. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  99. package/templates/skills/agentmail-sending/content.md +6 -7
  100. package/templates/skills/desloppify/content.md +8 -9
  101. package/templates/skills/jira-interaction/content.md +25 -33
  102. package/templates/skills/kapso-whatsapp/content.md +29 -30
  103. package/templates/skills/linear-interaction/content.md +8 -9
  104. package/templates/skills/pages/content.md +205 -55
  105. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  106. package/templates/skills/script-workflows/config.json +14 -0
  107. package/templates/skills/script-workflows/content.md +68 -0
  108. package/templates/skills/sprite-cli/content.md +4 -5
  109. package/templates/skills/swarm-scripts/content.md +2 -3
  110. package/templates/skills/turso-interaction/content.md +14 -17
  111. package/templates/skills/workflow-iterate/content.md +38 -391
  112. package/templates/skills/x-api-interactions/content.md +4 -6
  113. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  114. 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(3); // 1 skill × 3 dirs
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("skips complex skills", () => {
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).toBe(3);
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
+ });