@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.
Files changed (102) hide show
  1. package/README.md +5 -1
  2. package/openapi.json +53 -1
  3. package/package.json +6 -5
  4. package/plugin/skills/composio/SKILL.md +98 -0
  5. package/src/be/db.ts +374 -9
  6. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  7. package/src/be/migrations/081_metrics.sql +39 -0
  8. package/src/be/migrations/082_user_audit_fields.sql +120 -0
  9. package/src/be/modelsdev-cache.json +3825 -2417
  10. package/src/be/seed/registry.ts +3 -2
  11. package/src/be/seed-skills/index.ts +179 -0
  12. package/src/cli.tsx +51 -4
  13. package/src/commands/e2b-stack-wizard.tsx +394 -0
  14. package/src/commands/e2b.ts +1352 -53
  15. package/src/commands/onboard/dashboard-url.ts +29 -0
  16. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  17. package/src/commands/onboard.tsx +3 -1
  18. package/src/commands/runner.ts +154 -22
  19. package/src/commands/x.ts +118 -0
  20. package/src/e2b/dispatch.ts +234 -18
  21. package/src/github/handlers.ts +40 -1
  22. package/src/heartbeat/heartbeat.ts +26 -5
  23. package/src/http/active-sessions.ts +32 -1
  24. package/src/http/auth.ts +36 -0
  25. package/src/http/core.ts +20 -16
  26. package/src/http/db-query.ts +20 -0
  27. package/src/http/index.ts +2 -0
  28. package/src/http/memory.ts +13 -1
  29. package/src/http/metrics.ts +447 -0
  30. package/src/http/operator-actor.ts +9 -0
  31. package/src/http/poll.ts +11 -1
  32. package/src/http/skills.ts +53 -0
  33. package/src/http/tasks.ts +4 -1
  34. package/src/http/webhooks.ts +75 -0
  35. package/src/http/workflows.ts +5 -1
  36. package/src/integrations/kapso/client.ts +82 -0
  37. package/src/memory/automatic-task-gate.ts +47 -0
  38. package/src/metrics/version.ts +26 -0
  39. package/src/prompts/base-prompt.ts +24 -1
  40. package/src/prompts/session-templates.ts +74 -0
  41. package/src/providers/claude-adapter.ts +19 -0
  42. package/src/providers/codex-adapter.ts +22 -0
  43. package/src/providers/ctx-mode-env.ts +10 -0
  44. package/src/providers/opencode-adapter.ts +72 -7
  45. package/src/server.ts +10 -1
  46. package/src/slack/blocks.ts +12 -4
  47. package/src/slack/watcher.ts +3 -3
  48. package/src/telemetry.ts +14 -1
  49. package/src/templates.d.ts +4 -0
  50. package/src/tests/base-prompt.test.ts +76 -0
  51. package/src/tests/budget-claim-gate.test.ts +26 -0
  52. package/src/tests/claude-adapter.test.ts +86 -1
  53. package/src/tests/codex-adapter.test.ts +89 -0
  54. package/src/tests/core-auth.test.ts +8 -1
  55. package/src/tests/e2b-dispatch.test.ts +603 -11
  56. package/src/tests/events-http.test.ts +6 -2
  57. package/src/tests/github-handlers-cancel-config.test.ts +262 -0
  58. package/src/tests/heartbeat.test.ts +84 -3
  59. package/src/tests/http-api-integration.test.ts +116 -1
  60. package/src/tests/kapso-client.test.ts +74 -1
  61. package/src/tests/kapso-inbound.test.ts +60 -2
  62. package/src/tests/metrics-http.test.ts +247 -0
  63. package/src/tests/opencode-adapter.test.ts +185 -30
  64. package/src/tests/prompt-template-session.test.ts +4 -2
  65. package/src/tests/runner-repo-autostash.test.ts +117 -0
  66. package/src/tests/runner-requester-profile.test.ts +25 -0
  67. package/src/tests/runner-skills-refresh.test.ts +1 -1
  68. package/src/tests/self-improvement.test.ts +89 -0
  69. package/src/tests/skill-update-scope.test.ts +88 -1
  70. package/src/tests/slack-blocks.test.ts +15 -0
  71. package/src/tests/swarm-x-tool.test.ts +90 -0
  72. package/src/tests/system-default-skills.test.ts +122 -0
  73. package/src/tests/telemetry-init.test.ts +86 -0
  74. package/src/tests/ui-logs-parser.test.ts +271 -0
  75. package/src/tests/user-token-rest-auth.test.ts +129 -0
  76. package/src/tests/workflow-async-v2.test.ts +23 -0
  77. package/src/tests/x-composio.test.ts +122 -0
  78. package/src/tools/create-metric.ts +191 -0
  79. package/src/tools/skills/skill-delete.ts +14 -0
  80. package/src/tools/skills/skill-update.ts +14 -0
  81. package/src/tools/store-progress.ts +19 -5
  82. package/src/tools/swarm-x.ts +116 -0
  83. package/src/tools/tool-config.ts +6 -0
  84. package/src/types.ts +121 -0
  85. package/src/utils/request-auth-context.ts +28 -0
  86. package/src/utils/skills-refresh.ts +2 -2
  87. package/src/workflows/engine.ts +24 -2
  88. package/src/workflows/executors/agent-task.ts +2 -0
  89. package/src/x/composio.ts +295 -0
  90. package/templates/skills/artifacts/config.json +1 -0
  91. package/templates/skills/attio-interaction/SKILL.md +279 -0
  92. package/templates/skills/attio-interaction/config.json +14 -0
  93. package/templates/skills/attio-interaction/content.md +272 -0
  94. package/templates/skills/kv-storage/config.json +1 -0
  95. package/templates/skills/pages/config.json +1 -0
  96. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  97. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  98. package/templates/skills/swarm-scripts/config.json +14 -0
  99. package/templates/skills/swarm-scripts/content.md +86 -0
  100. package/templates/skills/workflow-iterate/config.json +1 -0
  101. package/templates/skills/workflow-structured-output/config.json +1 -0
  102. 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 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 };
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 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
+ });
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 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
+ });
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 19 session/system templates registered", () => {
93
+ test("total of 20 session/system templates registered", () => {
94
94
  const all = getAllTemplateDefinitions();
95
95
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
96
- expect(sessionSystem.length).toBe(19);
96
+ // 20 = the original 19 + `system.session.worker.pi` (a pi-specific worker
97
+ // composite that omits the context_mode block — see session-templates.ts).
98
+ expect(sessionSystem.length).toBe(20);
97
99
  });
98
100
  });
99
101