@desplega.ai/agent-swarm 1.87.0 → 1.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +13 -1
  3. package/package.json +5 -5
  4. package/src/be/db.ts +49 -7
  5. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  6. package/src/be/modelsdev-cache.json +1123 -1034
  7. package/src/be/seed/registry.ts +3 -2
  8. package/src/be/seed-skills/index.ts +172 -0
  9. package/src/cli.tsx +33 -4
  10. package/src/commands/e2b-stack-wizard.tsx +394 -0
  11. package/src/commands/e2b.ts +1352 -53
  12. package/src/commands/onboard/dashboard-url.ts +29 -0
  13. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  14. package/src/commands/onboard.tsx +3 -1
  15. package/src/commands/runner.ts +1 -0
  16. package/src/e2b/dispatch.ts +234 -18
  17. package/src/http/memory.ts +13 -1
  18. package/src/http/skills.ts +53 -0
  19. package/src/http/webhooks.ts +75 -0
  20. package/src/integrations/kapso/client.ts +82 -0
  21. package/src/memory/automatic-task-gate.ts +47 -0
  22. package/src/prompts/base-prompt.ts +16 -1
  23. package/src/prompts/session-templates.ts +51 -0
  24. package/src/providers/claude-adapter.ts +19 -0
  25. package/src/providers/codex-adapter.ts +22 -0
  26. package/src/providers/ctx-mode-env.ts +10 -0
  27. package/src/providers/opencode-adapter.ts +50 -1
  28. package/src/slack/blocks.ts +12 -4
  29. package/src/slack/watcher.ts +3 -3
  30. package/src/telemetry.ts +14 -1
  31. package/src/templates.d.ts +4 -0
  32. package/src/tests/base-prompt.test.ts +41 -0
  33. package/src/tests/claude-adapter.test.ts +86 -1
  34. package/src/tests/codex-adapter.test.ts +89 -0
  35. package/src/tests/e2b-dispatch.test.ts +603 -11
  36. package/src/tests/http-api-integration.test.ts +113 -0
  37. package/src/tests/kapso-client.test.ts +74 -1
  38. package/src/tests/kapso-inbound.test.ts +60 -2
  39. package/src/tests/opencode-adapter.test.ts +95 -0
  40. package/src/tests/prompt-template-session.test.ts +4 -2
  41. package/src/tests/self-improvement.test.ts +89 -0
  42. package/src/tests/skill-update-scope.test.ts +88 -1
  43. package/src/tests/slack-blocks.test.ts +15 -0
  44. package/src/tests/system-default-skills.test.ts +119 -0
  45. package/src/tests/telemetry-init.test.ts +86 -0
  46. package/src/tools/skills/skill-delete.ts +14 -0
  47. package/src/tools/skills/skill-update.ts +14 -0
  48. package/src/tools/store-progress.ts +19 -5
  49. package/src/types.ts +1 -0
  50. package/templates/skills/artifacts/config.json +1 -0
  51. package/templates/skills/kv-storage/config.json +1 -0
  52. package/templates/skills/pages/config.json +1 -0
  53. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  54. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  55. package/templates/skills/swarm-scripts/config.json +14 -0
  56. package/templates/skills/swarm-scripts/content.md +86 -0
  57. package/templates/skills/workflow-iterate/config.json +1 -0
  58. package/templates/skills/workflow-structured-output/config.json +1 -0
  59. package/tsconfig.json +2 -1
@@ -1257,6 +1257,119 @@ describe("Memory", () => {
1257
1257
  expect(body.memoryIds).toBeDefined();
1258
1258
  });
1259
1259
 
