@desplega.ai/agent-swarm 1.88.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 +3 -0
- package/openapi.json +41 -1
- package/package.json +2 -1
- package/plugin/skills/composio/SKILL.md +98 -0
- package/src/be/db.ts +325 -2
- 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 +2750 -1431
- package/src/be/seed-skills/index.ts +7 -0
- package/src/cli.tsx +18 -0
- package/src/commands/runner.ts +153 -22
- package/src/commands/x.ts +118 -0
- 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/metrics.ts +447 -0
- package/src/http/operator-actor.ts +9 -0
- package/src/http/poll.ts +11 -1
- package/src/http/tasks.ts +4 -1
- package/src/http/workflows.ts +5 -1
- package/src/metrics/version.ts +26 -0
- package/src/prompts/base-prompt.ts +8 -0
- package/src/prompts/session-templates.ts +23 -0
- package/src/providers/opencode-adapter.ts +22 -6
- package/src/server.ts +10 -1
- package/src/tests/base-prompt.test.ts +35 -0
- package/src/tests/budget-claim-gate.test.ts +26 -0
- package/src/tests/core-auth.test.ts +8 -1
- 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 +3 -1
- package/src/tests/metrics-http.test.ts +247 -0
- package/src/tests/opencode-adapter.test.ts +90 -30
- 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/swarm-x-tool.test.ts +90 -0
- package/src/tests/system-default-skills.test.ts +3 -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/swarm-x.ts +116 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/types.ts +120 -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/attio-interaction/SKILL.md +279 -0
- package/templates/skills/attio-interaction/config.json +14 -0
- package/templates/skills/attio-interaction/content.md +272 -0
|
@@ -7,13 +7,17 @@ import {
|
|
|
7
7
|
getActiveSessionForTask,
|
|
8
8
|
getDb,
|
|
9
9
|
getIdleWorkersWithCapacity,
|
|
10
|
+
getOrphanedInProgressTasksForAgent,
|
|
11
|
+
getPendingTaskForAgent,
|
|
10
12
|
getStalledInProgressTasks,
|
|
11
13
|
getTaskById,
|
|
12
14
|
getUnassignedPoolTasks,
|
|
13
15
|
initDb,
|
|
14
16
|
insertActiveSession,
|
|
17
|
+
resetOrphanedInProgressTasksForAgent,
|
|
15
18
|
startTask,
|
|
16
19
|
updateAgentStatus,
|
|
20
|
+
updateTaskClaudeSessionId,
|
|
17
21
|
} from "../be/db";
|
|
18
22
|
import {
|
|
19
23
|
codeLevelTriage,
|
|
@@ -157,6 +161,60 @@ describe("Heartbeat Triage", () => {
|
|
|
157
161
|
});
|
|
158
162
|
});
|
|
159
163
|
|
|
164
|
+
describe("orphaned in_progress recovery", () => {
|
|
165
|
+
test("resets stale in_progress task with no session and no claudeSessionId to pending", () => {
|
|
166
|
+
const agent = createAgent({ name: "orphan-worker", isLead: false, status: "idle" });
|
|
167
|
+
const task = createTaskExtended("Orphaned task", { agentId: agent.id });
|
|
168
|
+
startTask(task.id);
|
|
169
|
+
|
|
170
|
+
const oldTime = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
|
171
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [oldTime, task.id]);
|
|
172
|
+
|
|
173
|
+
const orphaned = getOrphanedInProgressTasksForAgent(agent.id, 60);
|
|
174
|
+
expect(orphaned.map((t) => t.id)).toContain(task.id);
|
|
175
|
+
|
|
176
|
+
const reset = resetOrphanedInProgressTasksForAgent(agent.id, 60);
|
|
177
|
+
expect(reset.map((t) => t.id)).toContain(task.id);
|
|
178
|
+
|
|
179
|
+
const updated = getTaskById(task.id);
|
|
180
|
+
expect(updated?.status).toBe("pending");
|
|
181
|
+
expect(getPendingTaskForAgent(agent.id)?.id).toBe(task.id);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("does not reset tasks with active session, provider session, or fresh update", () => {
|
|
185
|
+
const agent = createAgent({ name: "live-worker", isLead: false, status: "idle" });
|
|
186
|
+
const withActiveSession = createTaskExtended("Live session task", { agentId: agent.id });
|
|
187
|
+
const withProviderSession = createTaskExtended("Provider session task", {
|
|
188
|
+
agentId: agent.id,
|
|
189
|
+
});
|
|
190
|
+
const fresh = createTaskExtended("Fresh task", { agentId: agent.id });
|
|
191
|
+
|
|
192
|
+
startTask(withActiveSession.id);
|
|
193
|
+
startTask(withProviderSession.id);
|
|
194
|
+
startTask(fresh.id);
|
|
195
|
+
|
|
196
|
+
const oldTime = new Date(Date.now() - 2 * 60 * 1000).toISOString();
|
|
197
|
+
getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id IN (?, ?)", [
|
|
198
|
+
oldTime,
|
|
199
|
+
withActiveSession.id,
|
|
200
|
+
withProviderSession.id,
|
|
201
|
+
]);
|
|
202
|
+
insertActiveSession({
|
|
203
|
+
agentId: agent.id,
|
|
204
|
+
taskId: withActiveSession.id,
|
|
205
|
+
triggerType: "task_assigned",
|
|
206
|
+
});
|
|
207
|
+
updateTaskClaudeSessionId(withProviderSession.id, "claude-live-session");
|
|
208
|
+
|
|
209
|
+
const reset = resetOrphanedInProgressTasksForAgent(agent.id, 60);
|
|
210
|
+
expect(reset.length).toBe(0);
|
|
211
|
+
|
|
212
|
+
expect(getTaskById(withActiveSession.id)?.status).toBe("in_progress");
|
|
213
|
+
expect(getTaskById(withProviderSession.id)?.status).toBe("in_progress");
|
|
214
|
+
expect(getTaskById(fresh.id)?.status).toBe("in_progress");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
160
218
|
describe("getIdleWorkersWithCapacity", () => {
|
|
161
219
|
test("returns idle non-lead agents", () => {
|
|
162
220
|
createAgent({ name: "idle-worker", isLead: false, status: "idle" });
|
|
@@ -306,10 +364,13 @@ describe("Heartbeat Triage", () => {
|
|
|
306
364
|
expect(findings.autoAssigned.length).toBe(1);
|
|
307
365
|
expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
|
|
308
366
|
|
|
309
|
-
// Verify task is
|
|
367
|
+
// Verify task is pending so the worker's normal poll returns task_assigned.
|
|
310
368
|
const task = getTaskById(findings.autoAssigned[0]!.taskId);
|
|
311
|
-
expect(task?.status).toBe("
|
|
369
|
+
expect(task?.status).toBe("pending");
|
|
312
370
|
expect(task?.agentId).toBe(worker.id);
|
|
371
|
+
|
|
372
|
+
const dispatchable = getPendingTaskForAgent(worker.id);
|
|
373
|
+
expect(dispatchable?.id).toBe(task?.id);
|
|
313
374
|
});
|
|
314
375
|
|
|
315
376
|
test("auto-assignment skips lead agents", async () => {
|
|
@@ -340,6 +401,26 @@ describe("Heartbeat Triage", () => {
|
|
|
340
401
|
expect(findings.autoAssigned.length).toBe(0);
|
|
341
402
|
});
|
|
342
403
|
|
|
404
|
+
test("auto-assignment counts pending reservations when assigning pool tasks", async () => {
|
|
405
|
+
const worker = createAgent({ name: "single-slot-worker", isLead: false, status: "idle" });
|
|
406
|
+
createTaskExtended("Pool task 1");
|
|
407
|
+
createTaskExtended("Pool task 2");
|
|
408
|
+
|
|
409
|
+
const findings = await codeLevelTriage();
|
|
410
|
+
expect(findings.autoAssigned.length).toBe(1);
|
|
411
|
+
expect(findings.autoAssigned[0]!.agentId).toBe(worker.id);
|
|
412
|
+
|
|
413
|
+
const assigned = getDb()
|
|
414
|
+
.query("SELECT COUNT(*) as count FROM agent_tasks WHERE agentId = ? AND status = 'pending'")
|
|
415
|
+
.get(worker.id) as { count: number };
|
|
416
|
+
const remaining = getDb()
|
|
417
|
+
.query("SELECT COUNT(*) as count FROM agent_tasks WHERE status = 'unassigned'")
|
|
418
|
+
.get() as { count: number };
|
|
419
|
+
|
|
420
|
+
expect(assigned.count).toBe(1);
|
|
421
|
+
expect(remaining.count).toBe(1);
|
|
422
|
+
});
|
|
423
|
+
|
|
343
424
|
test("fixes worker with busy status but no active tasks", async () => {
|
|
344
425
|
createAgent({ name: "ghost-busy", isLead: false, status: "busy" });
|
|
345
426
|
|
|
@@ -408,7 +489,7 @@ describe("Heartbeat Triage", () => {
|
|
|
408
489
|
|
|
409
490
|
// Verify task was auto-assigned
|
|
410
491
|
const tasks = getDb()
|
|
411
|
-
.query("SELECT * FROM agent_tasks WHERE status = '
|
|
492
|
+
.query("SELECT * FROM agent_tasks WHERE status = 'pending' AND agentId = ?")
|
|
412
493
|
.all(worker.id) as Array<{ id: string }>;
|
|
413
494
|
expect(tasks.length).toBe(1);
|
|
414
495
|
});
|
|
@@ -13,6 +13,7 @@ import { Webhook } from "svix";
|
|
|
13
13
|
const TEST_PORT = 19876;
|
|
14
14
|
const TEST_DB_PATH = `/tmp/test-http-integration-${Date.now()}.sqlite`;
|
|
15
15
|
const BASE = `http://localhost:${TEST_PORT}`;
|
|
16
|
+
const TEST_API_KEY = "test-http-integration-key";
|
|
16
17
|
|
|
17
18
|
let serverProc: Subprocess;
|
|
18
19
|
|
|
@@ -28,6 +29,7 @@ async function api(
|
|
|
28
29
|
): Promise<{ status: number; body: any; ok: boolean }> {
|
|
29
30
|
const headers: Record<string, string> = {
|
|
30
31
|
"Content-Type": "application/json",
|
|
32
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
31
33
|
...opts.headers,
|
|
32
34
|
};
|
|
33
35
|
if (opts.agentId) headers["x-agent-id"] = opts.agentId;
|
|
@@ -106,7 +108,7 @@ beforeAll(async () => {
|
|
|
106
108
|
...process.env,
|
|
107
109
|
PORT: String(TEST_PORT),
|
|
108
110
|
DATABASE_PATH: TEST_DB_PATH,
|
|
109
|
-
API_KEY:
|
|
111
|
+
API_KEY: TEST_API_KEY,
|
|
110
112
|
CAPABILITIES: "core,task-pool,messaging,profiles,services,scheduling,memory",
|
|
111
113
|
// Disable optional integrations
|
|
112
114
|
SLACK_BOT_TOKEN: "",
|
|
@@ -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
|
+
});
|
|
@@ -48,7 +48,7 @@ let lastCreateOpencodeConfig: unknown;
|
|
|
48
48
|
async function driveSession(
|
|
49
49
|
events: OpencodeEvent[],
|
|
50
50
|
cfg: ProviderSessionConfig = testConfig(),
|
|
51
|
-
): Promise<{ emitted: ProviderEvent[]; result: ProviderResult }> {
|
|
51
|
+
): Promise<{ emitted: ProviderEvent[]; result: ProviderResult; serverCloseCalls: () => number }> {
|
|
52
52
|
const emitted: ProviderEvent[] = [];
|
|
53
53
|
|
|
54
54
|
// Build the fake client/server pair used by the mock
|
|
@@ -68,7 +68,8 @@ async function driveSession(
|
|
|
68
68
|
},
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
const
|
|
71
|
+
const closeServer = mock(() => {});
|
|
72
|
+
const fakeServer = { url: "http://127.0.0.1:12345", close: closeServer };
|
|
72
73
|
|
|
73
74
|
// Install mock BEFORE importing the adapter (Bun hoists mock.module)
|
|
74
75
|
mock.module("@opencode-ai/sdk", () => ({
|
|
@@ -88,7 +89,54 @@ async function driveSession(
|
|
|
88
89
|
await new Promise((r) => setTimeout(r, 0));
|
|
89
90
|
|
|
90
91
|
const result = await session.waitForCompletion();
|
|
91
|
-
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();
|
|
92
140
|
}
|
|
93
141
|
|
|
94
142
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
@@ -106,7 +154,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
106
154
|
properties: { sessionID: "sess-abc-123" },
|
|
107
155
|
},
|
|
108
156
|
];
|
|
109
|
-
const { emitted, result } = await driveSession(events);
|
|
157
|
+
const { emitted, result, serverCloseCalls } = await driveSession(events);
|
|
110
158
|
|
|
111
159
|
const resultEvent = emitted.find((e) => e.type === "result");
|
|
112
160
|
expect(resultEvent).toBeDefined();
|
|
@@ -118,6 +166,21 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
118
166
|
expect(result.isError).toBe(false);
|
|
119
167
|
expect(result.exitCode).toBe(0);
|
|
120
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);
|
|
121
184
|
});
|
|
122
185
|
|
|
123
186
|
test("session.error → emits error event and fails result", async () => {
|
|
@@ -130,7 +193,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
130
193
|
},
|
|
131
194
|
},
|
|
132
195
|
];
|
|
133
|
-
const { emitted, result } = await driveSession(events);
|
|
196
|
+
const { emitted, result, serverCloseCalls } = await driveSession(events);
|
|
134
197
|
|
|
135
198
|
const errorEvent = emitted.find((e) => e.type === "error");
|
|
136
199
|
expect(errorEvent).toBeDefined();
|
|
@@ -140,6 +203,7 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
|
|
|
140
203
|
expect(result.isError).toBe(true);
|
|
141
204
|
expect(result.exitCode).toBe(1);
|
|
142
205
|
expect(result.failureReason).toContain("provider overloaded");
|
|
206
|
+
expect(serverCloseCalls()).toBe(1);
|
|
143
207
|
});
|
|
144
208
|
|
|
145
209
|
test("prompt Model not found refreshes OpenRouter cache and retries once", async () => {
|
|
@@ -600,42 +664,38 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
|
600
664
|
});
|
|
601
665
|
|
|
602
666
|
test("per-task agent file is written with system prompt", async () => {
|
|
603
|
-
const events: OpencodeEvent[] = [
|
|
604
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
605
|
-
];
|
|
606
667
|
const cwd = `/tmp/opencode-test-agent-${Date.now()}`;
|
|
607
668
|
await Bun.$`mkdir -p ${cwd}`.quiet();
|
|
608
669
|
const cfg = testConfig({ taskId: "task-agent-file", systemPrompt: "be a coder", cwd });
|
|
609
|
-
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
|
+
});
|
|
610
679
|
|
|
611
|
-
const agentFile = Bun.file(join(cwd, ".opencode", "agents", "swarm-task-agent-file.md"));
|
|
612
|
-
const exists = await agentFile.exists();
|
|
613
|
-
expect(exists).toBe(true);
|
|
614
|
-
if (exists) {
|
|
615
|
-
const content = await agentFile.text();
|
|
616
|
-
expect(content).toContain("be a coder");
|
|
617
|
-
}
|
|
618
680
|
// Cleanup
|
|
619
681
|
await Bun.$`rm -rf ${cwd}`.quiet().nothrow();
|
|
620
682
|
});
|
|
621
683
|
|
|
622
684
|
test("per-task config file is written as valid JSON", async () => {
|
|
623
|
-
const events: OpencodeEvent[] = [
|
|
624
|
-
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
625
|
-
];
|
|
626
685
|
const cfg = testConfig({ taskId: "task-cfg-json" });
|
|
627
|
-
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
|
+
});
|
|
628
698
|
|
|
629
|
-
const configFile = Bun.file("/tmp/opencode-task-cfg-json.json");
|
|
630
|
-
const exists = await configFile.exists();
|
|
631
|
-
expect(exists).toBe(true);
|
|
632
|
-
if (exists) {
|
|
633
|
-
const text = await configFile.text();
|
|
634
|
-
expect(() => JSON.parse(text)).not.toThrow();
|
|
635
|
-
const parsed = JSON.parse(text) as { mcp?: unknown; permission?: unknown };
|
|
636
|
-
expect(parsed.mcp).toBeDefined();
|
|
637
|
-
expect(parsed.permission).toBeDefined();
|
|
638
|
-
}
|
|
639
699
|
// Cleanup
|
|
640
700
|
await Bun.$`rm -f /tmp/opencode-task-cfg-json.json`.quiet().nothrow();
|
|
641
701
|
await Bun.$`rm -rf /tmp/opencode-data-task-cfg-json`.quiet().nothrow();
|