@desplega.ai/agent-swarm 1.87.0 → 1.89.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 +5 -1
- package/openapi.json +53 -1
- package/package.json +6 -5
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +374 -9
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/migrations/081_metrics.sql +39 -0
- package/src/be/migrations/082_user_audit_fields.sql +120 -0
- package/src/be/modelsdev-cache.json +3825 -2417
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +179 -0
- package/src/cli.tsx +51 -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 +154 -22
- package/src/commands/x.ts +118 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/github/handlers.ts +40 -1
- package/src/heartbeat/heartbeat.ts +26 -5
- package/src/http/active-sessions.ts +32 -1
- package/src/http/auth.ts +36 -0
- package/src/http/core.ts +20 -16
- package/src/http/db-query.ts +20 -0
- package/src/http/index.ts +2 -0
- package/src/http/memory.ts +13 -1
- package/src/http/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/skills.ts +53 -0
- package/src/http/tasks.ts +4 -1
- package/src/http/webhooks.ts +75 -0
- package/src/http/workflows.ts +5 -1
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +24 -1
- package/src/prompts/session-templates.ts +74 -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 +72 -7
- package/src/server.ts +10 -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 +76 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/core-auth.test.ts +8 -1
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/events-http.test.ts +6 -2
- package/src/tests/github-handlers-cancel-config.test.ts +262 -0
- package/src/tests/heartbeat.test.ts +84 -3
- package/src/tests/http-api-integration.test.ts +116 -1
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +185 -30
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/runner-repo-autostash.test.ts +117 -0
- package/src/tests/runner-requester-profile.test.ts +25 -0
- package/src/tests/runner-skills-refresh.test.ts +1 -1
- 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/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +122 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tests/ui-logs-parser.test.ts +271 -0
- package/src/tests/user-token-rest-auth.test.ts +129 -0
- package/src/tests/workflow-async-v2.test.ts +23 -0
- package/src/tests/x-composio.test.ts +122 -0
- package/src/tools/create-metric.ts +191 -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/tools/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +121 -0
- package/src/utils/request-auth-context.ts +28 -0
- package/src/utils/skills-refresh.ts +2 -2
- package/src/workflows/engine.ts +24 -2
- package/src/workflows/executors/agent-task.ts +2 -0
- package/src/x/composio.ts +295 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -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
|
@@ -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
|
);
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
createServer as createHttpServer,
|
|
6
|
+
type IncomingMessage,
|
|
7
|
+
type Server,
|
|
8
|
+
type ServerResponse,
|
|
9
|
+
} from "node:http";
|
|
10
|
+
import { closeDb, getMetricVersions, initDb } from "../be/db";
|
|
11
|
+
import { handleMetrics } from "../http/metrics";
|
|
12
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
13
|
+
import type { Metric } from "../types";
|
|
14
|
+
|
|
15
|
+
const TEST_DB_PATH = "./test-metrics-http.sqlite";
|
|
16
|
+
const TEST_PORT = 13083;
|
|
17
|
+
const BASE = `http://localhost:${TEST_PORT}`;
|
|
18
|
+
|
|
19
|
+
type MetricRunResponse = {
|
|
20
|
+
widgets: Array<{
|
|
21
|
+
widget: { id: string };
|
|
22
|
+
result: {
|
|
23
|
+
columns: string[];
|
|
24
|
+
rows: Record<string, unknown>[];
|
|
25
|
+
};
|
|
26
|
+
}>;
|
|
27
|
+
result: {
|
|
28
|
+
columns: string[];
|
|
29
|
+
rows: Record<string, unknown>[];
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function createTestServer(): Server {
|
|
34
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
35
|
+
res.setHeader("Content-Type", "application/json");
|
|
36
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
37
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
38
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
39
|
+
const handled = await handleMetrics(req, res, pathSegments, queryParams, myAgentId);
|
|
40
|
+
if (!handled) {
|
|
41
|
+
res.writeHead(404);
|
|
42
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("Metrics HTTP API", () => {
|
|
48
|
+
let server: Server;
|
|
49
|
+
const agentId = crypto.randomUUID();
|
|
50
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
54
|
+
try {
|
|
55
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
initDb(TEST_DB_PATH);
|
|
59
|
+
server = createTestServer();
|
|
60
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
65
|
+
closeDb();
|
|
66
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
67
|
+
try {
|
|
68
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("fresh DB seeds starter metrics", async () => {
|
|
74
|
+
const res = await fetch(`${BASE}/api/metrics/definitions?fields=full`);
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
const body = (await res.json()) as { metrics: Metric[]; total: number };
|
|
77
|
+
expect(body.total).toBeGreaterThanOrEqual(1);
|
|
78
|
+
const starter = body.metrics.find((metric) => metric.slug === "swarm-operations-overview");
|
|
79
|
+
expect(starter?.definition.widgets.map((widget) => widget.viz.type)).toContain("multi-line");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("create, run, update snapshots prior definition", async () => {
|
|
83
|
+
const created = await fetch(`${BASE}/api/metrics/definitions`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers,
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
slug: "test-count",
|
|
88
|
+
title: "Test Count",
|
|
89
|
+
description: "Counts agent rows",
|
|
90
|
+
definition: {
|
|
91
|
+
version: 1,
|
|
92
|
+
widgets: [
|
|
93
|
+
{
|
|
94
|
+
id: "agent-count",
|
|
95
|
+
title: "Agent count",
|
|
96
|
+
query: { sql: "SELECT COUNT(*) AS count FROM agents", maxRows: 10 },
|
|
97
|
+
viz: { type: "stat", value: "count", format: "integer" },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
expect(created.status).toBe(201);
|
|
104
|
+
const { id } = (await created.json()) as { id: string; version: number };
|
|
105
|
+
|
|
106
|
+
const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers,
|
|
109
|
+
body: JSON.stringify({ variables: {} }),
|
|
110
|
+
});
|
|
111
|
+
expect(run.status).toBe(200);
|
|
112
|
+
const runBody = (await run.json()) as MetricRunResponse;
|
|
113
|
+
expect(runBody.widgets[0]?.result.columns).toEqual(["count"]);
|
|
114
|
+
expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
|
|
115
|
+
|
|
116
|
+
const updated = await fetch(`${BASE}/api/metrics/definitions/${id}`, {
|
|
117
|
+
method: "PUT",
|
|
118
|
+
headers,
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
title: "Updated Count",
|
|
121
|
+
definition: {
|
|
122
|
+
version: 1,
|
|
123
|
+
widgets: [
|
|
124
|
+
{
|
|
125
|
+
id: "task-count",
|
|
126
|
+
title: "Task count",
|
|
127
|
+
query: { sql: "SELECT COUNT(*) AS count FROM agent_tasks", maxRows: 10 },
|
|
128
|
+
viz: { type: "stat", value: "count", format: "integer" },
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
expect(updated.status).toBe(200);
|
|
135
|
+
expect(getMetricVersions(id)).toHaveLength(1);
|
|
136
|
+
expect(getMetricVersions(id)[0]?.snapshot.title).toBe("Test Count");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("humans can create metrics through the UI without an agent header", async () => {
|
|
140
|
+
const created = await fetch(`${BASE}/api/metrics/definitions`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
slug: "ui-owned-count",
|
|
145
|
+
title: "UI Owned Count",
|
|
146
|
+
definition: {
|
|
147
|
+
version: 1,
|
|
148
|
+
widgets: [
|
|
149
|
+
{
|
|
150
|
+
id: "task-count",
|
|
151
|
+
title: "Task count",
|
|
152
|
+
query: { sql: "SELECT COUNT(*) AS count FROM agent_tasks", maxRows: 10 },
|
|
153
|
+
viz: { type: "stat", value: "count", format: "integer" },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
expect(created.status).toBe(201);
|
|
160
|
+
const { id } = (await created.json()) as { id: string; version: number };
|
|
161
|
+
|
|
162
|
+
const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({ variables: {} }),
|
|
166
|
+
});
|
|
167
|
+
expect(run.status).toBe(200);
|
|
168
|
+
const runBody = (await run.json()) as MetricRunResponse;
|
|
169
|
+
expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("run binds metric variables into query params", async () => {
|
|
173
|
+
const created = await fetch(`${BASE}/api/metrics/definitions`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers,
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
slug: "variable-count",
|
|
178
|
+
title: "Variable Count",
|
|
179
|
+
definition: {
|
|
180
|
+
version: 1,
|
|
181
|
+
variables: [
|
|
182
|
+
{
|
|
183
|
+
key: "status",
|
|
184
|
+
label: "Status",
|
|
185
|
+
type: "select",
|
|
186
|
+
defaultValue: "pending",
|
|
187
|
+
options: [
|
|
188
|
+
{ label: "Pending", value: "pending" },
|
|
189
|
+
{ label: "Completed", value: "completed" },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
widgets: [
|
|
194
|
+
{
|
|
195
|
+
id: "status-count",
|
|
196
|
+
title: "Status count",
|
|
197
|
+
query: {
|
|
198
|
+
sql: "SELECT COUNT(*) AS count FROM agent_tasks WHERE status = ?",
|
|
199
|
+
params: ["{{status}}"],
|
|
200
|
+
maxRows: 10,
|
|
201
|
+
},
|
|
202
|
+
viz: { type: "stat", value: "count", format: "integer" },
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
expect(created.status).toBe(201);
|
|
209
|
+
const { id } = (await created.json()) as { id: string; version: number };
|
|
210
|
+
|
|
211
|
+
const run = await fetch(`${BASE}/api/metrics/definitions/${id}/run`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers,
|
|
214
|
+
body: JSON.stringify({ variables: { status: "completed" } }),
|
|
215
|
+
});
|
|
216
|
+
expect(run.status).toBe(200);
|
|
217
|
+
const runBody = (await run.json()) as MetricRunResponse & {
|
|
218
|
+
variables: Record<string, string>;
|
|
219
|
+
};
|
|
220
|
+
expect(runBody.variables.status).toBe("completed");
|
|
221
|
+
expect(runBody.widgets[0]?.result.rows[0]).toHaveProperty("count");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("saved metric SQL rejects writes and multiple statements", async () => {
|
|
225
|
+
for (const sql of ["DELETE FROM agent_tasks", "SELECT 1; SELECT 2"]) {
|
|
226
|
+
const res = await fetch(`${BASE}/api/metrics/definitions`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers,
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
title: "Bad Metric",
|
|
231
|
+
definition: {
|
|
232
|
+
version: 1,
|
|
233
|
+
widgets: [
|
|
234
|
+
{
|
|
235
|
+
id: "bad",
|
|
236
|
+
title: "Bad",
|
|
237
|
+
query: { sql },
|
|
238
|
+
viz: { type: "stat", value: "x" },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
expect(res.status).toBe(400);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -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";
|
|
@@ -47,7 +48,7 @@ let lastCreateOpencodeConfig: unknown;
|
|
|
47
48
|
async function driveSession(
|
|
48
49
|
events: OpencodeEvent[],
|
|
49
50
|
cfg: ProviderSessionConfig = testConfig(),
|
|
50
|
-
): Promise<{ emitted: ProviderEvent[]; result: ProviderResult }> {
|
|
51
|
+
): Promise<{ emitted: ProviderEvent[]; result: ProviderResult; serverCloseCalls: () => number }> {
|
|
51
52
|
const emitted: ProviderEvent[] = [];
|
|
52
53
|
|
|
53
54
|
// Build the fake client/server pair used by the mock
|
|
@@ -67,7 +68,8 @@ async function driveSession(
|
|
|
67
68
|
},
|
|
68
69
|
};
|
|
69
70
|
|
|
70
|
-
const
|
|
71
|
+
const closeServer = mock(() => {});
|
|
72
|
+
const fakeServer = { url: "http://127.0.0.1:12345", close: closeServer };
|
|
71
73
|
|
|
72
74
|
// Install mock BEFORE importing the adapter (Bun hoists mock.module)
|
|
73
75
|
mock.module("@opencode-ai/sdk", () => ({
|
|
@@ -87,7 +89,54 @@ async function driveSession(
|
|
|
87
89
|
await new Promise((r) => setTimeout(r, 0));
|
|
88
90
|
|
|
89
91
|
const result = await session.waitForCompletion();
|
|
90
|
-
return { emitted, result };
|
|
92
|
+
return { emitted, result, serverCloseCalls: () => closeServer.mock.calls.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function inspectSessionBeforeIdle(
|
|
96
|
+
cfg: ProviderSessionConfig,
|
|
97
|
+
inspect: () => Promise<void>,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const fakeSessionId = "sess-abc-123";
|
|
100
|
+
let releaseIdle!: () => void;
|
|
101
|
+
const idleReleased = new Promise<void>((resolve) => {
|
|
102
|
+
releaseIdle = resolve;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const fakeClient = {
|
|
106
|
+
session: {
|
|
107
|
+
create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
|
|
108
|
+
prompt: async (args: unknown) => {
|
|
109
|
+
lastPromptArgs = args;
|
|
110
|
+
return { data: {}, error: undefined };
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
event: {
|
|
114
|
+
subscribe: async () => ({
|
|
115
|
+
stream: (async function* (): AsyncGenerator<OpencodeEvent> {
|
|
116
|
+
await idleReleased;
|
|
117
|
+
yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
|
|
118
|
+
})(),
|
|
119
|
+
}),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
|
|
124
|
+
|
|
125
|
+
mock.module("@opencode-ai/sdk", () => ({
|
|
126
|
+
createOpencode: async (opts: unknown) => {
|
|
127
|
+
lastCreateOpencodeConfig = opts;
|
|
128
|
+
return { client: fakeClient, server: fakeServer };
|
|
129
|
+
},
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
const { OpencodeAdapter } = await import("../providers/opencode-adapter");
|
|
133
|
+
const adapter = new OpencodeAdapter();
|
|
134
|
+
const session = await adapter.createSession(cfg);
|
|
135
|
+
session.onEvent(() => {});
|
|
136
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
137
|
+
await inspect();
|
|
138
|
+
releaseIdle();
|
|
139
|
+
await session.waitForCompletion();
|
|
91
140
|
}
|
|
92
141
|
|
|
93
142
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
@@ -105,7 +154,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
105
154
|
properties: { sessionID: "sess-abc-123" },
|
|
106
155
|
},
|
|
107
156
|
];
|
|
108
|
-
const { emitted, result } = await driveSession(events);
|
|
157
|
+
const { emitted, result, serverCloseCalls } = await driveSession(events);
|
|
109
158
|
|
|
110
159
|
const resultEvent = emitted.find((e) => e.type === "result");
|
|
111
160
|
expect(resultEvent).toBeDefined();
|
|
@@ -117,6 +166,21 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
117
166
|
expect(result.isError).toBe(false);
|
|
118
167
|
expect(result.exitCode).toBe(0);
|
|
119
168
|
expect(result.sessionId).toBe("sess-abc-123");
|
|
169
|
+
expect(serverCloseCalls()).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("session.idle closes the server and drops later heartbeat events", async () => {
|
|
173
|
+
const events: OpencodeEvent[] = [
|
|
174
|
+
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
175
|
+
{ type: "server.heartbeat", properties: {} } as OpencodeEvent,
|
|
176
|
+
];
|
|
177
|
+
const { emitted, serverCloseCalls } = await driveSession(events);
|
|
178
|
+
|
|
179
|
+
expect(serverCloseCalls()).toBe(1);
|
|
180
|
+
const rawLogContents = emitted
|
|
181
|
+
.filter((e): e is Extract<ProviderEvent, { type: "raw_log" }> => e.type === "raw_log")
|
|
182
|
+
.map((e) => e.content);
|
|
183
|
+
expect(rawLogContents.some((content) => content.includes("server.heartbeat"))).toBe(false);
|
|
120
184
|
});
|
|
121
185
|
|
|
122
186
|
test("session.error → emits error event and fails result", async () => {
|
|
@@ -129,7 +193,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
129
193
|
},
|
|
130
194
|
},
|
|
131
195
|
];
|
|
132
|
-
const { emitted, result } = await driveSession(events);
|
|
196
|
+
const { emitted, result, serverCloseCalls } = await driveSession(events);
|
|
133
197
|
|
|
134
198
|
const errorEvent = emitted.find((e) => e.type === "error");
|
|
135
199
|
expect(errorEvent).toBeDefined();
|
|
@@ -139,6 +203,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
139
203
|
expect(result.isError).toBe(true);
|
|
140
204
|
expect(result.exitCode).toBe(1);
|
|
141
205
|
expect(result.failureReason).toContain("provider overloaded");
|
|
206
|
+
expect(serverCloseCalls()).toBe(1);
|
|
142
207
|
});
|
|
143
208
|
|
|
144
209
|
test("prompt Model not found refreshes OpenRouter cache and retries once", async () => {
|
|
@@ -599,44 +664,134 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
|
599
664
|
});
|
|
600
665
|
|
|
601
666
|
test("per-task agent file is written with system prompt", async () => {
|
|
602
|
-
const events: OpencodeEvent[] = [
|
|
603
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
604
|
-
];
|
|
605
667
|
const cwd = `/tmp/opencode-test-agent-${Date.now()}`;
|
|
606
668
|
await Bun.$`mkdir -p ${cwd}`.quiet();
|
|
607
669
|
const cfg = testConfig({ taskId: "task-agent-file", systemPrompt: "be a coder", cwd });
|
|
608
|
-
await
|
|
670
|
+
await inspectSessionBeforeIdle(cfg, async () => {
|
|
671
|
+
const agentFile = Bun.file(join(cwd, ".opencode", "agents", "swarm-task-agent-file.md"));
|
|
672
|
+
const exists = await agentFile.exists();
|
|
673
|
+
expect(exists).toBe(true);
|
|
674
|
+
if (exists) {
|
|
675
|
+
const content = await agentFile.text();
|
|
676
|
+
expect(content).toContain("be a coder");
|
|
677
|
+
}
|
|
678
|
+
});
|
|
609
679
|
|
|
610
|
-
const agentFile = Bun.file(join(cwd, ".opencode", "agents", "swarm-task-agent-file.md"));
|
|
611
|
-
const exists = await agentFile.exists();
|
|
612
|
-
expect(exists).toBe(true);
|
|
613
|
-
if (exists) {
|
|
614
|
-
const content = await agentFile.text();
|
|
615
|
-
expect(content).toContain("be a coder");
|
|
616
|
-
}
|
|
617
680
|
// Cleanup
|
|
618
681
|
await Bun.$`rm -rf ${cwd}`.quiet().nothrow();
|
|
619
682
|
});
|
|
620
683
|
|
|
621
684
|
test("per-task config file is written as valid JSON", async () => {
|
|
622
|
-
const events: OpencodeEvent[] = [
|
|
623
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
624
|
-
];
|
|
625
685
|
const cfg = testConfig({ taskId: "task-cfg-json" });
|
|
626
|
-
await
|
|
686
|
+
await inspectSessionBeforeIdle(cfg, async () => {
|
|
687
|
+
const configFile = Bun.file("/tmp/opencode-task-cfg-json.json");
|
|
688
|
+
const exists = await configFile.exists();
|
|
689
|
+
expect(exists).toBe(true);
|
|
690
|
+
if (exists) {
|
|
691
|
+
const text = await configFile.text();
|
|
692
|
+
expect(() => JSON.parse(text)).not.toThrow();
|
|
693
|
+
const parsed = JSON.parse(text) as { mcp?: unknown; permission?: unknown };
|
|
694
|
+
expect(parsed.mcp).toBeDefined();
|
|
695
|
+
expect(parsed.permission).toBeDefined();
|
|
696
|
+
}
|
|
697
|
+
});
|
|
627
698
|
|
|
628
|
-
const configFile = Bun.file("/tmp/opencode-task-cfg-json.json");
|
|
629
|
-
const exists = await configFile.exists();
|
|
630
|
-
expect(exists).toBe(true);
|
|
631
|
-
if (exists) {
|
|
632
|
-
const text = await configFile.text();
|
|
633
|
-
expect(() => JSON.parse(text)).not.toThrow();
|
|
634
|
-
const parsed = JSON.parse(text) as { mcp?: unknown; permission?: unknown };
|
|
635
|
-
expect(parsed.mcp).toBeDefined();
|
|
636
|
-
expect(parsed.permission).toBeDefined();
|
|
637
|
-
}
|
|
638
699
|
// Cleanup
|
|
639
700
|
await Bun.$`rm -f /tmp/opencode-task-cfg-json.json`.quiet().nothrow();
|
|
640
701
|
await Bun.$`rm -rf /tmp/opencode-data-task-cfg-json`.quiet().nothrow();
|
|
641
702
|
});
|
|
642
703
|
});
|
|
704
|
+
|
|
705
|
+
// ── Phase 4: context-mode in-process plugin ────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
describe("OpencodeAdapter — context-mode plugin wiring (phase 4)", () => {
|
|
708
|
+
let prevContextModeDisabled: string | undefined;
|
|
709
|
+
let prevContextModePluginPath: string | undefined;
|
|
710
|
+
// The global npm install of context-mode is absent in the test env, so point
|
|
711
|
+
// the override at a real temp file to make resolution succeed deterministically.
|
|
712
|
+
const fakePluginPath = "/tmp/ctx-mode-opencode-plugin.test.js";
|
|
713
|
+
|
|
714
|
+
beforeEach(() => {
|
|
715
|
+
prevContextModeDisabled = process.env.CONTEXT_MODE_DISABLED;
|
|
716
|
+
prevContextModePluginPath = process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
|
|
717
|
+
lastCreateOpencodeConfig = undefined;
|
|
718
|
+
mock.restore();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
afterEach(() => {
|
|
722
|
+
// Never leak the env mutations across tests.
|
|
723
|
+
if (prevContextModeDisabled === undefined) delete process.env.CONTEXT_MODE_DISABLED;
|
|
724
|
+
else process.env.CONTEXT_MODE_DISABLED = prevContextModeDisabled;
|
|
725
|
+
if (prevContextModePluginPath === undefined)
|
|
726
|
+
delete process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH;
|
|
727
|
+
else process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = prevContextModePluginPath;
|
|
728
|
+
Bun.$`rm -rf /tmp/opencode-task-1.json /tmp/opencode-data-task-1`.quiet().nothrow();
|
|
729
|
+
Bun.$`rm -rf /tmp/test/.opencode`.quiet().nothrow();
|
|
730
|
+
Bun.$`rm -f ${fakePluginPath}`.quiet().nothrow();
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
/** Pull the opencode config object passed to createOpencode. */
|
|
734
|
+
function getBuiltConfig(): { plugin?: string[]; mcp?: Record<string, unknown> } {
|
|
735
|
+
const opts = lastCreateOpencodeConfig as {
|
|
736
|
+
config?: { plugin?: string[]; mcp?: Record<string, unknown> };
|
|
737
|
+
};
|
|
738
|
+
expect(opts.config).toBeDefined();
|
|
739
|
+
return opts.config as { plugin?: string[]; mcp?: Record<string, unknown> };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
test("resolveContextModePluginPath returns the override path when it exists", async () => {
|
|
743
|
+
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
744
|
+
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
745
|
+
const { resolveContextModePluginPath } = await import("../providers/opencode-adapter");
|
|
746
|
+
expect(resolveContextModePluginPath()).toBe(fakePluginPath);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("resolveContextModePluginPath returns null when the override path is missing", async () => {
|
|
750
|
+
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = "/tmp/ctx-mode-does-not-exist.js";
|
|
751
|
+
const { resolveContextModePluginPath } = await import("../providers/opencode-adapter");
|
|
752
|
+
expect(resolveContextModePluginPath()).toBeNull();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("plugin array includes the resolved context-mode plugin path when available", async () => {
|
|
756
|
+
delete process.env.CONTEXT_MODE_DISABLED;
|
|
757
|
+
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
758
|
+
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
759
|
+
const events: OpencodeEvent[] = [
|
|
760
|
+
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
761
|
+
];
|
|
762
|
+
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
763
|
+
|
|
764
|
+
const built = getBuiltConfig();
|
|
765
|
+
expect(built.plugin).toContain(fakePluginPath);
|
|
766
|
+
// The bare package name must never be used — opencode can't resolve it offline.
|
|
767
|
+
expect(built.plugin).not.toContain("context-mode");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("plugin array excludes context-mode when CONTEXT_MODE_DISABLED=true", async () => {
|
|
771
|
+
process.env.CONTEXT_MODE_DISABLED = "true";
|
|
772
|
+
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
773
|
+
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
774
|
+
const events: OpencodeEvent[] = [
|
|
775
|
+
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
776
|
+
];
|
|
777
|
+
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
778
|
+
|
|
779
|
+
const built = getBuiltConfig();
|
|
780
|
+
expect(built.plugin).not.toContain(fakePluginPath);
|
|
781
|
+
expect(built.plugin).not.toContain("context-mode");
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("context-mode does NOT appear in the mcp block", async () => {
|
|
785
|
+
delete process.env.CONTEXT_MODE_DISABLED;
|
|
786
|
+
writeFileSync(fakePluginPath, "// test plugin\n");
|
|
787
|
+
process.env.CONTEXT_MODE_OPENCODE_PLUGIN_PATH = fakePluginPath;
|
|
788
|
+
const events: OpencodeEvent[] = [
|
|
789
|
+
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
790
|
+
];
|
|
791
|
+
await driveSession(events, testConfig({ taskId: "task-1" }));
|
|
792
|
+
|
|
793
|
+
const built = getBuiltConfig();
|
|
794
|
+
expect(built.mcp).toBeDefined();
|
|
795
|
+
expect(built.mcp?.["context-mode"]).toBeUndefined();
|
|
796
|
+
});
|
|
797
|
+
});
|
|
@@ -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
|
|