@desplega.ai/agent-swarm 1.83.1 → 1.84.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 (69) hide show
  1. package/openapi.json +158 -8
  2. package/package.json +1 -1
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/http/webhooks.ts +101 -0
  19. package/src/integrations/kapso/client.ts +198 -0
  20. package/src/integrations/kapso/config.ts +104 -0
  21. package/src/integrations/kapso/inbound.ts +111 -0
  22. package/src/jira/client.ts +3 -5
  23. package/src/jira/oauth.ts +1 -0
  24. package/src/jira/sync.ts +2 -2
  25. package/src/oauth/ensure-token.ts +1 -0
  26. package/src/oauth/wrapper.ts +38 -7
  27. package/src/providers/claude-adapter.ts +7 -2
  28. package/src/providers/claude-managed-adapter.ts +1 -1
  29. package/src/providers/codex-adapter.ts +30 -0
  30. package/src/providers/opencode-adapter.ts +149 -14
  31. package/src/providers/pi-mono-adapter.ts +41 -1
  32. package/src/providers/types.ts +1 -1
  33. package/src/server-user.ts +117 -0
  34. package/src/server.ts +14 -0
  35. package/src/tests/artifact-sdk.test.ts +23 -19
  36. package/src/tests/budget-user-scope.test.ts +376 -0
  37. package/src/tests/claude-managed-adapter.test.ts +6 -0
  38. package/src/tests/codex-adapter.test.ts +192 -0
  39. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  40. package/src/tests/db-queries-oauth.test.ts +43 -0
  41. package/src/tests/ensure-token.test.ts +93 -0
  42. package/src/tests/error-tracker.test.ts +52 -0
  43. package/src/tests/fetch-resolved-env.test.ts +33 -20
  44. package/src/tests/http-users.test.ts +29 -1
  45. package/src/tests/kapso-client.test.ts +94 -0
  46. package/src/tests/kapso-inbound.test.ts +198 -0
  47. package/src/tests/mcp-user-route.test.ts +325 -0
  48. package/src/tests/opencode-adapter.test.ts +75 -0
  49. package/src/tests/pi-mono-adapter.test.ts +21 -1
  50. package/src/tests/rate-limit-event.test.ts +69 -6
  51. package/src/tests/resume-session.test.ts +93 -0
  52. package/src/tests/task-tools-ctx.test.ts +100 -0
  53. package/src/tests/task-tools-ownership.test.ts +167 -0
  54. package/src/tests/tool-annotations.test.ts +3 -2
  55. package/src/tests/user-token-routes.test.ts +221 -0
  56. package/src/tools/cancel-task.ts +137 -83
  57. package/src/tools/get-task-details.ts +73 -59
  58. package/src/tools/get-tasks.ts +134 -126
  59. package/src/tools/register-kapso-number.ts +210 -0
  60. package/src/tools/send-task.ts +312 -312
  61. package/src/tools/task-action.ts +464 -367
  62. package/src/tools/task-tool-ctx.ts +43 -0
  63. package/src/tools/templates.ts +35 -0
  64. package/src/tools/tool-config.ts +6 -0
  65. package/src/tools/whatsapp-message.ts +135 -0
  66. package/src/types.ts +6 -2
  67. package/src/utils/error-tracker.ts +122 -9
  68. package/templates/skills/agentmail-sending/SKILL.md +49 -0
  69. package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { sendKapsoText } from "../integrations/kapso/client";
