@desplega.ai/agent-swarm 1.92.2 → 1.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +63 -3
- package/package.json +5 -5
- package/src/be/db.ts +91 -6
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/modelsdev-cache.json +1222 -986
- package/src/be/seed-pricing.ts +1 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +5 -5
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +124 -7
- package/src/http/api-keys.ts +42 -0
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/tasks.ts +10 -6
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +7 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +11 -4
- package/src/providers/pi-mono-adapter.ts +12 -2
- package/src/providers/types.ts +2 -0
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +2 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/http-api-integration.test.ts +4 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/model-control.test.ts +1 -1
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tools/schedules/create-schedule.ts +2 -2
- package/src/tools/schedules/update-schedule.ts +1 -1
- package/src/tools/send-task.ts +2 -2
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +2 -2
- package/src/types.ts +11 -0
- package/src/utils/context-window.ts +3 -0
- package/src/utils/credentials.ts +22 -2
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
|
@@ -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
|
+
});
|
|
@@ -233,7 +233,6 @@ describe("AgentTaskConfigSchema — outputSchema", () => {
|
|
|
233
233
|
test("accepts outputSchema in config", async () => {
|
|
234
234
|
const { AgentTaskExecutor } = await import("../workflows/executors/agent-task");
|
|
235
235
|
const executor = new AgentTaskExecutor({
|
|
236
|
-
// biome-ignore lint/suspicious/noExplicitAny: mock DB for test
|
|
237
236
|
db: {} as any,
|
|
238
237
|
eventBus: { emit: () => {}, on: () => {}, off: () => {} },
|
|
239
238
|
interpolate: (t: string) => t,
|
|
@@ -257,7 +256,6 @@ describe("AgentTaskConfigSchema — outputSchema", () => {
|
|
|
257
256
|
test("accepts followUpConfig in config", async () => {
|
|
258
257
|
const { AgentTaskExecutor } = await import("../workflows/executors/agent-task");
|
|
259
258
|
const executor = new AgentTaskExecutor({
|
|
260
|
-
// biome-ignore lint/suspicious/noExplicitAny: mock DB for test
|
|
261
259
|
db: {} as any,
|
|
262
260
|
eventBus: { emit: () => {}, on: () => {}, off: () => {} },
|
|
263
261
|
interpolate: (t: string) => t,
|
|
@@ -49,7 +49,6 @@ class MemoryStorage {
|
|
|
49
49
|
|
|
50
50
|
afterEach(() => {
|
|
51
51
|
// Clean up the global between tests so leakage can't mask bugs.
|
|
52
|
-
// biome-ignore lint/suspicious/noExplicitAny: test-only shim
|
|
53
52
|
delete (globalThis as any).localStorage;
|
|
54
53
|
});
|
|
55
54
|
|
|
@@ -82,7 +81,6 @@ describe("deriveStorageKey", () => {
|
|
|
82
81
|
describe("dismiss / restore round-trip via localStorage shape", () => {
|
|
83
82
|
test("dismiss writes '1' under the namespaced key; restore removes it", () => {
|
|
84
83
|
const storage = new MemoryStorage();
|
|
85
|
-
// biome-ignore lint/suspicious/noExplicitAny: test-only shim
|
|
86
84
|
(globalThis as any).localStorage = storage;
|
|
87
85
|
|
|
88
86
|
const key = deriveStorageKey("http://localhost:3013", "home-welcome");
|
|
@@ -101,7 +99,6 @@ describe("dismiss / restore round-trip via localStorage shape", () => {
|
|
|
101
99
|
|
|
102
100
|
test("namespace isolation: dismissing on apiUrl A does not affect apiUrl B", () => {
|
|
103
101
|
const storage = new MemoryStorage();
|
|
104
|
-
// biome-ignore lint/suspicious/noExplicitAny: test-only shim
|
|
105
102
|
(globalThis as any).localStorage = storage;
|
|
106
103
|
|
|
107
104
|
const keyA = deriveStorageKey("http://a.local:3013", "home-welcome");
|
|
@@ -118,7 +115,6 @@ describe("graceful failure when localStorage throws", () => {
|
|
|
118
115
|
test("setItem throw is swallowed by the hook's try/catch contract", () => {
|
|
119
116
|
const storage = new MemoryStorage();
|
|
120
117
|
storage.setThrowOnSet(true);
|
|
121
|
-
// biome-ignore lint/suspicious/noExplicitAny: test-only shim
|
|
122
118
|
(globalThis as any).localStorage = storage;
|
|
123
119
|
|
|
124
120
|
const key = deriveStorageKey("http://localhost:3013", "home-welcome");
|
|
@@ -76,10 +76,10 @@ export const registerCreateScheduleTool = (server: McpServer) => {
|
|
|
76
76
|
.optional()
|
|
77
77
|
.describe("Whether the schedule is enabled (default: true)"),
|
|
78
78
|
model: z
|
|
79
|
-
.enum(["haiku", "sonnet", "opus"])
|
|
79
|
+
.enum(["haiku", "sonnet", "opus", "fable"])
|
|
80
80
|
.optional()
|
|
81
81
|
.describe(
|
|
82
|
-
"Model to use for tasks created by this schedule ('haiku', 'sonnet', or '
|
|
82
|
+
"Model to use for tasks created by this schedule ('haiku', 'sonnet', 'opus', or 'fable'). If not set, uses agent/global config or defaults to 'opus'.",
|
|
83
83
|
),
|
|
84
84
|
}),
|
|
85
85
|
outputSchema: z.object({
|
|
@@ -44,7 +44,7 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
|
|
|
44
44
|
timezone: z.string().optional().describe("New timezone"),
|
|
45
45
|
enabled: z.boolean().optional().describe("Enable or disable the schedule"),
|
|
46
46
|
model: z
|
|
47
|
-
.enum(["haiku", "sonnet", "opus"])
|
|
47
|
+
.enum(["haiku", "sonnet", "opus", "fable"])
|
|
48
48
|
.nullable()
|
|
49
49
|
.optional()
|
|
50
50
|
.describe("Model to use for tasks created by this schedule. Set to null to clear."),
|
package/src/tools/send-task.ts
CHANGED
|
@@ -54,10 +54,10 @@ export const sendTaskInputSchema = z.object({
|
|
|
54
54
|
"VCS repo identifier (e.g., 'desplega-ai/agent-swarm' for GitHub or 'group/project' for GitLab). Links the task to a registered repo for workspace context.",
|
|
55
55
|
),
|
|
56
56
|
model: z
|
|
57
|
-
.enum(["haiku", "sonnet", "opus"])
|
|
57
|
+
.enum(["haiku", "sonnet", "opus", "fable"])
|
|
58
58
|
.optional()
|
|
59
59
|
.describe(
|
|
60
|
-
"Model to use for this task ('haiku', 'sonnet', or '
|
|
60
|
+
"Model to use for this task ('haiku', 'sonnet', 'opus', or 'fable'). If not set, uses agent/global config MODEL_OVERRIDE or defaults to 'opus'.",
|
|
61
61
|
),
|
|
62
62
|
allowDuplicate: z
|
|
63
63
|
.boolean()
|
package/src/tools/slack-post.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { getAgentById } from "@/be/db";
|
|
4
4
|
import { getSlackApp } from "@/slack/app";
|
|
5
|
+
import { withAutoJoin } from "@/slack/channel-join";
|
|
5
6
|
import { markdownToSlack } from "@/slack/responses";
|
|
6
7
|
import { createToolRegistrar } from "@/tools/utils";
|
|
7
8
|
|
|
@@ -68,22 +69,24 @@ export const registerSlackPostTool = (server: McpServer) => {
|
|
|
68
69
|
try {
|
|
69
70
|
const slackMessage = markdownToSlack(message);
|
|
70
71
|
|
|
71
|
-
const result = await app.client
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
const result = await withAutoJoin(app.client, channelId, () =>
|
|
73
|
+
app.client.chat.postMessage({
|
|
74
|
+
channel: channelId,
|
|
75
|
+
text: slackMessage, // Fallback for notifications
|
|
76
|
+
username: agent.name,
|
|
77
|
+
icon_emoji: ":crown:",
|
|
78
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
79
|
+
blocks: [
|
|
80
|
+
{
|
|
81
|
+
type: "section",
|
|
82
|
+
text: {
|
|
83
|
+
type: "mrkdwn",
|
|
84
|
+
text: slackMessage,
|
|
85
|
+
},
|
|
83
86
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
87
90
|
|
|
88
91
|
const messageTs = result.ts;
|
|
89
92
|
|
package/src/tools/slack-read.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { getAgentById, getInboxMessageById, getTaskById } from "@/be/db";
|
|
4
4
|
import { getSlackApp } from "@/slack/app";
|
|
5
|
+
import { withAutoJoin } from "@/slack/channel-join";
|
|
5
6
|
import { downloadFile } from "@/slack/files";
|
|
6
7
|
import { extractSlackMessageText } from "@/slack/message-text";
|
|
7
8
|
import { createToolRegistrar } from "@/tools/utils";
|
|
@@ -216,19 +217,16 @@ export const registerSlackReadTool = (server: McpServer) => {
|
|
|
216
217
|
let rawMessages: RawMessage[] = [];
|
|
217
218
|
|
|
218
219
|
if (slackThreadTs) {
|
|
219
|
-
// Fetch thread replies
|
|
220
|
-
const result = await client
|
|
221
|
-
channel: slackChannelId,
|
|
222
|
-
|
|
223
|
-
limit,
|
|
224
|
-
});
|
|
220
|
+
// Fetch thread replies — auto-join public channels on not_in_channel
|
|
221
|
+
const result = await withAutoJoin(client, slackChannelId, () =>
|
|
222
|
+
client.conversations.replies({ channel: slackChannelId, ts: slackThreadTs!, limit }),
|
|
223
|
+
);
|
|
225
224
|
rawMessages = (result.messages || []) as RawMessage[];
|
|
226
225
|
} else {
|
|
227
|
-
// Fetch channel history
|
|
228
|
-
const result = await client
|
|
229
|
-
channel: slackChannelId,
|
|
230
|
-
|
|
231
|
-
});
|
|
226
|
+
// Fetch channel history — auto-join public channels on not_in_channel
|
|
227
|
+
const result = await withAutoJoin(client, slackChannelId, () =>
|
|
228
|
+
client.conversations.history({ channel: slackChannelId, limit }),
|
|
229
|
+
);
|
|
232
230
|
rawMessages = (result.messages || []) as RawMessage[];
|
|
233
231
|
}
|
|
234
232
|
|
package/src/tools/slack-reply.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
markTaskSlackReplySent,
|
|
9
9
|
} from "@/be/db";
|
|
10
10
|
import { getSlackApp } from "@/slack/app";
|
|
11
|
+
import { withAutoJoin } from "@/slack/channel-join";
|
|
11
12
|
import { markdownToSlack } from "@/slack/responses";
|
|
12
13
|
import { createToolRegistrar } from "@/tools/utils";
|
|
13
14
|
|
|
@@ -118,22 +119,24 @@ export const registerSlackReplyTool = (server: McpServer) => {
|
|
|
118
119
|
try {
|
|
119
120
|
const slackMessage = markdownToSlack(message);
|
|
120
121
|
|
|
121
|
-
await app.client
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
122
|
+
await withAutoJoin(app.client, slackChannelId, () =>
|
|
123
|
+
app.client.chat.postMessage({
|
|
124
|
+
channel: slackChannelId,
|
|
125
|
+
thread_ts: slackThreadTs,
|
|
126
|
+
text: slackMessage, // Fallback for notifications
|
|
127
|
+
username: agent.name,
|
|
128
|
+
icon_emoji: agent.isLead ? ":crown:" : ":robot_face:",
|
|
129
|
+
blocks: [
|
|
130
|
+
{
|
|
131
|
+
type: "section",
|
|
132
|
+
text: {
|
|
133
|
+
type: "mrkdwn",
|
|
134
|
+
text: slackMessage,
|
|
135
|
+
},
|
|
133
136
|
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
],
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
137
140
|
|
|
138
141
|
// After successful postMessage, mark task as having a Slack reply
|
|
139
142
|
if (taskId) {
|
|
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import * as z from "zod";
|
|
3
3
|
import { getAgentById } from "@/be/db";
|
|
4
4
|
import { getSlackApp } from "@/slack/app";
|
|
5
|
+
import { withAutoJoin } from "@/slack/channel-join";
|
|
5
6
|
import { markdownToSlack } from "@/slack/responses";
|
|
6
7
|
import { createToolRegistrar } from "@/tools/utils";
|
|
7
8
|
|
|
@@ -62,21 +63,23 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
|
|
|
62
63
|
try {
|
|
63
64
|
const slackMessage = markdownToSlack(message);
|
|
64
65
|
|
|
65
|
-
const result = await app.client
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
const result = await withAutoJoin(app.client, channelId, () =>
|
|
67
|
+
app.client.chat.postMessage({
|
|
68
|
+
channel: channelId,
|
|
69
|
+
text: slackMessage, // Fallback for notifications
|
|
70
|
+
username: agent.name,
|
|
71
|
+
icon_emoji: ":crown:",
|
|
72
|
+
blocks: [
|
|
73
|
+
{
|
|
74
|
+
type: "section",
|
|
75
|
+
text: {
|
|
76
|
+
type: "mrkdwn",
|
|
77
|
+
text: slackMessage,
|
|
78
|
+
},
|
|
76
79
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
],
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
80
83
|
|
|
81
84
|
const ts = result.ts;
|
|
82
85
|
const resolvedChannelId = result.channel ?? channelId;
|
package/src/tools/task-action.ts
CHANGED
|
@@ -66,10 +66,10 @@ export const taskActionInputSchema = z.object({
|
|
|
66
66
|
"Working directory (absolute path) for the agent to start in. Only used with 'create' action.",
|
|
67
67
|
),
|
|
68
68
|
model: z
|
|
69
|
-
.enum(["haiku", "sonnet", "opus"])
|
|
69
|
+
.enum(["haiku", "sonnet", "opus", "fable"])
|
|
70
70
|
.optional()
|
|
71
71
|
.describe(
|
|
72
|
-
"Model to use for the created task ('haiku', 'sonnet', or '
|
|
72
|
+
"Model to use for the created task ('haiku', 'sonnet', 'opus', or 'fable'). Only used with 'create' action.",
|
|
73
73
|
),
|
|
74
74
|
});
|
|
75
75
|
|
package/src/types.ts
CHANGED
|
@@ -247,6 +247,10 @@ export const AgentTaskSchema = z.object({
|
|
|
247
247
|
provider: ProviderNameSchema.optional(),
|
|
248
248
|
providerMeta: z.record(z.string(), z.unknown()).optional(),
|
|
249
249
|
|
|
250
|
+
// Harness variant — sub-variant within a provider (e.g. "bridge" vs "stock" for claude)
|
|
251
|
+
harnessVariant: z.string().optional(),
|
|
252
|
+
harnessVariantMeta: z.record(z.string(), z.unknown()).optional(),
|
|
253
|
+
|
|
250
254
|
// Aggregated session cost for task list/read models. Undefined means no
|
|
251
255
|
// session cost rows have been recorded for this task.
|
|
252
256
|
totalCostUsd: z.number().min(0).optional(),
|
|
@@ -1579,6 +1583,13 @@ export const ScriptRunSchema = z.object({
|
|
|
1579
1583
|
});
|
|
1580
1584
|
export type ScriptRun = z.infer<typeof ScriptRunSchema>;
|
|
1581
1585
|
|
|
1586
|
+
export const ScriptRunListItemSchema = ScriptRunSchema.omit({
|
|
1587
|
+
source: true,
|
|
1588
|
+
args: true,
|
|
1589
|
+
output: true,
|
|
1590
|
+
});
|
|
1591
|
+
export type ScriptRunListItem = z.infer<typeof ScriptRunListItemSchema>;
|
|
1592
|
+
|
|
1582
1593
|
export const ScriptRunJournalEntrySchema = z.object({
|
|
1583
1594
|
id: z.string().uuid(),
|
|
1584
1595
|
runId: z.string().uuid(),
|