@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.
@@ -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 to both claude and pi when harnessType is 'both'", () => {
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, "both", FAKE_HOME);
108
+ const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
89
109
 
90
110
  expect(result.errors).toHaveLength(0);
91
- expect(result.synced).toBe(2); // 1 skill × 2 dirs
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("defaults to 'both' when no harnessType provided", () => {
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 'both' explicitly with homeOverride (default harnessType would use real home)
123
- const result = syncSkillsToFilesystem(agentId, "both", FAKE_HOME);
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(2);
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
+ });