@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.
Files changed (59) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +41 -1
  3. package/package.json +2 -1
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +325 -2
  6. package/src/be/migrations/081_metrics.sql +39 -0
  7. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  8. package/src/be/modelsdev-cache.json +2750 -1431
  9. package/src/be/seed-skills/index.ts +7 -0
  10. package/src/cli.tsx +18 -0
  11. package/src/commands/runner.ts +153 -22
  12. package/src/commands/x.ts +118 -0
  13. package/src/github/handlers.ts +40 -1
  14. package/src/heartbeat/heartbeat.ts +26 -5
  15. package/src/http/active-sessions.ts +32 -1
  16. package/src/http/auth.ts +36 -0
  17. package/src/http/core.ts +20 -16
  18. package/src/http/db-query.ts +20 -0
  19. package/src/http/index.ts +2 -0
  20. package/src/http/metrics.ts +447 -0
  21. package/src/http/operator-actor.ts +9 -0
  22. package/src/http/poll.ts +11 -1
  23. package/src/http/tasks.ts +4 -1
  24. package/src/http/workflows.ts +5 -1
  25. package/src/metrics/version.ts +26 -0
  26. package/src/prompts/base-prompt.ts +8 -0
  27. package/src/prompts/session-templates.ts +23 -0
  28. package/src/providers/opencode-adapter.ts +22 -6
  29. package/src/server.ts +10 -1
  30. package/src/tests/base-prompt.test.ts +35 -0
  31. package/src/tests/budget-claim-gate.test.ts +26 -0
  32. package/src/tests/core-auth.test.ts +8 -1
  33. package/src/tests/events-http.test.ts +6 -2
  34. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  35. package/src/tests/heartbeat.test.ts +84 -3
  36. package/src/tests/http-api-integration.test.ts +3 -1
  37. package/src/tests/metrics-http.test.ts +247 -0
  38. package/src/tests/opencode-adapter.test.ts +90 -30
  39. package/src/tests/runner-repo-autostash.test.ts +117 -0
  40. package/src/tests/runner-requester-profile.test.ts +25 -0
  41. package/src/tests/runner-skills-refresh.test.ts +1 -1
  42. package/src/tests/swarm-x-tool.test.ts +90 -0
  43. package/src/tests/system-default-skills.test.ts +3 -0
  44. package/src/tests/ui-logs-parser.test.ts +271 -0
  45. package/src/tests/user-token-rest-auth.test.ts +129 -0
  46. package/src/tests/workflow-async-v2.test.ts +23 -0
  47. package/src/tests/x-composio.test.ts +122 -0
  48. package/src/tools/create-metric.ts +191 -0
  49. package/src/tools/swarm-x.ts +116 -0
  50. package/src/tools/tool-config.ts +6 -0
  51. package/src/types.ts +120 -0
  52. package/src/utils/request-auth-context.ts +28 -0
  53. package/src/utils/skills-refresh.ts +2 -2
  54. package/src/workflows/engine.ts +24 -2
  55. package/src/workflows/executors/agent-task.ts +2 -0
  56. package/src/x/composio.ts +295 -0
  57. package/templates/skills/attio-interaction/SKILL.md +279 -0
  58. package/templates/skills/attio-interaction/config.json +14 -0
  59. 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 now in_progress
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("in_progress");
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 = 'in_progress' AND agentId = ?")
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: "", // no auth required
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 fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
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 driveSession(events, cfg);
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 driveSession(events, cfg);
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();