1260
+ test("POST /api/memory/index — gates runner session summaries for automatic task classes", async () => {
1261
+ const automaticCases = [
1262
+ { label: "schedule source", source: "schedule", taskType: "maintenance", tags: [] },
1263
+ { label: "system source", source: "system", taskType: "maintenance", tags: [] },
1264
+ { label: "scheduled tag", source: "mcp", taskType: "maintenance", tags: ["scheduled"] },
1265
+ { label: "schedule tag", source: "mcp", taskType: "maintenance", tags: ["schedule:test"] },
1266
+ {
1267
+ label: "auto-generated tag",
1268
+ source: "mcp",
1269
+ taskType: "maintenance",
1270
+ tags: ["auto-generated"],
1271
+ },
1272
+ { label: "heartbeat", source: "mcp", taskType: "heartbeat", tags: [] },
1273
+ { label: "heartbeat checklist", source: "mcp", taskType: "heartbeat-checklist", tags: [] },
1274
+ { label: "boot triage", source: "mcp", taskType: "boot-triage", tags: [] },
1275
+ { label: "health check", source: "mcp", taskType: "health-check", tags: [] },
1276
+ {
1277
+ label: "monitor suffix",
1278
+ source: "mcp",
1279
+ taskType: "claude-code-changelog-monitor",
1280
+ tags: [],
1281
+ },
1282
+ { label: "digest suffix", source: "mcp", taskType: "daily-blocker-digest", tags: [] },
1283
+ ];
1284
+
1285
+ for (const taskCase of automaticCases) {
1286
+ const task = await post("/api/tasks", {
1287
+ agentId: ids.leadAgent,
1288
+ body: {
1289
+ task: `Automatic memory integration test: ${taskCase.label}`,
1290
+ agentId: ids.workerAgent,
1291
+ source: taskCase.source,
1292
+ taskType: taskCase.taskType,
1293
+ tags: taskCase.tags,
1294
+ },
1295
+ });
1296
+ expect(task.status).toBe(201);
1297
+
1298
+ const { status, body } = await post("/api/memory/index", {
1299
+ agentId: ids.workerAgent,
1300
+ body: {
1301
+ content: `Runner summary that should not persist for ${taskCase.label}.`,
1302
+ name: `automatic-session-summary-${taskCase.label}`,
1303
+ scope: "agent",
1304
+ source: "session_summary",
1305
+ sourceTaskId: task.body.id,
1306
+ },
1307
+ });
1308
+ expect(status).toBe(202);
1309
+ expect(body.queued).toBe(false);
1310
+ expect(body.memoryIds).toEqual([]);
1311
+ expect(body.skipped).toBe("automatic_task_memory_disabled");
1312
+ }
1313
+ });
1314
+
1315
+ test("POST /api/memory/index — lets runner session summaries opt in for automatic tasks", async () => {
1316
+ const task = await post("/api/tasks", {
1317
+ agentId: ids.leadAgent,
1318
+ body: {
1319
+ task: "Scheduled memory integration opt-in test",
1320
+ agentId: ids.workerAgent,
1321
+ source: "schedule",
1322
+ taskType: "daily-digest",
1323
+ tags: ["schedule:test"],
1324
+ },
1325
+ });
1326
+ expect(task.status).toBe(201);
1327
+
1328
+ const { status, body } = await post("/api/memory/index", {
1329
+ agentId: ids.workerAgent,
1330
+ body: {
1331
+ content: "Runner summary with reusable learning that explicitly opts into persistence.",
1332
+ name: "automatic-session-summary-opt-in",
1333
+ scope: "agent",
1334
+ source: "session_summary",
1335
+ sourceTaskId: task.body.id,
1336
+ persistMemory: true,
1337
+ },
1338
+ });
1339
+ expect(status).toBe(202);
1340
+ expect(body.queued).toBe(true);
1341
+ expect(body.memoryIds.length).toBeGreaterThan(0);
1342
+ });
1343
+
1344
+ test("POST /api/memory/index — keeps runner session summaries for manual tasks", async () => {
1345
+ const task = await post("/api/tasks", {
1346
+ agentId: ids.leadAgent,
1347
+ body: {
1348
+ task: "Manual memory integration test",
1349
+ agentId: ids.workerAgent,
1350
+ },
1351
+ });
1352
+ expect(task.status).toBe(201);
1353
+
1354
+ const { status, body } = await post("/api/memory/index", {
1355
+ agentId: ids.workerAgent,
1356
+ body: {
1357
+ content: "Runner summary that should persist for a manual task.",
1358
+ name: "manual-session-summary",
1359
+ scope: "agent",
1360
+ source: "session_summary",
1361
+ sourceTaskId: task.body.id,
1362
+ },
1363
+ });
1364
+ expect(status).toBe(202);
1365
+ expect(body.queued).toBe(true);
1366
+ expect(body.memoryIds.length).toBeGreaterThan(0);
1367
+
1368
+ const memory = await get(`/api/memory/${body.memoryIds[0]}`, { agentId: ids.workerAgent });
1369
+ expect(memory.status).toBe(200);
1370
+ expect(memory.body.memory.sourceTaskId).toBe(task.body.id);
1371
+ });
1372
+
1260
1373
  test("POST /api/memory/search — missing query returns 400", async () => {
1261
1374
  const { status } = await post("/api/memory/search", {
1262
1375
  body: {},
@@ -1,5 +1,9 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { sendKapsoText } from "../integrations/kapso/client";
2
+ import {
3
+ markKapsoMessageRead,
4
+ sendKapsoReaction,
5
+ sendKapsoText,
6
+ } from "../integrations/kapso/client";
3
7
 
4
8
  const originalFetch = globalThis.fetch;
5
9
 
@@ -92,3 +96,72 @@ describe("sendKapsoText", () => {
92
96
  expect(result.errorMessage).toContain("Invalid API key");
93
97
  });
94
98
  });
99
+
100
+ describe("Kapso message actions", () => {
101
+ test("markKapsoMessageRead can include the typing indicator", async () => {
102
+ let captured: { url: string; body: unknown } | null = null;
103
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
104
+ captured = { url, body: JSON.parse(init.body as string) };
105
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
106
+ }) as typeof fetch;
107
+
108
+ const result = await markKapsoMessageRead({
109
+ apiBaseUrl: "https://api.kapso.ai",
110
+ apiKey: "k",
111
+ phoneNumberId: "p",
112
+ messageId: "wamid.IN",
113
+ typingIndicatorType: "text",
114
+ });
115
+
116
+ expect(result.ok).toBe(true);
117
+ expect(captured!.url).toBe("https://api.kapso.ai/meta/whatsapp/v24.0/p/messages");
118
+ expect(captured!.body).toEqual({
119
+ messaging_product: "whatsapp",
120
+ status: "read",
121
+ message_id: "wamid.IN",
122
+ typing_indicator: { type: "text" },
123
+ });
124
+ });
125
+
126
+ test("sendKapsoReaction posts the eyes reaction payload", async () => {
127
+ let body: Record<string, unknown> | null = null;
128
+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
129
+ body = JSON.parse(init.body as string);
130
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.REACT" }] }), {
131
+ status: 200,
132
+ });
133
+ }) as typeof fetch;
134
+
135
+ const result = await sendKapsoReaction({
136
+ apiBaseUrl: "https://api.kapso.ai",
137
+ apiKey: "k",
138
+ phoneNumberId: "p",
139
+ to: "34679077777",
140
+ messageId: "wamid.IN",
141
+ emoji: "👀",
142
+ });
143
+
144
+ expect(result.ok).toBe(true);
145
+ expect(body).toEqual({
146
+ messaging_product: "whatsapp",
147
+ recipient_type: "individual",
148
+ to: "34679077777",
149
+ type: "reaction",
150
+ reaction: { message_id: "wamid.IN", emoji: "👀" },
151
+ });
152
+ });
153
+
154
+ test("message action errors return structured failures", async () => {
155
+ mockFetch(400, { error: { message: "bad message id" } });
156
+
157
+ const result = await markKapsoMessageRead({
158
+ apiBaseUrl: "https://api.kapso.ai",
159
+ apiKey: "k",
160
+ phoneNumberId: "p",
161
+ messageId: "wamid.BAD",
162
+ });
163
+
164
+ expect(result.ok).toBe(false);
165
+ expect(result.errorMessage).toBe("bad message id");
166
+ });
167
+ });
@@ -1,4 +1,4 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
2
  import crypto from "node:crypto";