3
+
4
+ const originalFetch = globalThis.fetch;
5
+
6
+ afterEach(() => {
7
+ globalThis.fetch = originalFetch;
8
+ });
9
+
10
+ function mockFetch(status: number, body: unknown) {
11
+ globalThis.fetch = (async () =>
12
+ new Response(JSON.stringify(body), {
13
+ status,
14
+ headers: { "Content-Type": "application/json" },
15
+ })) as typeof fetch;
16
+ }
17
+
18
+ describe("sendKapsoText", () => {
19
+ test("success → returns outbound wamid", async () => {
20
+ let captured: { url: string; body: unknown } | null = null;
21
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
22
+ captured = { url, body: JSON.parse(init.body as string) };
23
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.OUT123" }] }), { status: 200 });
24
+ }) as typeof fetch;
25
+
26
+ const result = await sendKapsoText({
27
+ apiBaseUrl: "https://api.kapso.ai",
28
+ apiKey: "k",
29
+ phoneNumberId: "1035039933036854",
30
+ to: "34679077777",
31
+ body: "hola",
32
+ });
33
+
34
+ expect(result.ok).toBe(true);
35
+ expect(result.messageId).toBe("wamid.OUT123");
36
+ expect(captured!.url).toBe(
37
+ "https://api.kapso.ai/meta/whatsapp/v24.0/1035039933036854/messages",
38
+ );
39
+ expect(captured!.body).toMatchObject({
40
+ messaging_product: "whatsapp",
41
+ to: "34679077777",
42
+ type: "text",
43
+ text: { body: "hola", preview_url: false },
44
+ });
45
+ });
46
+
47
+ test("quote-reply sets context.message_id", async () => {
48
+ let body: Record<string, unknown> | null = null;
49
+ globalThis.fetch = (async (_url: string, init: RequestInit) => {
50
+ body = JSON.parse(init.body as string);
51
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.R" }] }), { status: 200 });
52
+ }) as typeof fetch;
53
+
54
+ await sendKapsoText({
55
+ apiBaseUrl: "https://api.kapso.ai",
56
+ apiKey: "k",
57
+ phoneNumberId: "p",
58
+ to: "34679077777",
59
+ body: "re",
60
+ contextMessageId: "wamid.IN999",
61
+ });
62
+
63
+ expect(body!.context).toEqual({ message_id: "wamid.IN999" });
64
+ });
65
+
66
+ test("24h-window error (code 131047) → sessionWindowExpired", async () => {
67
+ mockFetch(400, {
68
+ error: { code: 131047, message: "Message failed: more than 24 hours since last reply" },
69
+ });
70
+ const result = await sendKapsoText({
71
+ apiBaseUrl: "https://api.kapso.ai",
72
+ apiKey: "k",
73
+ phoneNumberId: "p",
74
+ to: "34679077777",
75
+ body: "late",
76
+ });
77
+ expect(result.ok).toBe(false);
78
+ expect(result.sessionWindowExpired).toBe(true);
79
+ });
80
+
81
+ test("generic error → not flagged as session-window", async () => {
82
+ mockFetch(401, { error: { code: 0, message: "Invalid API key" } });
83
+ const result = await sendKapsoText({
84
+ apiBaseUrl: "https://api.kapso.ai",
85
+ apiKey: "bad",
86
+ phoneNumberId: "p",
87
+ to: "34679077777",
88
+ body: "x",
89
+ });
90
+ expect(result.ok).toBe(false);
91
+ expect(result.sessionWindowExpired).toBe(false);
92
+ expect(result.errorMessage).toContain("Invalid API key");
93
+ });
94
+ });
@@ -0,0 +1,198 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import crypto from "node:crypto";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { closeDb, createAgent, getTaskById, initDb } from "../be/db";
5
+ import { handleWebhooks } from "../http/webhooks";
6
+ import { putKapsoNumberMapping } from "../integrations/kapso/config";
7
+ import { routeKapsoInbound } from "../integrations/kapso/inbound";
8
+
9
+ const TEST_DB_PATH = "./test-kapso-inbound.sqlite";
10
+ const HMAC_SECRET = "kapso-test-hmac-secret";
11
+
12
+ let agentId: string;
13
+
14
+ function makePayload(opts: {
15
+ phoneNumberId: string;
16
+ messageId?: string;
17
+ direction?: string;
18
+ type?: string;
19
+ text?: string;
20
+ from?: string;
21
+ conversationId?: string;
22
+ }) {
23
+ return {
24
+ message: {
25
+ id: opts.messageId ?? `wamid.${Math.random().toString(36).slice(2)}`,
26
+ from: opts.from ?? "34679077777",
27
+ type: opts.type ?? "text",
28
+ text: { body: opts.text ?? "hola" },
29
+ kapso: { direction: opts.direction ?? "inbound", content: opts.text ?? "hola" },
30
+ },
31
+ conversation: {
32
+ id: opts.conversationId ?? "conv-1",
33
+ phone_number: opts.from ?? "34679077777",
34
+ contact_name: "Taras",
35
+ },
36
+ phone_number_id: opts.phoneNumberId,
37
+ };
38
+ }
39
+
40
+ function sign(secret: string, body: string): string {
41
+ return crypto.createHmac("sha256", secret).update(body).digest("hex");
42
+ }
43
+
44
+ /** Minimal fake req/res to drive handleWebhooks without a live server. */
45
+ function fakeReqRes(rawBody: string, headers: Record<string, string>) {
46
+ const req = {
47
+ method: "POST",
48
+ headers,
49
+ async *[Symbol.asyncIterator]() {
50
+ yield Buffer.from(rawBody);
51
+ },
52
+ } as unknown as IncomingMessage;
53
+
54
+ const captured = { status: 0, body: "" };
55
+ const res = {
56
+ writeHead(status: number) {
57
+ captured.status = status;
58
+ return this;
59
+ },
60
+ end(chunk?: string) {
61
+ if (chunk) captured.body = chunk;
62
+ return this;
63
+ },
64
+ } as unknown as ServerResponse;
65
+
66
+ return { req, res, captured };
67
+ }
68
+
69
+ const KAPSO_PATH = ["api", "integrations", "kapso", "webhook"];
70
+
71
+ beforeAll(() => {
72
+ for (const suffix of ["", "-wal", "-shm"]) {
73
+ try {
74
+ require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
75
+ } catch {}
76
+ }
77
+ initDb(TEST_DB_PATH);
78
+ process.env.KAPSO_WEBHOOK_HMAC_SECRET = HMAC_SECRET;
79
+ const agent = createAgent({ name: "KapsoWorker", isLead: false, status: "idle" });
80
+ agentId = agent.id;
81
+ });
82
+
83
+ afterAll(() => {
84
+ closeDb();
85
+ delete process.env.KAPSO_WEBHOOK_HMAC_SECRET;
86
+ for (const suffix of ["", "-wal", "-shm"]) {
87
+ try {
88
+ require("node:fs").unlinkSync(`${TEST_DB_PATH}${suffix}`);
89
+ } catch {}
90
+ }
91
+ });
92
+
93
+ describe("routeKapsoInbound", () => {
94
+ test("mapping hit → dispatches a kapso-inbound task to the mapped agent", () => {
95
+ putKapsoNumberMapping({
96
+ phoneNumberId: "pn-task",
97
+ agentId,
98
+ createdAt: new Date().toISOString(),
99
+ });
100
+ const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-task" }));
101
+ expect(routing.kind).toBe("task");
102
+ if (routing.kind !== "task") throw new Error("expected task");
103
+ const task = getTaskById(routing.taskId);
104
+ expect(task).not.toBeNull();
105
+ expect(task!.taskType).toBe("kapso-inbound");
106
+ expect(task!.agentId).toBe(agentId);
107
+ expect(task!.task).toContain("## Source: WhatsApp (Kapso)");
108
+ });
109
+
110
+ test("no mapping → no_mapping (does not break, no task)", () => {
111
+ const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-unregistered" }));
112
+ expect(routing.kind).toBe("no_mapping");
113
+ });
114
+
115
+ test("workflow mapping → signals workflow dispatch", () => {
116
+ putKapsoNumberMapping({
117
+ phoneNumberId: "pn-wf",
118
+ workflowId: "11111111-1111-4111-8111-111111111111",
119
+ createdAt: new Date().toISOString(),
120
+ });
121
+ const routing = routeKapsoInbound(makePayload({ phoneNumberId: "pn-wf" }));
122
+ expect(routing.kind).toBe("workflow");
123
+ if (routing.kind !== "workflow") throw new Error("expected workflow");
124
+ expect(routing.workflowId).toBe("11111111-1111-4111-8111-111111111111");
125
+ });
126
+
127
+ test("non-inbound (outbound/status) → skip", () => {
128
+ const routing = routeKapsoInbound(
129
+ makePayload({ phoneNumberId: "pn-task", direction: "outbound" }),
130
+ );
131
+ expect(routing.kind).toBe("skip");
132
+ });
133
+
134
+ test("duplicate delivery of the same message id → second is deduped", () => {
135
+ putKapsoNumberMapping({
136
+ phoneNumberId: "pn-dup",
137
+ agentId,
138
+ createdAt: new Date().toISOString(),
139
+ });
140
+ const messageId = "wamid.DUPLICATE_TEST";
141
+ const first = routeKapsoInbound(makePayload({ phoneNumberId: "pn-dup", messageId }));
142
+ expect(first.kind).toBe("task");
143
+ const second = routeKapsoInbound(makePayload({ phoneNumberId: "pn-dup", messageId }));
144
+ expect(second.kind).toBe("duplicate");
145
+ });
146
+ });
147
+
148
+ describe("handleWebhooks — Kapso HMAC gate", () => {
149
+ test("valid HMAC + mapping hit → 200 and task routing", async () => {
150
+ putKapsoNumberMapping({
151
+ phoneNumberId: "pn-http",
152
+ agentId,
153
+ createdAt: new Date().toISOString(),
154
+ });
155
+ const rawBody = JSON.stringify(
156
+ makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_OK" }),
157
+ );
158
+ const { req, res, captured } = fakeReqRes(rawBody, {
159
+ "x-webhook-signature": sign(HMAC_SECRET, rawBody),
160
+ });
161
+ const handled = await handleWebhooks(req, res, KAPSO_PATH);
162
+ expect(handled).toBe(true);
163
+ expect(captured.status).toBe(200);
164
+ expect(JSON.parse(captured.body)).toMatchObject({ received: true, routing: "task" });
165
+ });
166
+
167
+ test("valid HMAC + no mapping → 200 no_mapping (fallback, does not break)", async () => {
168
+ const rawBody = JSON.stringify(
169
+ makePayload({ phoneNumberId: "pn-http-unmapped", messageId: "wamid.HTTP_NOMAP" }),
170
+ );
171
+ const { req, res, captured } = fakeReqRes(rawBody, {
172
+ "x-webhook-signature": sign(HMAC_SECRET, rawBody),
173
+ });
174
+ await handleWebhooks(req, res, KAPSO_PATH);
175
+ expect(captured.status).toBe(200);
176
+ expect(JSON.parse(captured.body)).toMatchObject({ routing: "no_mapping" });
177
+ });
178
+
179
+ test("invalid HMAC → 401", async () => {
180
+ const rawBody = JSON.stringify(
181
+ makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_BAD" }),
182
+ );
183
+ const { req, res, captured } = fakeReqRes(rawBody, {
184
+ "x-webhook-signature": sign("wrong-secret", rawBody),
185
+ });
186
+ await handleWebhooks(req, res, KAPSO_PATH);
187
+ expect(captured.status).toBe(401);
188
+ });
189
+
190
+ test("missing signature → 401", async () => {
191
+ const rawBody = JSON.stringify(
192
+ makePayload({ phoneNumberId: "pn-http", messageId: "wamid.HTTP_NOSIG" }),
193
+ );
194
+ const { req, res, captured } = fakeReqRes(rawBody, {});
195
+ await handleWebhooks(req, res, KAPSO_PATH);
196
+ expect(captured.status).toBe(401);
197
+ });
198
+ });
@@ -0,0 +1,325 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
+ import { closeDb, createTaskExtended, createUser, getDb, getTaskById, initDb } from "../be/db";
11
+ import { type IdentityActor, mintToken, revokeToken } from "../be/users";
12
+ import { handleCore } from "../http/core";
13
+ import { handleMcp } from "../http/mcp";
14
+ import { handleMcpUser } from "../http/mcp-user";
15
+
16
+ const TEST_DB_PATH = "./test-mcp-user-route.sqlite";
17
+ const API_KEY = "test-mcp-user-key";
18
+ const ACTOR: IdentityActor = { kind: "operator", id: "test" };
19
+
20
+ async function removeDbFiles(path: string): Promise<void> {
21
+ for (const suffix of ["", "-wal", "-shm"]) {
22
+ try {
23
+ await unlink(path + suffix);
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
26
+ }
27
+ }
28
+ }
29
+
30
+ async function listen(server: Server): Promise<number> {
31
+ const port = 15173;
32
+ await new Promise<void>((resolve, reject) => {
33
+ server.once("error", reject);
34
+ server.listen(port, "127.0.0.1", () => {
35
+ server.off("error", reject);
36
+ resolve();
37
+ });
38
+ });
39
+ const addr = server.address();
40
+ if (!addr || typeof addr === "string") throw new Error("no port");
41
+ return addr.port;
42
+ }
43
+
44
+ function createTestServer(): Server {
45
+ const transports: Record<string, StreamableHTTPServerTransport> = {};
46
+ const transportsUser: Record<string, StreamableHTTPServerTransport> = {};
47
+ const sessionUsers: Record<string, string> = {};
48
+
49
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
50
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
51
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
52
+ if (await handleMcp(req, res, transports)) return;
53
+ if (await handleMcpUser(req, res, transportsUser, sessionUsers)) return;
54
+ res.writeHead(404);
55
+ res.end("Not Found");
56
+ });
57
+ }
58
+
59
+ let server: Server;
60
+ let port: number;
61
+
62
+ beforeAll(async () => {
63
+ await removeDbFiles(TEST_DB_PATH);
64
+ initDb(TEST_DB_PATH);
65
+ server = createTestServer();
66
+ port = await listen(server);
67
+ });
68
+
69
+ afterAll(async () => {
70
+ await new Promise<void>((resolve) => server.close(() => resolve()));
71
+ closeDb();
72
+ await removeDbFiles(TEST_DB_PATH);
73
+ });
74
+
75
+ beforeEach(() => {
76
+ // Clean slate between tests for deterministic token and task state.
77
+ const db = getDb();
78
+ db.run("DELETE FROM user_identity_events");
79
+ db.run("DELETE FROM user_tokens");
80
+ db.run("DELETE FROM agent_tasks");
81
+ db.run("DELETE FROM users");
82
+ });
83
+
84
+ function endpoint(path = "/mcp-user"): string {
85
+ return `http://localhost:${port}${path}`;
86
+ }
87
+
88
+ function parseMcpPayload(text: string): unknown {
89
+ const trimmed = text.trim();
90
+ if (!trimmed) return null;
91
+ if (trimmed.startsWith("event:") || trimmed.startsWith("data:")) {
92
+ const data = trimmed
93
+ .split("\n")
94
+ .filter((line) => line.startsWith("data:"))
95
+ .map((line) => line.slice("data:".length).trim())
96
+ .join("\n");
97
+ return JSON.parse(data);
98
+ }
99
+ return JSON.parse(trimmed);
100
+ }
101
+
102
+ async function mcpPost(
103
+ token: string | null,
104
+ body: Record<string, unknown>,
105
+ sessionId?: string,
106
+ path = "/mcp-user",
107
+ extraHeaders?: Record<string, string>,
108
+ ): Promise<{ response: Response; payload: unknown; text: string }> {
109
+ const headers: Record<string, string> = {
110
+ Accept: "application/json, text/event-stream",
111
+ "Content-Type": "application/json",
112
+ ...extraHeaders,
113
+ };
114
+ if (token) headers.Authorization = `Bearer ${token}`;
115
+ if (sessionId) headers["mcp-session-id"] = sessionId;
116
+
117
+ const response = await fetch(endpoint(path), {
118
+ method: "POST",
119
+ headers,
120
+ body: JSON.stringify(body),
121
+ });
122
+ const text = await response.text();
123
+ const payload = text ? parseMcpPayload(text) : null;
124
+ return { response, payload, text };
125
+ }
126
+
127
+ async function initialize(
128
+ token: string,
129
+ path = "/mcp-user",
130
+ extraHeaders?: Record<string, string>,
131
+ ): Promise<string> {
132
+ const { response, text } = await mcpPost(
133
+ token,
134
+ {
135
+ jsonrpc: "2.0",
136
+ id: 1,
137
+ method: "initialize",
138
+ params: {
139
+ protocolVersion: "2024-11-05",
140
+ clientInfo: { name: "test", version: "1" },
141
+ capabilities: {},
142
+ },
143
+ },
144
+ undefined,
145
+ path,
146
+ extraHeaders,
147
+ );
148
+ expect(response.status).toBe(200);
149
+ const sessionId = response.headers.get("mcp-session-id");
150
+ if (!sessionId) throw new Error(`missing mcp-session-id from initialize response: ${text}`);
151
+ return sessionId;
152
+ }
153
+
154
+ async function notifyInitialized(
155
+ token: string,
156
+ sessionId: string,
157
+ path = "/mcp-user",
158
+ extraHeaders?: Record<string, string>,
159
+ ): Promise<void> {
160
+ const { response } = await mcpPost(
161
+ token,
162
+ { jsonrpc: "2.0", method: "notifications/initialized" },
163
+ sessionId,
164
+ path,
165
+ extraHeaders,
166
+ );
167
+ expect([200, 202]).toContain(response.status);
168
+ }
169
+
170
+ describe("/mcp-user auth and tool surface", () => {
171
+ test("request to /mcp-user with no token returns 401", async () => {
172
+ const { response } = await mcpPost(null, {
173
+ jsonrpc: "2.0",
174
+ id: 1,
175
+ method: "initialize",
176
+ params: {
177
+ protocolVersion: "2024-11-05",
178
+ clientInfo: { name: "test", version: "1" },
179
+ capabilities: {},
180
+ },
181
+ });
182
+
183
+ expect(response.status).toBe(401);
184
+ });
185
+
186
+ test("request to /mcp-user with a revoked token returns 401", async () => {
187
+ const user = createUser({ name: "Revoked User" });
188
+ const token = mintToken(user.id, "revoked", ACTOR);
189
+ revokeToken(token.tokenId, ACTOR);
190
+
191
+ const { response } = await mcpPost(token.plaintext, {
192
+ jsonrpc: "2.0",
193
+ id: 1,
194
+ method: "initialize",
195
+ params: {
196
+ protocolVersion: "2024-11-05",
197
+ clientInfo: { name: "test", version: "1" },
198
+ capabilities: {},
199
+ },
200
+ });
201
+
202
+ expect(response.status).toBe(401);
203
+ });
204
+
205
+ test("request to /mcp-user with a suspended user's valid token returns 401", async () => {
206
+ const user = createUser({ name: "Suspended User", status: "suspended" });
207
+ const token = mintToken(user.id, "suspended", ACTOR);
208
+
209
+ const { response } = await mcpPost(token.plaintext, {
210
+ jsonrpc: "2.0",
211
+ id: 1,
212
+ method: "initialize",
213
+ params: {
214
+ protocolVersion: "2024-11-05",
215
+ clientInfo: { name: "test", version: "1" },
216
+ capabilities: {},
217
+ },
218
+ });
219
+
220
+ expect(response.status).toBe(401);
221
+ });
222
+
223
+ test("request with a different user token than the opening session returns 401", async () => {
224
+ const userA = createUser({ name: "Session A" });
225
+ const userB = createUser({ name: "Session B" });
226
+ const tokenA = mintToken(userA.id, "a", ACTOR).plaintext;
227
+ const tokenB = mintToken(userB.id, "b", ACTOR).plaintext;
228
+ const sessionId = await initialize(tokenA);
229
+
230
+ const { response } = await mcpPost(
231
+ tokenB,
232
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
233
+ sessionId,
234
+ );
235
+
236
+ expect(response.status).toBe(401);
237
+ });
238
+
239
+ test("valid active-user token initializes and tools/list returns exactly the 5 task tools", async () => {
240
+ const user = createUser({ name: "Active User" });
241
+ const token = mintToken(user.id, "active", ACTOR).plaintext;
242
+ const sessionId = await initialize(token);
243
+ await notifyInitialized(token, sessionId);
244
+
245
+ const { response, payload } = await mcpPost(
246
+ token,
247
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
248
+ sessionId,
249
+ );
250
+
251
+ expect(response.status).toBe(200);
252
+ const result = payload as { result: { tools: Array<{ name: string }> } };
253
+ const names = result.result.tools.map((tool) => tool.name).sort();
254
+ expect(names).toEqual(
255
+ ["cancel-task", "get-task-details", "get-tasks", "send-task", "task-action"].sort(),
256
+ );
257
+ });
258
+
259
+ test("send-task over /mcp-user records requestedByUserId and get-tasks returns only that user's tasks", async () => {
260
+ const user = createUser({ name: "Task Requester" });
261
+ const otherUser = createUser({ name: "Other Task Requester" });
262
+ const token = mintToken(user.id, "task", ACTOR).plaintext;
263
+ const sessionId = await initialize(token);
264
+ await notifyInitialized(token, sessionId);
265
+
266
+ const { response, payload } = await mcpPost(
267
+ token,
268
+ {
269
+ jsonrpc: "2.0",
270
+ id: 3,
271
+ method: "tools/call",
272
+ params: { name: "send-task", arguments: { task: "user mcp task" } },
273
+ },
274
+ sessionId,
275
+ );
276
+
277
+ expect(response.status).toBe(200);
278
+ const result = payload as { result: { structuredContent: { task: { id: string } } } };
279
+ const taskId = result.result.structuredContent.task.id;
280
+ expect(getTaskById(taskId)?.requestedByUserId).toBe(user.id);
281
+ const foreignTask = createTaskExtended("foreign user mcp task", {
282
+ requestedByUserId: otherUser.id,
283
+ });
284
+ createTaskExtended("owner-only task");
285
+
286
+ const listResponse = await mcpPost(
287
+ token,
288
+ {
289
+ jsonrpc: "2.0",
290
+ id: 4,
291
+ method: "tools/call",
292
+ params: { name: "get-tasks", arguments: { includeFull: true, limit: 50 } },
293
+ },
294
+ sessionId,
295
+ );
296
+
297
+ expect(listResponse.response.status).toBe(200);
298
+ const listResult = listResponse.payload as {
299
+ result: { structuredContent: { tasks: Array<{ id: string; task?: string }> } };
300
+ };
301
+ const ids = listResult.result.structuredContent.tasks.map((task) => task.id);
302
+ expect(ids).toContain(taskId);
303
+ expect(ids).not.toContain(foreignTask.id);
304
+ expect(listResult.result.structuredContent.tasks).toHaveLength(1);
305
+ });
306
+
307
+ test("owner /mcp path still initializes with swarm API key", async () => {
308
+ const ownerHeaders = { "X-Agent-ID": "00000000-0000-4000-8000-000000000001" };
309
+ const sessionId = await initialize(API_KEY, "/mcp", ownerHeaders);
310
+ await notifyInitialized(API_KEY, sessionId, "/mcp", ownerHeaders);
311
+
312
+ const { response, payload } = await mcpPost(
313
+ API_KEY,
314
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
315
+ sessionId,
316
+ "/mcp",
317
+ ownerHeaders,
318
+ );
319
+
320
+ expect(response.status).toBe(200);
321
+ const result = payload as { result: { tools: Array<{ name: string }> } };
322
+ const names = result.result.tools.map((tool) => tool.name);
323
+ expect(names).toContain("send-task");
324
+ });
325
+ });
@@ -141,6 +141,81 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
141
141
  expect(result.failureReason).toContain("provider overloaded");
