@desplega.ai/agent-swarm 1.90.0 → 1.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +803 -150
  3. package/package.json +5 -5
  4. package/src/artifact-sdk/server.ts +2 -1
  5. package/src/be/db.ts +337 -1
  6. package/src/be/memory/providers/sqlite-store.ts +6 -1
  7. package/src/be/memory/types.ts +1 -0
  8. package/src/be/migrations/083_script_workflows.sql +51 -0
  9. package/src/be/modelsdev-cache.json +42352 -38595
  10. package/src/be/scripts/typecheck.ts +181 -1
  11. package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
  12. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
  13. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  14. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  15. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  16. package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
  17. package/src/be/seed-scripts/index.ts +54 -0
  18. package/src/be/seed-skills/index.ts +7 -0
  19. package/src/be/swarm-config-guard.ts +17 -0
  20. package/src/commands/artifact.ts +3 -2
  21. package/src/commands/profile-sync.ts +310 -0
  22. package/src/commands/runner.ts +134 -3
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/db-query.ts +20 -5
  25. package/src/http/index.ts +57 -0
  26. package/src/http/integrations.ts +6 -1
  27. package/src/http/mcp-bridge.ts +117 -0
  28. package/src/http/mcp-oauth.ts +97 -39
  29. package/src/http/memory.ts +5 -2
  30. package/src/http/openapi.ts +2 -2
  31. package/src/http/pages-public.ts +10 -11
  32. package/src/http/pages.ts +7 -11
  33. package/src/http/script-runs.ts +555 -0
  34. package/src/http/scripts.ts +24 -1
  35. package/src/http/utils.ts +11 -4
  36. package/src/jira/app.ts +2 -3
  37. package/src/jira/webhook-lifecycle.ts +2 -1
  38. package/src/linear/app.ts +2 -3
  39. package/src/prompts/session-templates.ts +24 -4
  40. package/src/providers/claude-adapter.ts +86 -13
  41. package/src/script-workflows/executor.ts +110 -0
  42. package/src/script-workflows/harness.ts +73 -0
  43. package/src/script-workflows/label-lint.ts +51 -0
  44. package/src/script-workflows/limits.ts +22 -0
  45. package/src/script-workflows/supervisor.ts +139 -0
  46. package/src/script-workflows/workflow-ctx.ts +205 -0
  47. package/src/scripts-runtime/executors/native.ts +1 -0
  48. package/src/scripts-runtime/sdk-allowlist.ts +124 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  50. package/src/scripts-runtime/types/stdlib.d.ts +287 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
  52. package/src/server.ts +2 -0
  53. package/src/slack/handlers.ts +11 -4
  54. package/src/slack/message-text.ts +98 -0
  55. package/src/slack/thread-buffer.ts +5 -3
  56. package/src/tests/claude-adapter-binary.test.ts +147 -4
  57. package/src/tests/claude-adapter-otel.test.ts +85 -1
  58. package/src/tests/db-query.test.ts +28 -0
  59. package/src/tests/error-tracker.test.ts +121 -0
  60. package/src/tests/harness-provider-resolution.test.ts +33 -0
  61. package/src/tests/hook-registration-nudge.test.ts +69 -0
  62. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  63. package/src/tests/mcp-tools.test.ts +6 -0
  64. package/src/tests/pages-public-html.test.ts +41 -0
  65. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  66. package/src/tests/profile-sync.test.ts +282 -0
  67. package/src/tests/prompt-template-session.test.ts +34 -5
  68. package/src/tests/script-runs-http.test.ts +278 -0
  69. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  70. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  71. package/src/tests/scripts-mcp-e2e.test.ts +49 -2
  72. package/src/tests/scripts-runtime.test.ts +33 -0
  73. package/src/tests/seed-scripts.test.ts +347 -2
  74. package/src/tests/slack-message-text.test.ts +250 -0
  75. package/src/tests/system-default-skills.test.ts +40 -0
  76. package/src/tools/create-metric.ts +2 -3
  77. package/src/tools/create-page.ts +3 -6
  78. package/src/tools/db-query.ts +16 -6
  79. package/src/tools/memory-rate.ts +2 -1
  80. package/src/tools/memory-search.ts +1 -0
  81. package/src/tools/register-kapso-number.ts +2 -4
  82. package/src/tools/request-human-input.ts +2 -1
  83. package/src/tools/script-common.ts +2 -4
  84. package/src/tools/script-run.ts +7 -0
  85. package/src/tools/script-runs.ts +123 -0
  86. package/src/tools/slack-read.ts +12 -3
  87. package/src/tools/tool-config.ts +4 -1
  88. package/src/types.ts +52 -0
  89. package/src/utils/constants.ts +58 -8
  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/skills/pages/content.md +205 -55
  94. package/templates/skills/script-workflows/config.json +14 -0
  95. package/templates/skills/script-workflows/content.md +68 -0
  96. package/templates/skills/swarm-scripts/content.md +45 -7