3
3
  import type { IncomingMessage, ServerResponse } from "node:http";
4
4
  import { closeDb, createAgent, createUser, getKv, getTaskById, initDb } from "../be/db";
@@ -11,6 +11,7 @@ const TEST_DB_PATH = "./test-kapso-inbound.sqlite";
11
11
  const HMAC_SECRET = "kapso-test-hmac-secret";
12
12
 
13
13
  let agentId: string;
14
+ const originalFetch = globalThis.fetch;
14
15
 
15
16
  function makePayload(opts: {
16
17
  phoneNumberId: string;
@@ -77,13 +78,21 @@ beforeAll(() => {
77
78
  }
78
79
  initDb(TEST_DB_PATH);
79
80
  process.env.KAPSO_WEBHOOK_HMAC_SECRET = HMAC_SECRET;
81
+ process.env.KAPSO_API_KEY = "kapso-test-api-key";
82
+ process.env.KAPSO_API_BASE_URL = "https://kapso.test";
80
83
  const agent = createAgent({ name: "KapsoWorker", isLead: false, status: "idle" });
81
84
  agentId = agent.id;
82
85
  });
83
86
 
87
+ afterEach(() => {
88
+ globalThis.fetch = originalFetch;
89
+ });
90
+
84
91
  afterAll(() => {
85
92
  closeDb();
86
93
  delete process.env.KAPSO_WEBHOOK_HMAC_SECRET;
94
+ delete process.env.KAPSO_API_KEY;
95
+ delete process.env.KAPSO_API_BASE_URL;
87
96
  for (const suffix of ["", "-wal", "-shm"]) {
88
97
  try {
89
98
  require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
@@ -205,12 +214,18 @@ describe("routeKapsoInbound", () => {
205
214
  });
206
215
 
207
216
  describe("handleWebhooks — Kapso HMAC gate", () => {
208
- test("valid HMAC + mapping hit → 200 and task routing", async () => {
217
+ test("valid HMAC + mapping hit → auto-acknowledges inbound, then 200 and task routing", async () => {
209
218
  putKapsoNumberMapping({
210
219
  phoneNumberId: "pn-http",
211
220
  agentId,
212
221
  createdAt: new Date().toISOString(),
213
222
  });
223
+ const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
224
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
225
+ calls.push({ url, body: JSON.parse(init.body as string) });
226
+ return new Response(JSON.stringify({ success: true }), { status: 200 });
227
+ }) as typeof fetch;
228
+
214
229
  const rawBody = JSON.stringify(
215
230
  makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_OK" }),
216
231
  );
@@ -221,9 +236,52 @@ describe("handleWebhooks — Kapso HMAC gate", () => {
221
236
  expect(handled).toBe(true);
222
237
  expect(captured.status).toBe(200);
223
238
  expect(JSON.parse(captured.body)).toMatchObject({ received: true, routing: "task" });
239
+ expect(calls).toHaveLength(2);
240
+ expect(
241
+ calls.every((call) => call.url === "https://kapso.test/meta/whatsapp/v24.0/pn-http/messages"),
242
+ ).toBe(true);
243
+ expect(calls.map((call) => call.body)).toContainEqual({
244
+ messaging_product: "whatsapp",
245
+ status: "read",
246
+ message_id: "wamid.HTTP_OK",
247
+ typing_indicator: { type: "text" },
248
+ });
249
+ expect(calls.map((call) => call.body)).toContainEqual({
250
+ messaging_product: "whatsapp",
251
+ recipient_type: "individual",
252
+ to: "34679077777",
253
+ type: "reaction",
254
+ reaction: { message_id: "wamid.HTTP_OK", emoji: "👀" },
255
+ });
256
+ });
257
+
258
+ test("Kapso acknowledgement failures do not block webhook success", async () => {
259
+ putKapsoNumberMapping({
260
+ phoneNumberId: "pn-http-ack-fail",
261
+ agentId,
262
+ createdAt: new Date().toISOString(),
263
+ });
264
+ globalThis.fetch = (async () => {
265
+ throw new Error("kapso unavailable");
266
+ }) as typeof fetch;
267
+
268
+ const rawBody = JSON.stringify(
269
+ makePayload({ phoneNumberId: "pn-http-ack-fail", messageId: "wamid.HTTP_ACK_FAIL" }),
270
+ );
271
+ const { req, res, captured } = fakeReqRes(rawBody, {
272
+ "x-webhook-signature": sign(HMAC_SECRET, rawBody),
273
+ });
274
+ const handled = await handleWebhooks(req, res, KAPSO_PATH);
275
+
276
+ expect(handled).toBe(true);
277
+ expect(captured.status).toBe(200);
278
+ expect(JSON.parse(captured.body)).toMatchObject({ received: true, routing: "task" });
224
279
  });
225
280
 
226
281
  test("valid HMAC + no mapping → 200 no_mapping (fallback, does not break)", async () => {
282
+ globalThis.fetch = (async () =>
283
+ new Response(JSON.stringify({ success: true }), { status: 200 })) as typeof fetch;
284
+
227
285
  const rawBody = JSON.stringify(
228
286
  makePayload({ phoneNumberId: "pn-http-unmapped", messageId: "wamid.HTTP_NOMAP" }),
229
287
  );
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
10
+ import { writeFileSync } from "node:fs";
10
11
  import { join } from "node:path";
11
12
  import type { Event as OpencodeEvent } from "@opencode-ai/sdk";
12
13
  import type { ProviderEvent, ProviderResult, ProviderSessionConfig } from "../providers/types";
@@ -640,3 +641,97 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
640
641
  await Bun.$`rm -rf /tmp/opencode-data-task-cfg-json`.quiet().nothrow();
641
642
  });
642
643
  });
644
+
645
+ // ── Phase 4: context-mode in-process plugin ────────────────────────────────────
646
+
647
+ describe("OpencodeAdapter — context-mode plugin wiring (phase 4)", () => {
648
+ let prevContextModeDisabled: string | undefined;
649
+ let prevContextModePluginPath: string | undefined;
650
+ // The global npm install of context-mode is absent in the test env, so point
651
+ // the override at a real temp file to make resolution succeed deterministically.
652
+ const fakePluginPath = "/tmp/ctx-mode-opencode-plugin.test.js";
653
+
654
+ beforeEach(() => {
655
+ prevContextModeDisabled = process.env.CONTEXT_MODE_DISABLED;
656
+ prevContextModePluginPath = process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
657
+ lastCreateOpencodeConfig = undefined;
658
+ mock.restore();
659
+ });
660
+
661
+ afterEach(() => {
662
+ // Never leak the env mutations across tests.
663
+ if (prevContextModeDisabled === undefined) delete process.env.CONTEXT_MODE_DISABLED;
664
+ else process.env.CONTEXT_MODE_DISABLED = prevContextModeDisabled;
665
+ if (prevContextModePluginPath === undefined)
666
+ delete process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
667
+ else process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = prevContextModePluginPath;
668
+ Bun.$`rm -rf /tmp/opencode-task-1.json /tmp/opencode-data-task-1`.quiet().nothrow();
669
+ Bun.$`rm -rf /tmp/test/.opencode`.quiet().nothrow();
670
+ Bun.$`rm -f ${fakePluginPath}`.quiet().nothrow();
671
+ });
672
+
673
+ /** Pull the opencode config object passed to createOpencode. */
674
+ function getBuiltConfig(): { plugin?: string[]; mcp?: Record<string, unknown> } {
675
+ const opts = lastCreateOpencodeConfig as {
676
+ config?: { plugin?: string[]; mcp?: Record<string, unknown> };
677
+ };
678
+ expect(opts.config).toBeDefined();
679
+ return opts.config as { plugin?: string[]; mcp?: Record<string, unknown> };
680
+ }
681
+
682
+ test("resolveContextModePluginPath returns the override path when it exists", async () => {
683
+ writeFileSync(fakePluginPath, "// test plugin\n");
684
+ process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
685
+ const { resolveContextModePluginPath } = await import("../providers/opencode-adapter");
686
+ expect(resolveContextModePluginPath()).toBe(fakePluginPath);
687
+ });
688
+
689
+ test("resolveContextModePluginPath returns null when the override path is missing", async () => {
690
+ process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = "/tmp/ctx-mode-does-not-exist.js";
691
+ const { resolveContextModePluginPath } = await import("../providers/opencode-adapter");
692
+ expect(resolveContextModePluginPath()).toBeNull();
693
+ });
694
+
695
+ test("plugin array includes the resolved context-mode plugin path when available", async () => {
696
+ delete process.env.CONTEXT_MODE_DISABLED;
697
+ writeFileSync(fakePluginPath, "// test plugin\n");
698
+ process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
699
+ const events: OpencodeEvent[] = [
700
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
701
+ ];
702
+ await driveSession(events, testConfig({ taskId: "task-1" }));
703
+
704
+ const built = getBuiltConfig();
705
+ expect(built.plugin).toContain(fakePluginPath);
706
+ // The bare package name must never be used — opencode can't resolve it offline.
707
+ expect(built.plugin).not.toContain("context-mode");
708
+ });
709
+
710
+ test("plugin array excludes context-mode when CONTEXT_MODE_DISABLED=true", async () => {
711
+ process.env.CONTEXT_MODE_DISABLED = "true";
712
+ writeFileSync(fakePluginPath, "// test plugin\n");
713
+ process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
714
+ const events: OpencodeEvent[] = [
715
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
716
+ ];
717
+ await driveSession(events, testConfig({ taskId: "task-1" }));
718
+
719
+ const built = getBuiltConfig();
720
+ expect(built.plugin).not.toContain(fakePluginPath);
721
+ expect(built.plugin).not.toContain("context-mode");
722
+ });
723
+
724
+ test("context-mode does NOT appear in the mcp block", async () => {
725
+ delete process.env.CONTEXT_MODE_DISABLED;
726
+ writeFileSync(fakePluginPath, "// test plugin\n");
727
+ process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
728
+ const events: OpencodeEvent[] = [
729
+ { type: "session.idle", properties: { sessionID: "sess-abc-123" } },
730
+ ];
731
+ await driveSession(events, testConfig({ taskId: "task-1" }));
732
+
733
+ const built = getBuiltConfig();
734
+ expect(built.mcp).toBeDefined();
735
+ expect(built.mcp?.["context-mode"]).toBeUndefined();
736
+ });
737
+ });
@@ -90,10 +90,12 @@ describe("Session templates — registration", () => {
90
90
  }
91
91
  });
92
92
 
93
- test("total of 19 session/system templates registered", () => {
93
+ test("total of 20 session/system templates registered", () => {
94
94
  const all = getAllTemplateDefinitions();
95
95
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
96
- expect(sessionSystem.length).toBe(19);
96
+ // 20 = the original 19 + `system.session.worker.pi` (a pi-specific worker
97
+ // composite that omits the context_mode block — see session-templates.ts).
98
+ expect(sessionSystem.length).toBe(20);
97
99
  });
98
100
  });
99
101
 
@@ -10,6 +10,11 @@ import {
10
10
  initDb,
11
11
  } from "../be/db";
12
12
  import { SqliteMemoryStore } from "../be/memory/providers/sqlite-store";
13
+ import {
14
+ isAutomaticOrRecurringTaskCompletion,
15
+ isScheduledTaskCompletion,
16
+ shouldPersistTaskCompletionMemory,
17
+ } from "../memory/automatic-task-gate";
13
18
  import { getBasePrompt } from "../prompts/base-prompt";
14
19
 
15
20
  const TEST_DB_PATH = "./test-self-improvement.sqlite";
@@ -143,6 +148,90 @@ describe("Self-Improvement Mechanisms", () => {
143
148
  expect(shortContent.length).toBeLessThan(30);
144
149
  // In store-progress, this would return early without creating memory
145
150
  });
151
+
152
+ test("manual task completions persist memory by default", () => {
153
+ const task = createTaskExtended("Manual implementation task", {
154
+ agentId: workerId,
155
+ source: "mcp",
156
+ priority: 50,
157
+ tags: ["memory", "bug-fix"],
158
+ });
159
+
160
+ expect(isScheduledTaskCompletion(task)).toBe(false);
161
+ expect(shouldPersistTaskCompletionMemory(task)).toBe(true);
162
+ });
163
+
164
+ test("scheduled task completions skip memory by default", () => {
165
+ const task = createTaskExtended("Run heartbeat checklist", {
166
+ agentId: workerId,
167
+ source: "schedule",
168
+ priority: 50,
169
+ tags: ["scheduled", "schedule:heartbeat-checklist"],
170
+ });
171
+
172
+ expect(isScheduledTaskCompletion(task)).toBe(true);
173
+ expect(isAutomaticOrRecurringTaskCompletion(task)).toBe(true);
174
+ expect(shouldPersistTaskCompletionMemory(task)).toBe(false);
175
+ });
176
+
177
+ test("heartbeat checklist completions skip memory without schedule tags", () => {
178
+ const task = createTaskExtended("Run heartbeat checklist", {
179
+ agentId: workerId,
180
+ source: "mcp",
181
+ priority: 60,
182
+ taskType: "heartbeat-checklist",
183
+ tags: ["checklist", "auto-generated"],
184
+ });
185
+
186
+ expect(isScheduledTaskCompletion(task)).toBe(false);
187
+ expect(isAutomaticOrRecurringTaskCompletion(task)).toBe(true);
188
+ expect(shouldPersistTaskCompletionMemory(task)).toBe(false);
189
+ });
190
+
191
+ test("boot triage completions skip memory by default", () => {
192
+ const task = createTaskExtended("Triage reboot-interrupted work", {
193
+ agentId: workerId,
194
+ source: "mcp",
195
+ priority: 80,
196
+ taskType: "boot-triage",
197
+ tags: ["boot", "triage", "auto-generated"],
198
+ });
199
+
200
+ expect(isAutomaticOrRecurringTaskCompletion(task)).toBe(true);
201
+ expect(shouldPersistTaskCompletionMemory(task)).toBe(false);
202
+ });
203
+
204
+ test("monitor and digest completions skip memory by default", () => {
205
+ const monitorTask = createTaskExtended("Check Claude Code changelog", {
206
+ agentId: workerId,
207
+ source: "schedule",
208
+ priority: 50,
209
+ taskType: "monitoring",
210
+ tags: ["health", "schedule:claude-code-changelog-monitor"],
211
+ });
212
+ const digestTask = createTaskExtended("Compile daily blocker digest", {
213
+ agentId: workerId,
214
+ source: "schedule",
215
+ priority: 50,
216
+ tags: ["scheduled", "schedule:daily-blocker-digest"],
217
+ });
218
+
219
+ expect(isAutomaticOrRecurringTaskCompletion(monitorTask)).toBe(true);
220
+ expect(shouldPersistTaskCompletionMemory(monitorTask)).toBe(false);
221
+ expect(isAutomaticOrRecurringTaskCompletion(digestTask)).toBe(true);
222
+ expect(shouldPersistTaskCompletionMemory(digestTask)).toBe(false);
223
+ });
224
+
225
+ test("scheduled task completions can opt in to memory persistence", () => {
226
+ const task = createTaskExtended("Daily digest found reusable incident pattern", {
227
+ agentId: workerId,
228
+ source: "schedule",
229
+ priority: 50,
230
+ tags: ["scheduled", "schedule:daily-blocker-digest"],
231
+ });
232
+
233
+ expect(shouldPersistTaskCompletionMemory(task, true)).toBe(true);
234
+ });
146
235
  });
147
236
 
148
237
  // ==========================================================================
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { closeDb, createAgent, createSkill, getSkillById, initDb } from "../be/db";
5
+ import { registerSkillDeleteTool } from "../tools/skills/skill-delete";
5
6
  import { registerSkillUpdateTool } from "../tools/skills/skill-update";
6
7
 
7
8
  const TEST_DB_PATH = "./test-skill-update-scope.sqlite";
@@ -39,7 +40,30 @@ async function callSkillUpdate(
39
40
  return result as { structuredContent: StructuredContent };
40
41
  }
41
42
 
42
- describe("skill-update scope promotion", () => {
43
+ async function callSkillDelete(
44
+ server: McpServer,
45
+ callerAgentId: string | undefined,
46
+ args: Record<string, unknown>,
47
+ ): Promise<{ structuredContent: StructuredContent }> {
48
+ // biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
49
+ const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
50
+ ._registeredTools;
51
+ const handler = tools["skill-delete"].handler;
52
+
53
+ const extra = {
54
+ sessionId: "test-session",
55
+ requestInfo: {
56
+ headers: {
57
+ "x-agent-id": callerAgentId ?? "",
58
+ },
59
+ },
60
+ };
61
+
62
+ const result = await handler(args, extra);
63
+ return result as { structuredContent: StructuredContent };
64
+ }
65
+
66
+ describe("skill mutation tools", () => {
43
67
  let server: McpServer;
44
68
 
45
69
  beforeAll(async () => {
@@ -59,6 +83,7 @@ describe("skill-update scope promotion", () => {
59
83
 
60
84
  server = new McpServer({ name: "test-skill-update-scope", version: "1.0.0" });
61
85
  registerSkillUpdateTool(server);
86
+ registerSkillDeleteTool(server);
62
87
  });
63
88
 
64
89
  afterAll(async () => {
@@ -162,4 +187,66 @@ describe("skill-update scope promotion", () => {
162
187
  expect(stored?.scope).toBe("agent");
163
188
  expect(stored?.isEnabled).toBe(false);
164
189
  });
190
+
191
+ test("system-default skill content updates are rejected", async () => {
192
+ const skill = createSkill({
193
+ name: "system-content-locked",
194
+ description: "System content lock",
195
+ content: "---\nname: system-content-locked\ndescription: System content lock\n---\n\nBody.",
196
+ type: "personal",
197
+ scope: "swarm",
198
+ ownerAgentId: WORKER_ID,
199
+ systemDefault: true,
200
+ });
201
+
202
+ const result = await callSkillUpdate(server, LEAD_ID, {
203
+ skillId: skill.id,
204
+ content: "---\nname: system-content-locked\ndescription: Changed\n---\n\nChanged.",
205
+ });
206
+
207
+ expect(result.structuredContent.success).toBe(false);
208
+ expect(result.structuredContent.message).toContain("system-managed");
209
+
210
+ const stored = getSkillById(skill.id);
211
+ expect(stored?.description).toBe("System content lock");
212
+ expect(stored?.version).toBe(1);
213
+ });
214
+
215
+ test("system-default skill enable toggle remains allowed", async () => {
216
+ const skill = createSkill({
217
+ name: "system-toggle-allowed",
218
+ description: "System toggle",
219
+ content: "---\nname: system-toggle-allowed\ndescription: System toggle\n---\n\nBody.",
220
+ type: "personal",
221
+ scope: "swarm",
222
+ ownerAgentId: WORKER_ID,
223
+ systemDefault: true,
224
+ });
225
+
226
+ const result = await callSkillUpdate(server, LEAD_ID, {
227
+ skillId: skill.id,
228
+ isEnabled: false,
229
+ });
230
+
231
+ expect(result.structuredContent.success).toBe(true);
232
+ expect(getSkillById(skill.id)?.isEnabled).toBe(false);
233
+ });
234
+
235
+ test("system-default skill deletes are rejected", async () => {
236
+ const skill = createSkill({
237
+ name: "system-delete-locked",
238
+ description: "System delete lock",
239
+ content: "---\nname: system-delete-locked\ndescription: System delete lock\n---\n\nBody.",
240
+ type: "personal",
241
+ scope: "swarm",
242
+ ownerAgentId: WORKER_ID,
243
+ systemDefault: true,
244
+ });
245
+
246
+ const result = await callSkillDelete(server, LEAD_ID, { skillId: skill.id });
247
+
248
+ expect(result.structuredContent.success).toBe(false);
249
+ expect(result.structuredContent.message).toContain("system-managed");
250
+ expect(getSkillById(skill.id)).not.toBeNull();
251
+ });
165
252
  });
@@ -821,6 +821,21 @@ describe("buildTreeBlocks", () => {
821
821
  expect(blocks.length).toBe(2);
822
822
  });
823
823
 
824
+ test("offered root shows offered icon without undefined text", () => {
825
+ const root: TreeNode = {
826
+ taskId: makeTaskId("loff0001"),
827
+ agentName: "OfferedAgent",
828
+ status: "offered",
829
+ children: [],
830
+ };
831
+
832
+ const blocks = buildTreeBlocks([root]);
833
+ const text = blocks[0].text.text;
834
+
835
+ expect(text).toContain("📨 *OfferedAgent*");
836
+ expect(text).not.toContain("undefined");
837
+ });
838
+
824
839
  test("cancelled root shows cancel icon with no cancel button", () => {
825
840
  const root: TreeNode = {
826
841
  taskId: makeTaskId("mmmm0001"),