@desplega.ai/agent-swarm 1.71.2 → 1.72.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 -2
- package/openapi.json +994 -62
- package/package.json +2 -1
- package/src/be/budget-admission.ts +121 -0
- package/src/be/budget-refusal-notify.ts +145 -0
- package/src/be/db.ts +488 -5
- package/src/be/migrations/044_provider_meta.sql +2 -0
- package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
- package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
- package/src/cli.tsx +22 -1
- package/src/commands/claude-managed-setup.ts +687 -0
- package/src/commands/codex-login.ts +1 -1
- package/src/commands/runner.ts +175 -28
- package/src/commands/templates.ts +10 -6
- package/src/http/budgets.ts +219 -0
- package/src/http/index.ts +6 -0
- package/src/http/integrations.ts +134 -0
- package/src/http/poll.ts +161 -3
- package/src/http/pricing.ts +245 -0
- package/src/http/session-data.ts +54 -6
- package/src/http/tasks.ts +23 -2
- package/src/prompts/base-prompt.ts +103 -73
- package/src/prompts/session-templates.ts +43 -0
- package/src/providers/claude-adapter.ts +3 -1
- package/src/providers/claude-managed-adapter.ts +871 -0
- package/src/providers/claude-managed-models.ts +117 -0
- package/src/providers/claude-managed-swarm-events.ts +77 -0
- package/src/providers/codex-adapter.ts +3 -1
- package/src/providers/codex-skill-resolver.ts +10 -0
- package/src/providers/codex-swarm-events.ts +20 -161
- package/src/providers/devin-adapter.ts +894 -0
- package/src/providers/devin-api.ts +207 -0
- package/src/providers/devin-playbooks.ts +91 -0
- package/src/providers/devin-skill-resolver.ts +113 -0
- package/src/providers/index.ts +10 -1
- package/src/providers/pi-mono-adapter.ts +3 -1
- package/src/providers/swarm-events-shared.ts +262 -0
- package/src/providers/types.ts +26 -1
- package/src/tests/base-prompt.test.ts +199 -0
- package/src/tests/budget-admission.test.ts +339 -0
- package/src/tests/budget-claim-gate.test.ts +288 -0
- package/src/tests/budget-refusal-notification.test.ts +324 -0
- package/src/tests/budgets-routes.test.ts +331 -0
- package/src/tests/claude-managed-adapter.test.ts +1301 -0
- package/src/tests/claude-managed-setup.test.ts +325 -0
- package/src/tests/devin-adapter.test.ts +677 -0
- package/src/tests/devin-api.test.ts +339 -0
- package/src/tests/integrations-http.test.ts +211 -0
- package/src/tests/migration-046-budgets.test.ts +327 -0
- package/src/tests/pricing-routes.test.ts +315 -0
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +2 -2
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/runner-budget-refused.test.ts +271 -0
- package/src/tests/session-costs-codex-recompute.test.ts +386 -0
- package/src/tools/poll-task.ts +13 -2
- package/src/tools/task-action.ts +92 -2
- package/src/tools/templates.ts +29 -0
- package/src/types.ts +116 -0
- package/src/utils/budget-backoff.ts +34 -0
- package/src/utils/credentials.ts +4 -0
- package/src/utils/provider-metadata.ts +9 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Devin REST API client (`src/providers/devin-api.ts`).
|
|
3
|
+
*
|
|
4
|
+
* A minimal `node:http` mock server returns canned JSON responses based on the
|
|
5
|
+
* request path and method. The test sets `DEVIN_API_BASE_URL` so the client
|
|
6
|
+
* hits the mock instead of the real Devin API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
11
|
+
import * as devinApi from "../providers/devin-api";
|
|
12
|
+
|
|
13
|
+
const TEST_PORT = 13050;
|
|
14
|
+
const TEST_BASE_URL = `http://localhost:${TEST_PORT}`;
|
|
15
|
+
const ORG_ID = "org-test-123";
|
|
16
|
+
const API_KEY = "cog_test_key";
|
|
17
|
+
|
|
18
|
+
// Canned responses -----------------------------------------------------------
|
|
19
|
+
const SESSION_RESPONSE = {
|
|
20
|
+
session_id: "ses-abc-123",
|
|
21
|
+
url: "https://app.devin.ai/sessions/ses-abc-123",
|
|
22
|
+
status: "new",
|
|
23
|
+
created_at: 1700000000,
|
|
24
|
+
updated_at: 1700000000,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const PLAYBOOK_RESPONSE = {
|
|
28
|
+
playbook_id: "pb-xyz-789",
|
|
29
|
+
title: "test playbook",
|
|
30
|
+
body: "do the thing",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Mock HTTP server
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
let server: Server;
|
|
38
|
+
|
|
39
|
+
/** Last request metadata captured by the mock, for assertion purposes. */
|
|
40
|
+
let lastRequest: {
|
|
41
|
+
method: string;
|
|
42
|
+
url: string;
|
|
43
|
+
headers: Record<string, string | string[] | undefined>;
|
|
44
|
+
body: string;
|
|
45
|
+
} | null = null;
|
|
46
|
+
|
|
47
|
+
/** Override the response for the next request (one-shot). */
|
|
48
|
+
let nextResponse: { status: number; body: string } | null = null;
|
|
49
|
+
|
|
50
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const chunks: Buffer[] = [];
|
|
53
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
54
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handler(req: IncomingMessage, res: ServerResponse): void {
|
|
59
|
+
void (async () => {
|
|
60
|
+
const body = await readBody(req);
|
|
61
|
+
lastRequest = {
|
|
62
|
+
method: req.method ?? "GET",
|
|
63
|
+
url: req.url ?? "/",
|
|
64
|
+
headers: req.headers as Record<string, string | string[] | undefined>,
|
|
65
|
+
body,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// One-shot override
|
|
69
|
+
if (nextResponse) {
|
|
70
|
+
const nr = nextResponse;
|
|
71
|
+
nextResponse = null;
|
|
72
|
+
res.writeHead(nr.status, { "Content-Type": "application/json" });
|
|
73
|
+
res.end(nr.body);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const url = req.url ?? "";
|
|
78
|
+
const method = req.method ?? "GET";
|
|
79
|
+
|
|
80
|
+
// POST /v3/organizations/:orgId/sessions
|
|
81
|
+
if (method === "POST" && url.match(/\/v3\/organizations\/[^/]+\/sessions$/)) {
|
|
82
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
83
|
+
res.end(JSON.stringify(SESSION_RESPONSE));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// GET /v3/organizations/:orgId/sessions/:sessionId
|
|
88
|
+
if (method === "GET" && url.match(/\/v3\/organizations\/[^/]+\/sessions\/[^/]+$/)) {
|
|
89
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
90
|
+
res.end(JSON.stringify(SESSION_RESPONSE));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// POST /v3/organizations/:orgId/sessions/:sessionId/messages
|
|
95
|
+
if (method === "POST" && url.match(/\/v3\/organizations\/[^/]+\/sessions\/[^/]+\/messages$/)) {
|
|
96
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ ok: true }));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// POST /v3/organizations/:orgId/sessions/:sessionId/archive
|
|
102
|
+
if (method === "POST" && url.match(/\/v3\/organizations\/[^/]+\/sessions\/[^/]+\/archive$/)) {
|
|
103
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ ok: true }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// GET /v3/organizations/:orgId/sessions/:sessionId/messages
|
|
109
|
+
if (method === "GET" && url.match(/\/v3\/organizations\/[^/]+\/sessions\/[^/]+\/messages/)) {
|
|
110
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
111
|
+
res.end(
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
items: [
|
|
114
|
+
{ event_id: "msg-001", source: "user", message: "hello", created_at: 1700000001 },
|
|
115
|
+
{ event_id: "msg-002", source: "devin", message: "hi there", created_at: 1700000002 },
|
|
116
|
+
],
|
|
117
|
+
end_cursor: "cursor-abc",
|
|
118
|
+
has_next_page: false,
|
|
119
|
+
total: 2,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// POST /v3/organizations/:orgId/playbooks
|
|
126
|
+
if (method === "POST" && url.match(/\/v3\/organizations\/[^/]+\/playbooks$/)) {
|
|
127
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
128
|
+
res.end(JSON.stringify(PLAYBOOK_RESPONSE));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
133
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
134
|
+
})();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
beforeAll(async () => {
|
|
138
|
+
process.env.DEVIN_API_BASE_URL = TEST_BASE_URL;
|
|
139
|
+
|
|
140
|
+
await new Promise<void>((resolve) => {
|
|
141
|
+
server = createServer(handler);
|
|
142
|
+
server.listen(TEST_PORT, () => resolve());
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterAll(() => {
|
|
147
|
+
server.close();
|
|
148
|
+
delete process.env.DEVIN_API_BASE_URL;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Tests
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
describe("devin-api: createSession", () => {
|
|
156
|
+
test("success — returns DevinSessionResponse", async () => {
|
|
157
|
+
const result = await devinApi.createSession(ORG_ID, API_KEY, {
|
|
158
|
+
prompt: "hello devin",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result.session_id).toBe("ses-abc-123");
|
|
162
|
+
expect(result.url).toBe("https://app.devin.ai/sessions/ses-abc-123");
|
|
163
|
+
expect(result.status).toBe("new");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("4xx error — throws with status and body", async () => {
|
|
167
|
+
nextResponse = {
|
|
168
|
+
status: 422,
|
|
169
|
+
body: JSON.stringify({ error: "invalid prompt" }),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
await expect(devinApi.createSession(ORG_ID, API_KEY, { prompt: "" })).rejects.toThrow(
|
|
173
|
+
/HTTP 422/,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("auth header carries Bearer token", async () => {
|
|
178
|
+
lastRequest = null;
|
|
179
|
+
await devinApi.createSession(ORG_ID, API_KEY, { prompt: "check headers" });
|
|
180
|
+
|
|
181
|
+
expect(lastRequest).not.toBeNull();
|
|
182
|
+
expect(lastRequest!.headers.authorization).toBe(`Bearer ${API_KEY}`);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("URL includes org ID", async () => {
|
|
186
|
+
lastRequest = null;
|
|
187
|
+
await devinApi.createSession(ORG_ID, API_KEY, { prompt: "check url" });
|
|
188
|
+
|
|
189
|
+
expect(lastRequest!.url).toContain(`/v3/organizations/${ORG_ID}/sessions`);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("devin-api: getSession", () => {
|
|
194
|
+
test("success — returns session data", async () => {
|
|
195
|
+
const result = await devinApi.getSession(ORG_ID, API_KEY, "ses-abc-123");
|
|
196
|
+
expect(result.session_id).toBe("ses-abc-123");
|
|
197
|
+
expect(result.status).toBe("new");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("URL includes session ID", async () => {
|
|
201
|
+
lastRequest = null;
|
|
202
|
+
await devinApi.getSession(ORG_ID, API_KEY, "ses-poll-test");
|
|
203
|
+
expect(lastRequest!.url).toContain("/ses-poll-test");
|
|
204
|
+
expect(lastRequest!.method).toBe("GET");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("devin-api: sendMessage", () => {
|
|
209
|
+
test("success — does not throw", async () => {
|
|
210
|
+
await expect(
|
|
211
|
+
devinApi.sendMessage(ORG_ID, API_KEY, "ses-abc-123", "approve"),
|
|
212
|
+
).resolves.toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("sends message in JSON body", async () => {
|
|
216
|
+
lastRequest = null;
|
|
217
|
+
await devinApi.sendMessage(ORG_ID, API_KEY, "ses-abc-123", "my message");
|
|
218
|
+
|
|
219
|
+
const body = JSON.parse(lastRequest!.body);
|
|
220
|
+
expect(body.message).toBe("my message");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("devin-api: archiveSession", () => {
|
|
225
|
+
test("success — does not throw", async () => {
|
|
226
|
+
await expect(devinApi.archiveSession(ORG_ID, API_KEY, "ses-abc-123")).resolves.toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("URL includes /archive", async () => {
|
|
230
|
+
lastRequest = null;
|
|
231
|
+
await devinApi.archiveSession(ORG_ID, API_KEY, "ses-archive-test");
|
|
232
|
+
expect(lastRequest!.url).toContain("/ses-archive-test/archive");
|
|
233
|
+
expect(lastRequest!.method).toBe("POST");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("devin-api: getSessionMessages", () => {
|
|
238
|
+
test("success — returns messages with cursor info", async () => {
|
|
239
|
+
const result = await devinApi.getSessionMessages(ORG_ID, API_KEY, "ses-abc-123");
|
|
240
|
+
expect(result.items).toHaveLength(2);
|
|
241
|
+
expect(result.items[0].event_id).toBe("msg-001");
|
|
242
|
+
expect(result.items[1].source).toBe("devin");
|
|
243
|
+
expect(result.end_cursor).toBe("cursor-abc");
|
|
244
|
+
expect(result.has_next_page).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("URL includes session ID and default pagination", async () => {
|
|
248
|
+
lastRequest = null;
|
|
249
|
+
await devinApi.getSessionMessages(ORG_ID, API_KEY, "ses-msg-test");
|
|
250
|
+
expect(lastRequest!.method).toBe("GET");
|
|
251
|
+
expect(lastRequest!.url).toContain("/ses-msg-test/messages");
|
|
252
|
+
expect(lastRequest!.url).toContain("first=200");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("passes cursor via after param", async () => {
|
|
256
|
+
lastRequest = null;
|
|
257
|
+
await devinApi.getSessionMessages(ORG_ID, API_KEY, "ses-abc-123", "cursor-prev");
|
|
258
|
+
expect(lastRequest!.url).toContain("after=cursor-prev");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("4xx error — throws with status", async () => {
|
|
262
|
+
nextResponse = { status: 404, body: JSON.stringify({ error: "session not found" }) };
|
|
263
|
+
await expect(devinApi.getSessionMessages(ORG_ID, API_KEY, "ses-missing")).rejects.toThrow(
|
|
264
|
+
/HTTP 404/,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("devin-api: createPlaybook", () => {
|
|
270
|
+
test("success — returns DevinPlaybookResponse", async () => {
|
|
271
|
+
const result = await devinApi.createPlaybook(ORG_ID, API_KEY, {
|
|
272
|
+
title: "test playbook",
|
|
273
|
+
body: "do the thing",
|
|
274
|
+
});
|
|
275
|
+
expect(result.playbook_id).toBe("pb-xyz-789");
|
|
276
|
+
expect(result.title).toBe("test playbook");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("sends title and body in JSON body", async () => {
|
|
280
|
+
lastRequest = null;
|
|
281
|
+
await devinApi.createPlaybook(ORG_ID, API_KEY, {
|
|
282
|
+
title: "my pb",
|
|
283
|
+
body: "instructions here",
|
|
284
|
+
});
|
|
285
|
+
const body = JSON.parse(lastRequest!.body);
|
|
286
|
+
expect(body.title).toBe("my pb");
|
|
287
|
+
expect(body.body).toBe("instructions here");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("devin-api: error handling", () => {
|
|
292
|
+
test("5xx response throws with status", async () => {
|
|
293
|
+
nextResponse = {
|
|
294
|
+
status: 500,
|
|
295
|
+
body: JSON.stringify({ error: "internal server error" }),
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
await expect(devinApi.getSession(ORG_ID, API_KEY, "ses-abc-123")).rejects.toThrow(/HTTP 500/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("5xx includes response body in error message", async () => {
|
|
302
|
+
nextResponse = {
|
|
303
|
+
status: 503,
|
|
304
|
+
body: JSON.stringify({ error: "service unavailable" }),
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
await devinApi.createSession(ORG_ID, API_KEY, { prompt: "test" });
|
|
309
|
+
expect.unreachable("should have thrown");
|
|
310
|
+
} catch (err) {
|
|
311
|
+
expect((err as Error).message).toContain("service unavailable");
|
|
312
|
+
expect((err as Error).message).toContain("503");
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("error message includes the operation label", async () => {
|
|
317
|
+
nextResponse = { status: 400, body: "bad request" };
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await devinApi.archiveSession(ORG_ID, API_KEY, "ses-test");
|
|
321
|
+
expect.unreachable("should have thrown");
|
|
322
|
+
} catch (err) {
|
|
323
|
+
expect((err as Error).message).toContain("archiveSession");
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("devin-api: base URL override", () => {
|
|
329
|
+
test("DEVIN_API_BASE_URL is respected (requests reach mock server)", async () => {
|
|
330
|
+
// The fact that all the above tests work at all proves the base URL
|
|
331
|
+
// override is working — but let's be explicit about it.
|
|
332
|
+
lastRequest = null;
|
|
333
|
+
await devinApi.getSession(ORG_ID, API_KEY, "ses-url-test");
|
|
334
|
+
// The request reached our mock server on TEST_PORT, which only happens
|
|
335
|
+
// if the base URL was overridden from the default https://api.devin.ai.
|
|
336
|
+
expect(lastRequest).not.toBeNull();
|
|
337
|
+
expect(lastRequest!.url).toBe(`/v3/organizations/${ORG_ID}/sessions/ses-url-test`);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
|
+
import { closeDb, deleteSwarmConfig, getSwarmConfigs, initDb, upsertSwarmConfig } from "../be/db";
|
|
5
|
+
import { type ClaudeManagedTestClient, createIntegrationsHandler } from "../http/integrations";
|
|
6
|
+
import { getPathSegments } from "../http/utils";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Tests for POST /api/integrations/claude-managed/test
|
|
10
|
+
//
|
|
11
|
+
// Covers:
|
|
12
|
+
// - Success path — beta.agents.retrieve returns name + model.
|
|
13
|
+
// - Missing-config path — neither swarm_config nor process.env has the
|
|
14
|
+
// required keys → ok:false with a hint to run the setup CLI.
|
|
15
|
+
// - Anthropic API error path — retrieve throws → ok:false with the error
|
|
16
|
+
// message; HTTP status remains 200 (per the route contract).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const TEST_DB_PATH = "./test-integrations-http.sqlite";
|
|
20
|
+
const TEST_PORT = 13089;
|
|
21
|
+
|
|
22
|
+
interface FakeClientLog {
|
|
23
|
+
retrieveCalls: string[];
|
|
24
|
+
retrieveResult?: { name?: string | null; model?: string | null };
|
|
25
|
+
retrieveError?: Error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildFakeClient(log: FakeClientLog): ClaudeManagedTestClient {
|
|
29
|
+
return {
|
|
30
|
+
beta: {
|
|
31
|
+
agents: {
|
|
32
|
+
retrieve: async (agentId: string) => {
|
|
33
|
+
log.retrieveCalls.push(agentId);
|
|
34
|
+
if (log.retrieveError) throw log.retrieveError;
|
|
35
|
+
return log.retrieveResult ?? {};
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clearManagedConfigRows() {
|
|
43
|
+
const all = getSwarmConfigs({ scope: "global" });
|
|
44
|
+
for (const row of all) {
|
|
45
|
+
if (
|
|
46
|
+
row.key === "ANTHROPIC_API_KEY" ||
|
|
47
|
+
row.key === "MANAGED_AGENT_ID" ||
|
|
48
|
+
row.key === "MANAGED_ENVIRONMENT_ID"
|
|
49
|
+
) {
|
|
50
|
+
deleteSwarmConfig(row.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Also scrub process.env so resolveConfigValue's fallback doesn't bleed
|
|
54
|
+
// values from the host environment into the test.
|
|
55
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
56
|
+
delete process.env.MANAGED_AGENT_ID;
|
|
57
|
+
delete process.env.MANAGED_ENVIRONMENT_ID;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("POST /api/integrations/claude-managed/test", () => {
|
|
61
|
+
let server: Server;
|
|
62
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
63
|
+
const log: FakeClientLog = { retrieveCalls: [] };
|
|
64
|
+
const savedEnv = {
|
|
65
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
66
|
+
MANAGED_AGENT_ID: process.env.MANAGED_AGENT_ID,
|
|
67
|
+
MANAGED_ENVIRONMENT_ID: process.env.MANAGED_ENVIRONMENT_ID,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
initDb(TEST_DB_PATH);
|
|
72
|
+
|
|
73
|
+
const handler = createIntegrationsHandler({
|
|
74
|
+
buildClient: () => buildFakeClient(log),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
server = createHttpServer(async (req, res) => {
|
|
78
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
79
|
+
const handled = await handler(req, res, pathSegments);
|
|
80
|
+
if (!handled) {
|
|
81
|
+
res.writeHead(404);
|
|
82
|
+
res.end("not found");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
await new Promise<void>((resolve) => {
|
|
86
|
+
server.listen(TEST_PORT, () => resolve());
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(async () => {
|
|
91
|
+
server.close();
|
|
92
|
+
closeDb();
|
|
93
|
+
await unlink(TEST_DB_PATH).catch(() => {});
|
|
94
|
+
await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
|
|
95
|
+
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
96
|
+
// Restore original env so other test files aren't affected.
|
|
97
|
+
for (const [k, v] of Object.entries(savedEnv)) {
|
|
98
|
+
if (v === undefined) delete process.env[k];
|
|
99
|
+
else process.env[k] = v;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
log.retrieveCalls = [];
|
|
105
|
+
log.retrieveResult = undefined;
|
|
106
|
+
log.retrieveError = undefined;
|
|
107
|
+
clearManagedConfigRows();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("success path — returns ok:true with agent name + model", async () => {
|
|
111
|
+
upsertSwarmConfig({
|
|
112
|
+
scope: "global",
|
|
113
|
+
key: "ANTHROPIC_API_KEY",
|
|
114
|
+
value: "sk-ant-test",
|
|
115
|
+
isSecret: true,
|
|
116
|
+
});
|
|
117
|
+
upsertSwarmConfig({
|
|
118
|
+
scope: "global",
|
|
119
|
+
key: "MANAGED_AGENT_ID",
|
|
120
|
+
value: "agent_abc123",
|
|
121
|
+
isSecret: false,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
log.retrieveResult = { name: "swarm-worker", model: "claude-sonnet-4-6" };
|
|
125
|
+
|
|
126
|
+
const res = await fetch(`${baseUrl}/api/integrations/claude-managed/test`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { "Content-Type": "application/json" },
|
|
129
|
+
body: "{}",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
const body = await res.json();
|
|
134
|
+
expect(body).toEqual({
|
|
135
|
+
ok: true,
|
|
136
|
+
agentName: "swarm-worker",
|
|
137
|
+
model: "claude-sonnet-4-6",
|
|
138
|
+
});
|
|
139
|
+
expect(log.retrieveCalls).toEqual(["agent_abc123"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("missing-config path — returns ok:false with helpful error", async () => {
|
|
143
|
+
// No swarm_config rows, no env. resolveConfigValue should return null
|
|
144
|
+
// for both keys and short-circuit before calling Anthropic.
|
|
145
|
+
const res = await fetch(`${baseUrl}/api/integrations/claude-managed/test`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: "{}",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(res.status).toBe(200);
|
|
152
|
+
const body = (await res.json()) as { ok: boolean; error: string };
|
|
153
|
+
expect(body.ok).toBe(false);
|
|
154
|
+
expect(body.error).toContain("ANTHROPIC_API_KEY");
|
|
155
|
+
expect(body.error).toContain("MANAGED_AGENT_ID");
|
|
156
|
+
expect(body.error).toContain("claude-managed-setup");
|
|
157
|
+
// No SDK call should have been attempted.
|
|
158
|
+
expect(log.retrieveCalls).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("Anthropic API error — returns ok:false with the error message, HTTP 200", async () => {
|
|
162
|
+
upsertSwarmConfig({
|
|
163
|
+
scope: "global",
|
|
164
|
+
key: "ANTHROPIC_API_KEY",
|
|
165
|
+
value: "sk-ant-test",
|
|
166
|
+
isSecret: true,
|
|
167
|
+
});
|
|
168
|
+
upsertSwarmConfig({
|
|
169
|
+
scope: "global",
|
|
170
|
+
key: "MANAGED_AGENT_ID",
|
|
171
|
+
value: "agent_does_not_exist",
|
|
172
|
+
isSecret: false,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
log.retrieveError = new Error("404 not_found_error: agent not found");
|
|
176
|
+
|
|
177
|
+
const res = await fetch(`${baseUrl}/api/integrations/claude-managed/test`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/json" },
|
|
180
|
+
body: "{}",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(res.status).toBe(200);
|
|
184
|
+
const body = (await res.json()) as { ok: boolean; error: string };
|
|
185
|
+
expect(body.ok).toBe(false);
|
|
186
|
+
expect(body.error).toContain("agent not found");
|
|
187
|
+
expect(log.retrieveCalls).toEqual(["agent_does_not_exist"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("env-fallback path — uses process.env when swarm_config row missing", async () => {
|
|
191
|
+
process.env.ANTHROPIC_API_KEY = "sk-ant-fromenv";
|
|
192
|
+
process.env.MANAGED_AGENT_ID = "agent_env_fallback";
|
|
193
|
+
|
|
194
|
+
log.retrieveResult = { name: "from-env", model: "claude-sonnet-4-6" };
|
|
195
|
+
|
|
196
|
+
const res = await fetch(`${baseUrl}/api/integrations/claude-managed/test`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
body: "{}",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
expect(body).toEqual({
|
|
205
|
+
ok: true,
|
|
206
|
+
agentName: "from-env",
|
|
207
|
+
model: "claude-sonnet-4-6",
|
|
208
|
+
});
|
|
209
|
+
expect(log.retrieveCalls).toEqual(["agent_env_fallback"]);
|
|
210
|
+
});
|
|
211
|
+
});
|