@desplega.ai/agent-swarm 1.83.0 → 1.83.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openapi.json +39 -3
- package/package.json +6 -6
- package/src/be/db.ts +2 -2
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/runner.ts +41 -54
- package/src/http/schedules.ts +34 -9
- package/src/http/skills.ts +27 -2
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/utils/skills-refresh.ts +123 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the worker-side `refreshSkillsIfChanged()` helper. The helper
|
|
3
|
+
* is exercised against a Bun.serve() stub that mimics the signature + list
|
|
4
|
+
* + sync-filesystem endpoints. Cases lock down its contract: cheap probe on
|
|
5
|
+
* no-change, full refresh on hash drift, inactive/disabled filtering,
|
|
6
|
+
* transient 5xx swallowed.
|
|
7
|
+
*/
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { refreshSkillsIfChanged, type SkillsRefreshContext } from "../utils/skills-refresh";
|
|
10
|
+
|
|
11
|
+
// ── Bun.serve() stub backing fake signature/list/sync endpoints ──────────────
|
|
12
|
+
|
|
13
|
+
type StubState = {
|
|
14
|
+
signatureHash: string;
|
|
15
|
+
signatureStatus: number;
|
|
16
|
+
syncStatus: number;
|
|
17
|
+
skillsBody: {
|
|
18
|
+
skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
|
|
19
|
+
signature: string;
|
|
20
|
+
};
|
|
21
|
+
calls: { signature: number; list: number; sync: number };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const state: StubState = {
|
|
25
|
+
signatureHash: "hash-v1",
|
|
26
|
+
signatureStatus: 200,
|
|
27
|
+
syncStatus: 200,
|
|
28
|
+
skillsBody: {
|
|
29
|
+
skills: [
|
|
30
|
+
{ name: "alpha", description: "first skill", isActive: true, isEnabled: true },
|
|
31
|
+
{ name: "beta", description: "second skill", isActive: true, isEnabled: true },
|
|
32
|
+
],
|
|
33
|
+
signature: "hash-v1",
|
|
34
|
+
},
|
|
35
|
+
calls: { signature: 0, list: 0, sync: 0 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
39
|
+
let baseUrl = "";
|
|
40
|
+
|
|
41
|
+
describe("refreshSkillsIfChanged", () => {
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
server = Bun.serve({
|
|
44
|
+
port: 0,
|
|
45
|
+
fetch(req) {
|
|
46
|
+
const url = new URL(req.url);
|
|
47
|
+
if (url.pathname.endsWith("/skills/signature")) {
|
|
48
|
+
state.calls.signature++;
|
|
49
|
+
if (state.signatureStatus !== 200) {
|
|
50
|
+
return new Response("err", { status: state.signatureStatus });
|
|
51
|
+
}
|
|
52
|
+
return Response.json({
|
|
53
|
+
hash: state.signatureHash,
|
|
54
|
+
count: state.skillsBody.skills.length,
|
|
55
|
+
generatedAt: new Date().toISOString(),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (url.pathname.match(/\/api\/agents\/[^/]+\/skills$/)) {
|
|
59
|
+
state.calls.list++;
|
|
60
|
+
return Response.json({
|
|
61
|
+
skills: state.skillsBody.skills,
|
|
62
|
+
total: state.skillsBody.skills.length,
|
|
63
|
+
signature: state.skillsBody.signature,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (url.pathname === "/api/skills/sync-filesystem") {
|
|
67
|
+
state.calls.sync++;
|
|
68
|
+
if (state.syncStatus !== 200) {
|
|
69
|
+
return new Response("sync err", { status: state.syncStatus });
|
|
70
|
+
}
|
|
71
|
+
return Response.json({ synced: 2, removed: 0, errors: [] });
|
|
72
|
+
}
|
|
73
|
+
return new Response("not found", { status: 404 });
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
baseUrl = `http://localhost:${server.port}`;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
server?.stop(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function makeCtx(): SkillsRefreshContext {
|
|
84
|
+
return {
|
|
85
|
+
apiUrl: baseUrl,
|
|
86
|
+
swarmUrl: baseUrl,
|
|
87
|
+
apiKey: "test-key",
|
|
88
|
+
agentId: "agent-1",
|
|
89
|
+
role: "worker",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test("first call populates summary and updates the cached hash", async () => {
|
|
94
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
95
|
+
state.signatureHash = "hash-v1";
|
|
96
|
+
state.skillsBody.signature = "hash-v1";
|
|
97
|
+
|
|
98
|
+
const lastHash = { current: null as string | null };
|
|
99
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
100
|
+
|
|
101
|
+
expect(result.changed).toBe(true);
|
|
102
|
+
expect(result.summary).toEqual([
|
|
103
|
+
{ name: "alpha", description: "first skill" },
|
|
104
|
+
{ name: "beta", description: "second skill" },
|
|
105
|
+
]);
|
|
106
|
+
expect(lastHash.current).toBe("hash-v1");
|
|
107
|
+
expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("subsequent call with unchanged hash skips list + sync", async () => {
|
|
111
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
112
|
+
const lastHash = { current: "hash-v1" };
|
|
113
|
+
|
|
114
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
115
|
+
|
|
116
|
+
expect(result.changed).toBe(false);
|
|
117
|
+
expect(result.summary).toBeUndefined();
|
|
118
|
+
expect(lastHash.current).toBe("hash-v1");
|
|
119
|
+
expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("hash drift refetches list and updates cached hash to the list's snapshot", async () => {
|
|
123
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
124
|
+
state.signatureHash = "hash-v2";
|
|
125
|
+
state.skillsBody.signature = "hash-v2";
|
|
126
|
+
state.skillsBody.skills = [
|
|
127
|
+
{ name: "alpha", description: "first skill", isActive: true, isEnabled: true },
|
|
128
|
+
{ name: "beta", description: "second skill", isActive: true, isEnabled: true },
|
|
129
|
+
{ name: "gamma", description: "third skill", isActive: true, isEnabled: true },
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const lastHash = { current: "hash-v1" };
|
|
133
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
134
|
+
|
|
135
|
+
expect(result.changed).toBe(true);
|
|
136
|
+
expect(result.summary).toHaveLength(3);
|
|
137
|
+
expect(lastHash.current).toBe("hash-v2");
|
|
138
|
+
expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("filters out inactive or disabled skills from the summary", async () => {
|
|
142
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
143
|
+
state.signatureHash = "hash-v3";
|
|
144
|
+
state.skillsBody.signature = "hash-v3";
|
|
145
|
+
state.skillsBody.skills = [
|
|
146
|
+
{ name: "active", description: "kept", isActive: true, isEnabled: true },
|
|
147
|
+
{ name: "disabled", description: "dropped", isActive: true, isEnabled: false },
|
|
148
|
+
{ name: "inactive", description: "dropped", isActive: false, isEnabled: true },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const lastHash = { current: "hash-v2" };
|
|
152
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
153
|
+
|
|
154
|
+
expect(result.changed).toBe(true);
|
|
155
|
+
expect(result.summary).toEqual([{ name: "active", description: "kept" }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("transient 5xx on signature endpoint returns changed:false without touching list/sync", async () => {
|
|
159
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
160
|
+
state.signatureStatus = 503;
|
|
161
|
+
|
|
162
|
+
const lastHash = { current: "hash-v3" };
|
|
163
|
+
const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
164
|
+
|
|
165
|
+
expect(result.changed).toBe(false);
|
|
166
|
+
expect(lastHash.current).toBe("hash-v3");
|
|
167
|
+
expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
|
|
168
|
+
|
|
169
|
+
state.signatureStatus = 200; // restore for any later tests
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("sync-filesystem failure leaves cached hash unchanged so the next poll retries", async () => {
|
|
173
|
+
// Server-side state: a new hash + skill set, sync endpoint failing.
|
|
174
|
+
state.signatureHash = "hash-v4";
|
|
175
|
+
state.skillsBody.signature = "hash-v4";
|
|
176
|
+
state.skillsBody.skills = [
|
|
177
|
+
{ name: "alpha", description: "first", isActive: true, isEnabled: true },
|
|
178
|
+
];
|
|
179
|
+
state.syncStatus = 503;
|
|
180
|
+
state.calls = { signature: 0, list: 0, sync: 0 };
|
|
181
|
+
|
|
182
|
+
const lastHash = { current: "hash-prev" };
|
|
183
|
+
const first = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
184
|
+
|
|
185
|
+
// Summary still returns (the list call succeeded), but the cached
|
|
186
|
+
// hash must NOT advance — otherwise the next signature probe would
|
|
187
|
+
// short-circuit and the FS would stay stale forever.
|
|
188
|
+
expect(first.changed).toBe(true);
|
|
189
|
+
expect(first.summary).toEqual([{ name: "alpha", description: "first" }]);
|
|
190
|
+
expect(lastHash.current).toBe("hash-prev");
|
|
191
|
+
expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
|
|
192
|
+
|
|
193
|
+
// Sync recovers — next poll retries because cached hash still differs.
|
|
194
|
+
state.syncStatus = 200;
|
|
195
|
+
const second = await refreshSkillsIfChanged(makeCtx(), lastHash);
|
|
196
|
+
expect(second.changed).toBe(true);
|
|
197
|
+
expect(lastHash.current).toBe("hash-v4");
|
|
198
|
+
expect(state.calls).toEqual({ signature: 2, list: 2, sync: 2 });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
|
|
3
|
+
|
|
4
|
+
describe("mergeScheduleTiming", () => {
|
|
5
|
+
test("explicit null overrides existing", () => {
|
|
6
|
+
const result = mergeScheduleTiming(
|
|
7
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
8
|
+
{ cronExpression: null },
|
|
9
|
+
);
|
|
10
|
+
expect(result).toEqual({ mergedCron: null, mergedInterval: null });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("undefined preserves existing", () => {
|
|
14
|
+
const result = mergeScheduleTiming({ cronExpression: "0 * * * *", intervalMs: null }, {});
|
|
15
|
+
expect(result).toEqual({ mergedCron: "0 * * * *", mergedInterval: null });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("explicit value overrides existing", () => {
|
|
19
|
+
const result = mergeScheduleTiming(
|
|
20
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
21
|
+
{ cronExpression: "0 2 * * *" },
|
|
22
|
+
);
|
|
23
|
+
expect(result).toEqual({ mergedCron: "0 2 * * *", mergedInterval: null });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("validateRecurringTiming", () => {
|
|
28
|
+
test("cron-only existing + { cronExpression: null } patch → both-null error", () => {
|
|
29
|
+
const merged = mergeScheduleTiming(
|
|
30
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
31
|
+
{ cronExpression: null },
|
|
32
|
+
);
|
|
33
|
+
expect(validateRecurringTiming(merged)).toEqual({ kind: "both-null" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("cron-only existing + { cronExpression: null, intervalMs: 60000 } → valid", () => {
|
|
37
|
+
const merged = mergeScheduleTiming(
|
|
38
|
+
{ cronExpression: "0 * * * *", intervalMs: null },
|
|
39
|
+
{ cronExpression: null, intervalMs: 60000 },
|
|
40
|
+
);
|
|
41
|
+
expect(validateRecurringTiming(merged)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("both existing populated, patch nulls both → both-null error", () => {
|
|
45
|
+
const merged = mergeScheduleTiming(
|
|
46
|
+
{ cronExpression: "0 * * * *", intervalMs: 60000 },
|
|
47
|
+
{ cronExpression: null, intervalMs: null },
|
|
48
|
+
);
|
|
49
|
+
expect(validateRecurringTiming(merged)).toEqual({ kind: "both-null" });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
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
5
|
import { join } from "node:path";
|
|
6
6
|
import { closeDb, createAgent, createSkill, initDb, installSkill } from "../be/db";
|
|
7
7
|
import { syncSkillsToFilesystem } from "../be/skill-sync";
|
|
8
8
|
|
|
9
|
+
const SWARM_MARKER = ".swarm-managed";
|
|
10
|
+
|
|
9
11
|
const TEST_DB_PATH = `./test-skill-sync-${process.pid}.sqlite`;
|
|
10
12
|
const FAKE_HOME = join(tmpdir(), `skill-sync-test-${process.pid}`);
|
|
11
13
|
|
|
@@ -80,20 +82,40 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
80
82
|
expect(readFileSync(skillFile, "utf-8")).toContain("Test body.");
|
|
81
83
|
});
|
|
82
84
|
|
|
83
|
-
test("syncs
|
|
85
|
+
test("syncs simple skills to codex directory", () => {
|
|
86
|
+
const result = syncSkillsToFilesystem(agentId, "codex", FAKE_HOME);
|
|
87
|
+
|
|
88
|
+
expect(result.errors).toHaveLength(0);
|
|
89
|
+
expect(result.synced).toBeGreaterThanOrEqual(1);
|
|
90
|
+
|
|
91
|
+
const skillFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
|
|
92
|
+
expect(existsSync(skillFile)).toBe(true);
|
|
93
|
+
expect(readFileSync(skillFile, "utf-8")).toContain("Test body.");
|
|
94
|
+
|
|
95
|
+
// Verify claude and pi paths were NOT written when targeting codex only
|
|
96
|
+
const claudeOnlyFile = join(FAKE_HOME, ".claude", "skills", "codex-only-marker", "SKILL.md");
|
|
97
|
+
const piOnlyFile = join(FAKE_HOME, ".pi", "agent", "skills", "codex-only-marker", "SKILL.md");
|
|
98
|
+
expect(existsSync(claudeOnlyFile)).toBe(false);
|
|
99
|
+
expect(existsSync(piOnlyFile)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("syncs to claude, pi, and codex when harnessType is 'all'", () => {
|
|
84
103
|
// Clean up first to get accurate count
|
|
85
104
|
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
86
105
|
rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
|
|
106
|
+
rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
|
|
87
107
|
|
|
88
|
-
const result = syncSkillsToFilesystem(agentId, "
|
|
108
|
+
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
89
109
|
|
|
90
110
|
expect(result.errors).toHaveLength(0);
|
|
91
|
-
expect(result.synced).toBe(
|
|
111
|
+
expect(result.synced).toBe(3); // 1 skill × 3 dirs
|
|
92
112
|
|
|
93
113
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
94
114
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
115
|
+
const codexFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
|
|
95
116
|
expect(existsSync(claudeFile)).toBe(true);
|
|
96
117
|
expect(existsSync(piFile)).toBe(true);
|
|
118
|
+
expect(existsSync(codexFile)).toBe(true);
|
|
97
119
|
});
|
|
98
120
|
|
|
99
121
|
test("skips complex skills", () => {
|
|
@@ -103,9 +125,11 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
103
125
|
expect(existsSync(complexDir)).toBe(false);
|
|
104
126
|
});
|
|
105
127
|
|
|
106
|
-
test("removes stale skill directories", () => {
|
|
128
|
+
test("removes stale swarm-managed skill directories", () => {
|
|
129
|
+
// Mark this stale dir as swarm-managed (mirrors what an earlier sync would have done)
|
|
107
130
|
const staleDir = join(FAKE_HOME, ".claude", "skills", "old-removed-skill");
|
|
108
131
|
mkdirSync(staleDir, { recursive: true });
|
|
132
|
+
writeFileSync(join(staleDir, SWARM_MARKER), "");
|
|
109
133
|
expect(existsSync(staleDir)).toBe(true);
|
|
110
134
|
|
|
111
135
|
const result = syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
@@ -114,21 +138,61 @@ describe("syncSkillsToFilesystem", () => {
|
|
|
114
138
|
expect(existsSync(staleDir)).toBe(false);
|
|
115
139
|
});
|
|
116
140
|
|
|
117
|
-
test("
|
|
141
|
+
test("removes stale swarm-managed codex skill directories", () => {
|
|
142
|
+
const staleCodexDir = join(FAKE_HOME, ".codex", "skills", "old-codex-skill");
|
|
143
|
+
mkdirSync(staleCodexDir, { recursive: true });
|
|
144
|
+
writeFileSync(join(staleCodexDir, SWARM_MARKER), "");
|
|
145
|
+
expect(existsSync(staleCodexDir)).toBe(true);
|
|
146
|
+
|
|
147
|
+
const result = syncSkillsToFilesystem(agentId, "codex", FAKE_HOME);
|
|
148
|
+
|
|
149
|
+
expect(result.removed).toBeGreaterThanOrEqual(1);
|
|
150
|
+
expect(existsSync(staleCodexDir)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("leaves foreign (unmarked) skill directories alone — local-dev safety", () => {
|
|
154
|
+
// Simulate a user-installed codex skill in their personal ~/.codex/skills
|
|
155
|
+
// that the swarm did NOT create. The cleanup pass MUST NOT remove it.
|
|
156
|
+
const foreignDir = join(FAKE_HOME, ".codex", "skills", "user-personal-skill");
|
|
157
|
+
mkdirSync(foreignDir, { recursive: true });
|
|
158
|
+
writeFileSync(join(foreignDir, "SKILL.md"), "user's own skill — keep me");
|
|
159
|
+
// No SWARM_MARKER file → not ours to manage.
|
|
160
|
+
expect(existsSync(foreignDir)).toBe(true);
|
|
161
|
+
|
|
162
|
+
syncSkillsToFilesystem(agentId, "codex", FAKE_HOME);
|
|
163
|
+
|
|
164
|
+
expect(existsSync(foreignDir)).toBe(true);
|
|
165
|
+
expect(readFileSync(join(foreignDir, "SKILL.md"), "utf-8")).toBe("user's own skill — keep me");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("written skill directories carry the swarm-managed marker", () => {
|
|
169
|
+
// Clean up first
|
|
170
|
+
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
171
|
+
|
|
172
|
+
syncSkillsToFilesystem(agentId, "claude", FAKE_HOME);
|
|
173
|
+
|
|
174
|
+
const marker = join(FAKE_HOME, ".claude", "skills", "test-skill", SWARM_MARKER);
|
|
175
|
+
expect(existsSync(marker)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("defaults to 'all' when no harnessType provided", () => {
|
|
118
179
|
// Clean up first
|
|
119
180
|
rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
|
|
120
181
|
rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
|
|
182
|
+
rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
|
|
121
183
|
|
|
122
|
-
// Use '
|
|
123
|
-
const result = syncSkillsToFilesystem(agentId, "
|
|
184
|
+
// Use 'all' explicitly with homeOverride (default harnessType would use real home)
|
|
185
|
+
const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
|
|
124
186
|
|
|
125
187
|
expect(result.errors).toHaveLength(0);
|
|
126
|
-
expect(result.synced).toBe(
|
|
188
|
+
expect(result.synced).toBe(3);
|
|
127
189
|
|
|
128
190
|
const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
|
|
129
191
|
const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
|
|
192
|
+
const codexFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
|
|
130
193
|
expect(existsSync(claudeFile)).toBe(true);
|
|
131
194
|
expect(existsSync(piFile)).toBe(true);
|
|
195
|
+
expect(existsSync(codexFile)).toBe(true);
|
|
132
196
|
});
|
|
133
197
|
|
|
134
198
|
test("returns empty result for agent with no skills", () => {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
closeDb,
|
|
5
|
+
createAgent,
|
|
6
|
+
createSkill,
|
|
7
|
+
initDb,
|
|
8
|
+
installSkill,
|
|
9
|
+
toggleAgentSkill,
|
|
10
|
+
uninstallSkill,
|
|
11
|
+
updateSkill,
|
|
12
|
+
} from "../be/db";
|
|
13
|
+
import { computeAgentSkillsSignature } from "../be/skill-sync";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = `./test-skills-signature-${process.pid}.sqlite`;
|
|
16
|
+
|
|
17
|
+
describe("computeAgentSkillsSignature", () => {
|
|
18
|
+
let agentId: string;
|
|
19
|
+
let otherAgentId: string;
|
|
20
|
+
let skill1Id: string;
|
|
21
|
+
let skill2Id: string;
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
initDb(TEST_DB_PATH);
|
|
25
|
+
|
|
26
|
+
const agent = createAgent({
|
|
27
|
+
name: "Signature Test Worker",
|
|
28
|
+
description: "Test agent",
|
|
29
|
+
role: "worker",
|
|
30
|
+
isLead: false,
|
|
31
|
+
status: "idle",
|
|
32
|
+
maxTasks: 1,
|
|
33
|
+
capabilities: [],
|
|
34
|
+
});
|
|
35
|
+
agentId = agent.id;
|
|
36
|
+
|
|
37
|
+
const otherAgent = createAgent({
|
|
38
|
+
name: "Signature Test Other",
|
|
39
|
+
description: "Independent agent",
|
|
40
|
+
role: "worker",
|
|
41
|
+
isLead: false,
|
|
42
|
+
status: "idle",
|
|
43
|
+
maxTasks: 1,
|
|
44
|
+
capabilities: [],
|
|
45
|
+
});
|
|
46
|
+
otherAgentId = otherAgent.id;
|
|
47
|
+
|
|
48
|
+
const skill1 = createSkill({
|
|
49
|
+
name: "sig-skill-1",
|
|
50
|
+
description: "First skill",
|
|
51
|
+
content: "---\nname: sig-skill-1\ndescription: First skill\n---\nBody 1.",
|
|
52
|
+
type: "personal",
|
|
53
|
+
scope: "agent",
|
|
54
|
+
});
|
|
55
|
+
skill1Id = skill1.id;
|
|
56
|
+
|
|
57
|
+
const skill2 = createSkill({
|
|
58
|
+
name: "sig-skill-2",
|
|
59
|
+
description: "Second skill",
|
|
60
|
+
content: "---\nname: sig-skill-2\ndescription: Second skill\n---\nBody 2.",
|
|
61
|
+
type: "personal",
|
|
62
|
+
scope: "agent",
|
|
63
|
+
});
|
|
64
|
+
skill2Id = skill2.id;
|
|
65
|
+
|
|
66
|
+
installSkill(agentId, skill1Id);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
closeDb();
|
|
71
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
72
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
73
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("returns identical hash across no-op calls (deterministic)", () => {
|
|
77
|
+
const sig1 = computeAgentSkillsSignature(agentId);
|
|
78
|
+
const sig2 = computeAgentSkillsSignature(agentId);
|
|
79
|
+
expect(sig1.hash).toBe(sig2.hash);
|
|
80
|
+
expect(sig1.count).toBe(sig2.count);
|
|
81
|
+
expect(sig1.hash).toHaveLength(64); // sha256 hex
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("hash changes when a new skill is installed", () => {
|
|
85
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
86
|
+
installSkill(agentId, skill2Id);
|
|
87
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
88
|
+
expect(after.hash).not.toBe(before.hash);
|
|
89
|
+
expect(after.count).toBe(before.count + 1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("hash changes when a skill is uninstalled", () => {
|
|
93
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
94
|
+
uninstallSkill(agentId, skill2Id);
|
|
95
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
96
|
+
expect(after.hash).not.toBe(before.hash);
|
|
97
|
+
expect(after.count).toBe(before.count - 1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("hash changes when a skill is toggled inactive", () => {
|
|
101
|
+
installSkill(agentId, skill2Id);
|
|
102
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
103
|
+
toggleAgentSkill(agentId, skill2Id, false);
|
|
104
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
105
|
+
expect(after.hash).not.toBe(before.hash);
|
|
106
|
+
// Toggling inactive removes it from the active+enabled view used by getAgentSkills
|
|
107
|
+
expect(after.count).toBe(before.count - 1);
|
|
108
|
+
|
|
109
|
+
// Re-activate so subsequent tests have a known state
|
|
110
|
+
toggleAgentSkill(agentId, skill2Id, true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("hash changes when updateSkill mutates a skill (via lastUpdatedAt bump)", async () => {
|
|
114
|
+
const before = computeAgentSkillsSignature(agentId);
|
|
115
|
+
// updateSkill always bumps lastUpdatedAt — even an isEnabled no-op flip back is enough.
|
|
116
|
+
// Wait 5ms to guarantee a different ISO timestamp.
|
|
117
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
118
|
+
updateSkill(skill1Id, { description: "First skill (updated)" });
|
|
119
|
+
const after = computeAgentSkillsSignature(agentId);
|
|
120
|
+
expect(after.hash).not.toBe(before.hash);
|
|
121
|
+
expect(after.count).toBe(before.count);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("agent A's signature is independent of mutations to agent B's skills", () => {
|
|
125
|
+
installSkill(otherAgentId, skill1Id);
|
|
126
|
+
const aBefore = computeAgentSkillsSignature(agentId);
|
|
127
|
+
const bBefore = computeAgentSkillsSignature(otherAgentId);
|
|
128
|
+
|
|
129
|
+
// Mutate agent B: install another skill, toggle, uninstall
|
|
130
|
+
installSkill(otherAgentId, skill2Id);
|
|
131
|
+
toggleAgentSkill(otherAgentId, skill1Id, false);
|
|
132
|
+
uninstallSkill(otherAgentId, skill2Id);
|
|
133
|
+
|
|
134
|
+
const aAfter = computeAgentSkillsSignature(agentId);
|
|
135
|
+
const bAfter = computeAgentSkillsSignature(otherAgentId);
|
|
136
|
+
|
|
137
|
+
expect(aAfter.hash).toBe(aBefore.hash);
|
|
138
|
+
expect(aAfter.count).toBe(aBefore.count);
|
|
139
|
+
expect(bAfter.hash).not.toBe(bBefore.hash);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool-level regression test for `update-schedule`.
|
|
3
|
+
*
|
|
4
|
+
* Covers the end-to-end path through the tool (not just the helper), verifying
|
|
5
|
+
* that the mergeScheduleTiming / validateRecurringTiming wiring is active on
|
|
6
|
+
* the actual MCP call path.
|
|
7
|
+
*
|
|
8
|
+
* Does NOT duplicate schedule-validation-helper.test.ts (pure helper logic).
|
|
9
|
+
*/
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { unlink } from "node:fs/promises";
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import { closeDb, createAgent, createScheduledTask, getScheduledTaskById, initDb } from "../be/db";
|
|
15
|
+
import { registerUpdateScheduleTool } from "../tools/schedules/update-schedule";
|
|
16
|
+
|
|
17
|
+
const TEST_DB_PATH = "./test-update-schedule-mcp-tool.sqlite";
|
|
18
|
+
|
|
19
|
+
type RegisteredTool = {
|
|
20
|
+
handler: (args: unknown, extra: unknown) => Promise<CallToolResult>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function buildServer(): McpServer {
|
|
24
|
+
const server = new McpServer({ name: "update-schedule-mcp-test", version: "1.0.0" });
|
|
25
|
+
registerUpdateScheduleTool(server);
|
|
26
|
+
return server;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function callUpdateSchedule(
|
|
30
|
+
server: McpServer,
|
|
31
|
+
args: Record<string, unknown>,
|
|
32
|
+
callerAgentId: string,
|
|
33
|
+
): Promise<CallToolResult> {
|
|
34
|
+
const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
|
|
35
|
+
._registeredTools;
|
|
36
|
+
const tool = tools["update-schedule"];
|
|
37
|
+
if (!tool) throw new Error("update-schedule not registered");
|
|
38
|
+
return tool.handler(args, {
|
|
39
|
+
sessionId: "test-session",
|
|
40
|
+
requestInfo: { headers: { "x-agent-id": callerAgentId } },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ScheduleOutput = {
|
|
45
|
+
success: boolean;
|
|
46
|
+
message: string;
|
|
47
|
+
schedule?: {
|
|
48
|
+
cronExpression?: string | null;
|
|
49
|
+
intervalMs?: number | null;
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
nextRunAt?: string | null;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function structured(result: CallToolResult): ScheduleOutput {
|
|
56
|
+
return result.structuredContent as ScheduleOutput;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let creatorId: string;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
63
|
+
try {
|
|
64
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
initDb(TEST_DB_PATH);
|
|
68
|
+
const creator = createAgent({
|
|
69
|
+
name: "update-schedule-mcp-creator",
|
|
70
|
+
isLead: false,
|
|
71
|
+
status: "idle",
|
|
72
|
+
});
|
|
73
|
+
creatorId = creator.id;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
closeDb();
|
|
78
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
79
|
+
try {
|
|
80
|
+
await unlink(TEST_DB_PATH + suffix);
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("update-schedule MCP tool", () => {
|
|
86
|
+
test("regression: { cronExpression: null, intervalMs: null, enabled: false } returns success:false and leaves DB row unchanged", async () => {
|
|
87
|
+
const server = buildServer();
|
|
88
|
+
const schedule = createScheduledTask({
|
|
89
|
+
name: `mcp-regression-${Date.now()}`,
|
|
90
|
+
cronExpression: "0 * * * *",
|
|
91
|
+
taskTemplate: "hourly task",
|
|
92
|
+
createdByAgentId: creatorId,
|
|
93
|
+
timezone: "UTC",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const before = getScheduledTaskById(schedule.id)!;
|
|
97
|
+
|
|
98
|
+
const result = await callUpdateSchedule(
|
|
99
|
+
server,
|
|
100
|
+
{ scheduleId: schedule.id, cronExpression: null, intervalMs: null, enabled: false },
|
|
101
|
+
creatorId,
|
|
102
|
+
);
|
|
103
|
+
const sc = structured(result);
|
|
104
|
+
|
|
105
|
+
expect(sc.success).toBe(false);
|
|
106
|
+
expect(sc.message).toContain("At least one of intervalMs or cronExpression must be set");
|
|
107
|
+
|
|
108
|
+
// DB row must be unchanged — no partial write on validation failure
|
|
109
|
+
const after = getScheduledTaskById(schedule.id)!;
|
|
110
|
+
expect(after.cronExpression).toBe(before.cronExpression);
|
|
111
|
+
expect(after.intervalMs).toBe(before.intervalMs);
|
|
112
|
+
expect(after.enabled).toBe(before.enabled);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("cron-to-interval switch: { cronExpression: null, intervalMs: 60000 } succeeds and nextRunAt is recomputed", async () => {
|
|
116
|
+
const server = buildServer();
|
|
117
|
+
const schedule = createScheduledTask({
|
|
118
|
+
name: `mcp-cron-switch-${Date.now()}`,
|
|
119
|
+
cronExpression: "0 * * * *",
|
|
120
|
+
taskTemplate: "hourly task",
|
|
121
|
+
createdByAgentId: creatorId,
|
|
122
|
+
timezone: "UTC",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await callUpdateSchedule(
|
|
126
|
+
server,
|
|
127
|
+
{ scheduleId: schedule.id, cronExpression: null, intervalMs: 60000 },
|
|
128
|
+
creatorId,
|
|
129
|
+
);
|
|
130
|
+
const sc = structured(result);
|
|
131
|
+
|
|
132
|
+
expect(sc.success).toBe(true);
|
|
133
|
+
// cron cleared, interval applied
|
|
134
|
+
expect(sc.schedule?.cronExpression).toBeUndefined();
|
|
135
|
+
expect(sc.schedule?.intervalMs).toBe(60000);
|
|
136
|
+
// nextRunAt must be recomputed from the new interval
|
|
137
|
+
expect(sc.schedule?.nextRunAt).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("interval happy path: { intervalMs: 120000 } on interval schedule succeeds and nextRunAt updates", async () => {
|
|
141
|
+
const server = buildServer();
|
|
142
|
+
const schedule = createScheduledTask({
|
|
143
|
+
name: `mcp-interval-update-${Date.now()}`,
|
|
144
|
+
intervalMs: 60000,
|
|
145
|
+
taskTemplate: "heartbeat task",
|
|
146
|
+
createdByAgentId: creatorId,
|
|
147
|
+
timezone: "UTC",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = await callUpdateSchedule(
|
|
151
|
+
server,
|
|
152
|
+
{ scheduleId: schedule.id, intervalMs: 120000 },
|
|
153
|
+
creatorId,
|
|
154
|
+
);
|
|
155
|
+
const sc = structured(result);
|
|
156
|
+
|
|
157
|
+
expect(sc.success).toBe(true);
|
|
158
|
+
expect(sc.schedule?.intervalMs).toBe(120000);
|
|
159
|
+
expect(sc.schedule?.nextRunAt).toBeTruthy();
|
|
160
|
+
});
|
|
161
|
+
});
|