@@ -8,6 +8,10 @@ import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
8
8
  import { typecheckScript } from "../be/scripts/typecheck";
9
9
  import { runSeeder } from "../be/seed";
10
10
  import { SEED_SCRIPTS, scriptsSeeder } from "../be/seed-scripts";
11
+ import compoundInsights from "../be/seed-scripts/catalog/compound-insights";
12
+ import opsCatalogAudit, {
13
+ renderPage as renderOpsCatalogAuditPage,
14
+ } from "../be/seed-scripts/catalog/ops-catalog-audit";
11
15
  import { extractScriptSignature } from "../scripts-runtime/extract-signature";
12
16
  import { validateScriptImports } from "../scripts-runtime/import-allowlist";
13
17
 
@@ -48,8 +52,8 @@ afterAll(async () => {
48
52
  });
49
53
 
50
54
  describe("seed-scripts catalog", () => {
51
- test("manifest holds 10 unique, well-described scripts", () => {
52
- expect(SEED_SCRIPTS.length).toBe(10);
55
+ test("manifest holds 16 unique, well-described scripts", () => {
56
+ expect(SEED_SCRIPTS.length).toBe(16);
53
57
  const names = SEED_SCRIPTS.map((s) => s.name);
54
58
  expect(new Set(names).size).toBe(names.length);
55
59
  for (const s of SEED_SCRIPTS) {
@@ -143,6 +147,347 @@ describe("seed-scripts catalog", () => {
143
147
  const row = getScript({ name: target.name, scope: "global" });
144
148
  expect(row?.source).toBe(userSource);
145
149
  });
150
+
151
+ test("compound-insights decodes numeric-key SQLite blob objects for similarity checks", async () => {
152
+ function encodedVector(values: number[]): Record<string, number> {
153
+ const bytes = new Uint8Array(new Float32Array(values).buffer);
154
+ return Object.fromEntries(Array.from(bytes.entries()).map(([i, byte]) => [String(i), byte]));
155
+ }
156
+
157
+ const queries: string[] = [];
158
+ const ctx = {
159
+ swarm: {
160
+ async db_query({ sql }: { sql: string }) {
161
+ queries.push(sql);
162
+ if (sql.includes("SELECT scope, source, count(*) as cnt")) {
163
+ return {
164
+ columns: ["scope", "source", "cnt", "zeroAccess"],
165
+ rows: [["agent", "session_summary", 2, 0]],
166
+ };
167
+ }
168
+ if (sql.includes("SELECT id, name, source, accessCount, embedding")) {
169
+ return {
170
+ columns: ["id", "name", "source", "accessCount", "embedding"],
171
+ rows: [
172
+ ["a", "first", "session_summary", 3, encodedVector([1, 0, 0, 0])],
173
+ ["b", "second", "task_completion", 2, encodedVector([0.9, 0.1, 0, 0])],
174
+ ],
175
+ };
176
+ }
177
+ if (sql.includes("SELECT source, count(*) as count")) {
178
+ return { columns: ["source", "count"], rows: [] };
179
+ }
180
+ return { columns: [], rows: [] };
181
+ },
182
+ },
183
+ };
184
+
185
+ const result = await compoundInsights(
186
+ {
187
+ days: 7,
188
+ includeToolUsage: false,
189
+ includeScheduleHealth: false,
190
+ includeScriptCandidates: false,
191
+ includeByAgent: false,
192
+ },
193
+ ctx,
194
+ );
195
+
196
+ expect(queries.some((sql) => sql.includes("embedding IS NOT NULL"))).toBe(true);
197
+ expect(result.memoryHealth.pollution.similarityCheck.sampledAutoSnapshots).toBe(2);
198
+ expect(result.memoryHealth.pollution.similarityCheck.strongestAutoSnapshotPair).toMatchObject({
199
+ a: { id: "a", name: "first", source: "session_summary" },
200
+ b: { id: "b", name: "second", source: "task_completion" },
201
+ });
202
+ expect(
203
+ result.memoryHealth.pollution.similarityCheck.strongestAutoSnapshotPair.similarity,
204
+ ).toBeGreaterThan(0.99);
205
+ });
206
+
207
+ test("ops-catalog-audit clusters schedule, workflow, and prompt findings by goal", async () => {
208
+ const queries: string[] = [];
209
+ const result = await opsCatalogAudit(
210
+ { nowIso: "2026-06-04T12:00:00.000Z", publishPage: false },
211
+ {
212
+ swarm: {
213
+ async db_query({ sql }: { sql: string }) {
214
+ queries.push(sql);
215
+ if (sql.includes("FROM scheduled_tasks")) {
216
+ return {
217
+ columns: [
218
+ "id",
219
+ "name",
220
+ "description",
221
+ "cronExpression",
222
+ "intervalMs",
223
+ "taskTemplate",
224
+ "taskType",
225
+ "tags",
226
+ "priority",
227
+ "targetAgentId",
228
+ "enabled",
229
+ "lastRunAt",
230
+ "nextRunAt",
231
+ "createdByAgentId",
232
+ "timezone",
233
+ "consecutiveErrors",
234
+ "scheduleType",
235
+ "targetAgentName",
236
+ "targetAgentRole",
237
+ "targetAgentDescription",
238
+ "targetAgentCapabilities",
239
+ "targetAgentProvider",
240
+ "targetAgentHarnessProvider",
241
+ ],
242
+ rows: [
243
+ [
244
+ "sched-a",
245
+ "repo-ci-audit",
246
+ "",
247
+ "0 * * * *",
248
+ null,
249
+ "Run gh pr checks and bun test in the repo",
250
+ "feature",
251
+ "[]",
252
+ 50,
253
+ null,
254
+ 1,
255
+ "2026-05-01T00:00:00.000Z",
256
+ null,
257
+ null,
258
+ "UTC",
259
+ 0,
260
+ "recurring",
261
+ null,
262
+ null,
263
+ null,
264
+ null,
265
+ null,
266
+ null,
267
+ ],
268
+ [
269
+ "sched-b",
270
+ "memory-gate-597",
271
+ "temporary monitor until 2026-06-01",
272
+ "0 * * * *",
273
+ null,
274
+ "Check memory gate",
275
+ "monitor",
276
+ "[]",
277
+ 50,
278
+ "agent-ops",
279
+ 1,
280
+ "2026-06-04T00:00:00.000Z",
281
+ "2026-06-04T13:00:00.000Z",
282
+ null,
283
+ "UTC",
284
+ 0,
285
+ "recurring",
286
+ "Ops Reviewer",
287
+ "ops",
288
+ "operations reviewer",
289
+ '["ops"]',
290
+ "opencode",
291
+ "opencode",
292
+ ],
293
+ ],
294
+ };
295
+ }
296
+ if (sql.includes("FROM workflows")) {
297
+ return {
298
+ columns: [
299
+ "id",
300
+ "name",
301
+ "description",
302
+ "enabled",
303
+ "definition",
304
+ "triggers",
305
+ "input",
306
+ "triggerSchema",
307
+ "createdAt",
308
+ "lastUpdatedAt",
309
+ ],
310
+ rows: [
311
+ [
312
+ "wf-smoke",
313
+ "gsc-runtime-smoke",
314
+ "temporary smoke fixture",
315
+ 1,
316
+ JSON.stringify({ nodes: [{ id: "a", type: "swarm-script" }] }),
317
+ "[]",
318
+ null,
319
+ null,
320
+ "2026-06-01T00:00:00.000Z",
321
+ "2026-06-01T00:00:00.000Z",
322
+ ],
323
+ [
324
+ "wf-gate",
325
+ "content-litmus-gate",
326
+ "quality gate",
327
+ 1,
328
+ JSON.stringify({ nodes: [{ id: "judge", type: "raw-llm" }] }),
329
+ "[]",
330
+ null,
331
+ null,
332
+ "2026-06-01T00:00:00.000Z",
333
+ "2026-06-01T00:00:00.000Z",
334
+ ],
335
+ ],
336
+ };
337
+ }
338
+ if (sql.includes("FROM prompt_templates")) {
339
+ return {
340
+ columns: [
341
+ "id",
342
+ "eventType",
343
+ "scope",
344
+ "scopeId",
345
+ "state",
346
+ "body",
347
+ "isDefault",
348
+ "version",
349
+ "createdBy",
350
+ "updatedAt",
351
+ ],
352
+ rows: [
353
+ [
354
+ "prompt-a",
355
+ "system.agent.role",
356
+ "global",
357
+ null,
358
+ "enabled",
359
+ "Use https://api.example-swarm.dev and do not browse. You must browse.",
360
+ 1,
361
+ 1,
362
+ "system",
363
+ "2026-06-01T00:00:00.000Z",
364
+ ],
365
+ [
366
+ "prompt-b",
367
+ "legacy.only",
368
+ "global",
369
+ null,
370
+ "enabled",
371
+ "Duplicate body",
372
+ 1,
373
+ 1,
374
+ "system",
375
+ "2026-06-01T00:00:00.000Z",
376
+ ],
377
+ [
378
+ "prompt-c",
379
+ "slack.assistant.greeting",
380
+ "global",
381
+ null,
382
+ "enabled",
383
+ "Duplicate body",
384
+ 1,
385
+ 1,
386
+ "system",
387
+ "2026-06-01T00:00:00.000Z",
388
+ ],
389
+ ],
390
+ };
391
+ }
392
+ if (sql.includes("FROM skills")) {
393
+ return {
394
+ columns: ["name", "count", "locations"],
395
+ rows: [["pages", 2, "global:global, swarm:global"]],
396
+ };
397
+ }
398
+ return { columns: [], rows: [] };
399
+ },
400
+ },
401
+ },
402
+ );
403
+
404
+ const findingIds = (items: Array<{ id: string }>) => items.map((finding) => finding.id);
405
+
406
+ expect(queries.length).toBe(4);
407
+ expect(result.summary.findingsTotal).toBeGreaterThanOrEqual(8);
408
+ expect(findingIds(result.goals.schedules.findings)).toEqual(
409
+ expect.arrayContaining([
410
+ "schedules.duplicate-crons",
411
+ "schedules.dead-or-stale",
412
+ "schedules.temporary-self-lift",
413
+ "schedules.rule-13-15-routing",
414
+ ]),
415
+ );
416
+ expect(findingIds(result.goals.workflows.findings)).toEqual(
417
+ expect.arrayContaining(["workflows.enabled-fixtures", "workflows.structured-output-gaps"]),
418
+ );
419
+ expect(findingIds(result.goals.promptsTemplates.findings)).toEqual(
420
+ expect.arrayContaining([
421
+ "prompts.registry-drift",
422
+ "prompts.redundant-bodies",
423
+ "prompts.stale-urls-hosts",
424
+ "prompts.contradictory-instructions",
425
+ "prompts.system-default-skill-duplicates",
426
+ ]),
427
+ );
428
+ });
429
+
430
+ test("ops-catalog-audit renders a summary-first designed HTML report", () => {
431
+ const html = renderOpsCatalogAuditPage({
432
+ generatedAt: "2026-06-04T12:00:00.000Z",
433
+ summary: {
434
+ schedulesEnabled: 40,
435
+ workflowsTotal: 33,
436
+ workflowsEnabled: 28,
437
+ promptTemplates: 76,
438
+ findingsTotal: 2,
439
+ },
440
+ goals: {
441
+ schedules: {
442
+ goal: "Reduce schedule cost/context waste and prevent misrouted code work.",
443
+ findingCount: 1,
444
+ checks: { duplicateCronGroups: 1, routingRisks: 1 },
445
+ findings: [
446
+ {
447
+ id: "schedules.rule-13-15-routing",
448
+ severity: "critical",
449
+ summary: "1 enabled code-work schedule is not pinned to a code-capable worker.",
450
+ action: "Set targetAgentId to a code-capable worker.",
451
+ samples: [
452
+ { id: "sched-a", name: "repo-ci-audit", reason: "pool-targeted code work" },
453
+ ],
454
+ },
455
+ ],
456
+ },
457
+ workflows: {
458
+ goal: "Separate load-bearing workflows from fixtures and enforce deterministic gate outputs.",
459
+ findingCount: 0,
460
+ checks: { enabledFixtures: 0, structuredOutputGaps: 0 },
461
+ findings: [],
462
+ },
463
+ promptsTemplates: {
464
+ goal: "Keep prompt registry, runtime defaults, host guidance, and skill seed blocks aligned.",
465
+ findingCount: 1,
466
+ checks: { staleUrlPrompts: 1 },
467
+ findings: [
468
+ {
469
+ id: "prompts.stale-urls-hosts",
470
+ severity: "high",
471
+ summary: "1 prompt template contains stale/local/example hosts.",
472
+ action: "Replace hardcoded hosts with runtime env-var guidance.",
473
+ samples: [{ id: "prompt-a", eventType: "system.agent.role", match: "localhost" }],
474
+ },
475
+ ],
476
+ },
477
+ },
478
+ });
479
+
480
+ expect(html).toContain("<main>");
481
+ expect(html).toContain('class="metrics"');
482
+ expect(html).toContain("<strong>40</strong><span>Schedules enabled</span>");
483
+ expect(html).toContain("schedules.rule-13-15-routing");
484
+ expect(html).toContain('class="finding danger"');
485
+ expect(html).toContain("<details>");
486
+ expect(html).toContain("Compressed JSON appendix");
487
+ expect(html).toContain('<div class="sample-table"');
488
+ expect(html).toContain("@media (max-width: 860px)");
489
+ expect(html).not.toContain("<ul>");
490
+ });
146
491
  });
147
492
 
148
493
  /**
@@ -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
+ });
@@ -37,8 +37,10 @@ describe("system-default skills", () => {
37
37
  const names = skills.map((skill) => skill.name);
38
38
 
39
39
  expect(names).toContain("attio-interaction");
40
+ expect(names).toContain("script-workflows");
40
41
  expect(names).toContain("swarm-scripts");
41
42
  expect(skills.find((skill) => skill.name === "attio-interaction")?.systemDefault).toBe(true);
43
+ expect(skills.find((skill) => skill.name === "script-workflows")?.systemDefault).toBe(true);
42
44
  expect(skills.find((skill) => skill.name === "swarm-scripts")?.systemDefault).toBe(true);
43
45
  expect(skills.find((skill) => skill.name === "kv-storage")?.systemDefault).toBe(true);
44
46
  expect(skills.find((skill) => skill.name === "pages")?.systemDefault).toBe(true);
@@ -48,6 +50,7 @@ describe("system-default skills", () => {
48
50
 
49
51
  const defaults = getSystemDefaultSkills().map((skill) => skill.name);
50
52
  expect(defaults).toContain("attio-interaction");
53
+ expect(defaults).toContain("script-workflows");
51
54
  expect(defaults).toContain("swarm-scripts");
52
55
  expect(defaults).toContain("kv-storage");
53
56
  expect(defaults).toContain("pages");
@@ -78,6 +81,43 @@ describe("system-default skills", () => {
78
81
  expect(skills.find((skill) => skill.id === manualDefault.id)?.isActive).toBe(true);
79
82
  });
80
83
 
84
+ test("existing agents see swarm-scope skills without explicit install rows", () => {
85
+ const existingAgent = createAgent({
86
+ name: "Existing Swarm Skill Worker",
87
+ description: "Created before a swarm-scope skill",
88
+ role: "worker",
89
+ isLead: false,
90
+ status: "idle",
91
+ maxTasks: 1,
92
+ capabilities: [],
93
+ });
94
+
95
+ const swarmSkill = createSkill({
96
+ name: "manual-swarm-scope-skill",
97
+ description: "Manual swarm scope skill",
98
+ content:
99
+ "---\nname: manual-swarm-scope-skill\ndescription: Manual swarm scope skill\n---\nBody.",
100
+ type: "personal",
101
+ scope: "swarm",
102
+ systemDefault: false,
103
+ });
104
+
105
+ const installRow = getDb()
106
+ .prepare<{ count: number }, [string, string]>(
107
+ `SELECT COUNT(*) AS count
108
+ FROM agent_skills
109
+ WHERE agentId = ?
110
+ AND skillId = ?`,
111
+ )
112
+ .get(existingAgent.id, swarmSkill.id);
113
+
114
+ expect(installRow?.count ?? 0).toBe(0);
115
+
116
+ const skills = getAgentSkills(existingAgent.id);
117
+ expect(skills.map((skill) => skill.name)).toContain("manual-swarm-scope-skill");
118
+ expect(skills.find((skill) => skill.id === swarmSkill.id)?.isActive).toBe(true);
119
+ });
120
+
81
121
  test("new agents get concrete agent_skills rows for system defaults", () => {
82
122
  const beforeAgent = createAgent({
83
123
  name: "Concrete Install Worker",