142
142
  });
143
143
 
144
+ test("prompt Model not found refreshes OpenRouter cache and retries once", async () => {
145
+ const emitted: ProviderEvent[] = [];
146
+ const refreshCalls: Array<{ model?: string; configFilePath: string; dataHomePath: string }> =
147
+ [];
148
+ const fakeSessionId = "sess-abc-123";
149
+ let promptCalls = 0;
150
+ let resolveSecondPrompt!: () => void;
151
+ const secondPromptSent = new Promise<void>((resolve) => {
152
+ resolveSecondPrompt = resolve;
153
+ });
154
+
155
+ const fakeClient = {
156
+ session: {
157
+ create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
158
+ prompt: async (args: unknown) => {
159
+ lastPromptArgs = args;
160
+ promptCalls += 1;
161
+ if (promptCalls === 1) {
162
+ throw new Error(
163
+ "Model not found: openrouter/x-ai/grok-4.3. Did you mean: x-ai/grok-4.3?",
164
+ );
165
+ }
166
+ resolveSecondPrompt();
167
+ return { data: {}, error: undefined };
168
+ },
169
+ },
170
+ event: {
171
+ subscribe: async () => ({
172
+ stream: (async function* (): AsyncGenerator<OpencodeEvent> {
173
+ await secondPromptSent;
174
+ yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
175
+ })(),
176
+ }),
177
+ },
178
+ };
179
+ const fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
180
+
181
+ mock.module("@opencode-ai/sdk", () => ({
182
+ createOpencode: async () => ({ client: fakeClient, server: fakeServer }),
183
+ }));
184
+
185
+ const { OpencodeAdapter, _setOpenRouterModelCacheRefreshForTests } = await import(
186
+ "../providers/opencode-adapter"
187
+ );
188
+ _setOpenRouterModelCacheRefreshForTests(
189
+ async (opencodeConfig, configFilePath, dataHomePath) => {
190
+ refreshCalls.push({ model: opencodeConfig.model, configFilePath, dataHomePath });
191
+ },
192
+ );
193
+ try {
194
+ const adapter = new OpencodeAdapter();
195
+ const session = await adapter.createSession(
196
+ testConfig({ model: "openrouter/x-ai/grok-4.3", taskId: "task-refresh" }),
197
+ );
198
+ session.onEvent((e) => emitted.push(e));
199
+
200
+ const result = await session.waitForCompletion();
201
+
202
+ expect(result.isError).toBe(false);
203
+ expect(promptCalls).toBe(2);
204
+ expect(refreshCalls).toEqual([
205
+ {
206
+ model: "openrouter/x-ai/grok-4.3",
207
+ configFilePath: "/tmp/opencode-task-refresh.json",
208
+ dataHomePath: "/tmp/opencode-data-task-refresh",
209
+ },
210
+ ]);
211
+ expect(emitted.some((e) => e.type === "progress" && e.message.includes("refreshing"))).toBe(
212
+ true,
213
+ );
214
+ } finally {
215
+ _setOpenRouterModelCacheRefreshForTests(null);
216
+ }
217
+ });
218
+
144
219
  test("permission.updated → emits error (headless cannot approve)", async () => {
145
220
  const events: OpencodeEvent[] = [
146
221
  {