@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.
- package/README.md +2 -1
- package/openapi.json +13 -1
- package/package.json +5 -5
- package/src/be/db.ts +49 -7
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/modelsdev-cache.json +1123 -1034
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +172 -0
- package/src/cli.tsx +33 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +1 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/http/memory.ts +13 -1
- package/src/http/skills.ts +53 -0
- package/src/http/webhooks.ts +75 -0
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/prompts/base-prompt.ts +16 -1
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +50 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +41 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/http-api-integration.test.ts +113 -0
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/opencode-adapter.test.ts +95 -0
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/system-default-skills.test.ts +119 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/types.ts +1 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"),
|