@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.
Files changed (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Unit tests for DevinAdapter / DevinSession (`src/providers/devin-adapter.ts`).
3
+ *
4
+ * Uses a mock HTTP server (node:http) on port 13051 to simulate the Devin v3
5
+ * API. The mock supports controllable responses per-endpoint so individual
6
+ * tests can drive the polling loop through different session lifecycle paths.
7
+ *
8
+ * Because the API client captures `DEVIN_API_BASE_URL` at module-load time,
9
+ * we set env vars before the first import and use dynamic imports for the
10
+ * adapter module.
11
+ */
12
+
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { unlinkSync } from "node:fs";
15
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
16
+ import type { ProviderEvent, ProviderSessionConfig } from "../providers/types";
17
+
18
+ const TEST_PORT = 13051;
19
+ const TEST_BASE_URL = `http://localhost:${TEST_PORT}`;
20
+ const ORG_ID = "org-adapter-test";
21
+ const API_KEY = "cog_adapter_key";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Controllable mock state
25
+ // ---------------------------------------------------------------------------
26
+
27
+ type MockSessionResponse = {
28
+ session_id: string;
29
+ url: string;
30
+ status: string;
31
+ status_detail?: string;
32
+ structured_output?: unknown;
33
+ pull_requests?: Array<{ pr_url: string; pr_state: string }>;
34
+ acus_consumed?: number;
35
+ created_at: number;
36
+ updated_at: number;
37
+ };
38
+
39
+ /** The response returned by GET /sessions/:id (polling). Updated by tests. */
40
+ let pollResponse: MockSessionResponse = {
41
+ session_id: "ses-test-001",
42
+ url: "https://app.devin.ai/sessions/ses-test-001",
43
+ status: "new",
44
+ created_at: 1700000000,
45
+ updated_at: 1700000000,
46
+ };
47
+
48
+ /** If set, the next N poll requests will respond with this error status. */
49
+ let pollErrorResponse: { status: number; body: string } | null = null;
50
+ let pollErrorCount = 0;
51
+
52
+ /** Playbook creation responses. */
53
+ const playbookResponse = {
54
+ playbook_id: "pb-adapter-001",
55
+ title: "test",
56
+ body: "test body",
57
+ };
58
+
59
+ /** Track last request for assertions. */
60
+ let lastCreateSessionBody: Record<string, unknown> | null = null;
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Mock HTTP server
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function readBody(req: IncomingMessage): Promise<string> {
67
+ return new Promise((resolve) => {
68
+ const chunks: Buffer[] = [];
69
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
70
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
71
+ });
72
+ }
73
+
74
+ let server: Server;
75
+
76
+ function handler(req: IncomingMessage, res: ServerResponse): void {
77
+ void (async () => {
78
+ const body = await readBody(req);
79
+ const url = req.url ?? "";
80
+ const method = req.method ?? "GET";
81
+
82
+ // POST .../sessions — create session
83
+ if (
84
+ method === "POST" &&
85
+ url.match(/\/v3\/organizations\/[^/]+\/sessions$/) &&
86
+ !url.includes("/messages") &&
87
+ !url.includes("/archive")
88
+ ) {
89
+ lastCreateSessionBody = body ? JSON.parse(body) : null;
90
+ res.writeHead(200, { "Content-Type": "application/json" });
91
+ res.end(
92
+ JSON.stringify({
93
+ session_id: pollResponse.session_id,
94
+ url: pollResponse.url,
95
+ status: "new",
96
+ created_at: Date.now(),
97
+ updated_at: Date.now(),
98
+ }),
99
+ );
100
+ return;
101
+ }
102
+
103
+ // GET .../sessions/:id — poll
104
+ if (method === "GET" && url.match(/\/v3\/organizations\/[^/]+\/sessions\/[^/]+$/)) {
105
+ if (pollErrorResponse && pollErrorCount > 0) {
106
+ pollErrorCount--;
107
+ res.writeHead(pollErrorResponse.status, { "Content-Type": "application/json" });
108
+ res.end(pollErrorResponse.body);
109
+ return;
110
+ }
111
+
112
+ res.writeHead(200, { "Content-Type": "application/json" });
113
+ res.end(JSON.stringify(pollResponse));
114
+ return;
115
+ }
116
+
117
+ // POST .../messages
118
+ if (method === "POST" && url.includes("/messages")) {
119
+ res.writeHead(200, { "Content-Type": "application/json" });
120
+ res.end(JSON.stringify({ ok: true }));
121
+ return;
122
+ }
123
+
124
+ // POST .../archive
125
+ if (method === "POST" && url.includes("/archive")) {
126
+ res.writeHead(200, { "Content-Type": "application/json" });
127
+ res.end(JSON.stringify({ ok: true }));
128
+ return;
129
+ }
130
+
131
+ // POST .../playbooks
132
+ if (method === "POST" && url.match(/\/v3\/organizations\/[^/]+\/playbooks$/)) {
133
+ res.writeHead(200, { "Content-Type": "application/json" });
134
+ res.end(JSON.stringify(playbookResponse));
135
+ return;
136
+ }
137
+
138
+ res.writeHead(404, { "Content-Type": "application/json" });
139
+ res.end(JSON.stringify({ error: "not found" }));
140
+ })();
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Module imports (dynamic — after env setup)
145
+ // ---------------------------------------------------------------------------
146
+
147
+ let DevinAdapter: typeof import("../providers/devin-adapter").DevinAdapter;
148
+
149
+ const logFiles: string[] = [];
150
+ function testLogFile(): string {
151
+ const path = `/tmp/devin-adapter-test-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`;
152
+ logFiles.push(path);
153
+ return path;
154
+ }
155
+
156
+ function testConfig(overrides: Partial<ProviderSessionConfig> = {}): ProviderSessionConfig {
157
+ return {
158
+ prompt: "test prompt",
159
+ systemPrompt: "",
160
+ model: "devin",
161
+ role: "worker",
162
+ agentId: "agent-devin-test",
163
+ taskId: "task-devin-test",
164
+ apiUrl: "",
165
+ apiKey: "",
166
+ cwd: "/tmp",
167
+ logFile: testLogFile(),
168
+ ...overrides,
169
+ };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Setup / teardown
174
+ // ---------------------------------------------------------------------------
175
+
176
+ const savedEnv: Record<string, string | undefined> = {};
177
+
178
+ beforeAll(async () => {
179
+ // Save any existing env vars we'll mutate.
180
+ for (const key of [
181
+ "DEVIN_API_KEY",
182
+ "DEVIN_ORG_ID",
183
+ "DEVIN_POLL_INTERVAL_MS",
184
+ "DEVIN_ACU_COST_USD",
185
+ "DEVIN_API_BASE_URL",
186
+ ]) {
187
+ savedEnv[key] = process.env[key];
188
+ }
189
+
190
+ // Set test env vars — must happen BEFORE module import.
191
+ process.env.DEVIN_API_BASE_URL = TEST_BASE_URL;
192
+ process.env.DEVIN_API_KEY = API_KEY;
193
+ process.env.DEVIN_ORG_ID = ORG_ID;
194
+ process.env.DEVIN_POLL_INTERVAL_MS = "50";
195
+
196
+ await new Promise<void>((resolve) => {
197
+ server = createServer(handler);
198
+ server.listen(TEST_PORT, () => resolve());
199
+ });
200
+
201
+ const mod = await import("../providers/devin-adapter");
202
+ DevinAdapter = mod.DevinAdapter;
203
+ });
204
+
205
+ beforeEach(() => {
206
+ // Reset mock state between tests.
207
+ pollResponse = {
208
+ session_id: "ses-test-001",
209
+ url: "https://app.devin.ai/sessions/ses-test-001",
210
+ status: "new",
211
+ created_at: 1700000000,
212
+ updated_at: 1700000000,
213
+ };
214
+ pollErrorResponse = null;
215
+ pollErrorCount = 0;
216
+ lastCreateSessionBody = null;
217
+
218
+ // Ensure env vars are set (individual tests may clear them).
219
+ process.env.DEVIN_API_KEY = API_KEY;
220
+ process.env.DEVIN_ORG_ID = ORG_ID;
221
+ process.env.DEVIN_POLL_INTERVAL_MS = "50";
222
+ });
223
+
224
+ afterAll(() => {
225
+ server.close();
226
+
227
+ // Restore original env.
228
+ for (const [key, value] of Object.entries(savedEnv)) {
229
+ if (value === undefined) {
230
+ delete process.env[key];
231
+ } else {
232
+ process.env[key] = value;
233
+ }
234
+ }
235
+
236
+ // Clean up log files.
237
+ for (const f of logFiles) {
238
+ try {
239
+ unlinkSync(f);
240
+ } catch {
241
+ // Ignore missing.
242
+ }
243
+ }
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Helper: collect events until session settles (with timeout safety).
248
+ // ---------------------------------------------------------------------------
249
+
250
+ async function runUntilSettled(
251
+ adapter: InstanceType<typeof DevinAdapter>,
252
+ config: ProviderSessionConfig,
253
+ opts: { timeout?: number } = {},
254
+ ): Promise<{
255
+ events: ProviderEvent[];
256
+ result: Awaited<ReturnType<import("../providers/types").ProviderSession["waitForCompletion"]>>;
257
+ }> {
258
+ const session = await adapter.createSession(config);
259
+ const events: ProviderEvent[] = [];
260
+ session.onEvent((e) => events.push(e));
261
+
262
+ const timeout = opts.timeout ?? 5000;
263
+ const result = await Promise.race([
264
+ session.waitForCompletion(),
265
+ new Promise<never>((_, reject) =>
266
+ setTimeout(() => reject(new Error("Test timed out waiting for session to settle")), timeout),
267
+ ),
268
+ ]);
269
+
270
+ return { events, result };
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Tests
275
+ // ---------------------------------------------------------------------------
276
+
277
+ describe("DevinAdapter.createSession — env validation", () => {
278
+ test("throws without DEVIN_API_KEY", async () => {
279
+ delete process.env.DEVIN_API_KEY;
280
+ const adapter = new DevinAdapter();
281
+ await expect(adapter.createSession(testConfig())).rejects.toThrow(/DEVIN_API_KEY/);
282
+ });
283
+
284
+ test("throws without DEVIN_ORG_ID", async () => {
285
+ process.env.DEVIN_API_KEY = API_KEY;
286
+ delete process.env.DEVIN_ORG_ID;
287
+ const adapter = new DevinAdapter();
288
+ await expect(adapter.createSession(testConfig())).rejects.toThrow(/DEVIN_ORG_ID/);
289
+ });
290
+ });
291
+
292
+ describe("DevinAdapter.createSession — playbook creation", () => {
293
+ test("creates playbook from systemPrompt and attaches to session", async () => {
294
+ // Make the session go to exit immediately so the test settles.
295
+ pollResponse.status = "exit";
296
+ pollResponse.status_detail = "finished";
297
+ pollResponse.acus_consumed = 1;
298
+
299
+ const adapter = new DevinAdapter();
300
+ const config = testConfig({ systemPrompt: "You are a coding assistant." });
301
+ await runUntilSettled(adapter, config);
302
+
303
+ // The create-session call should include the playbook_id.
304
+ expect(lastCreateSessionBody).not.toBeNull();
305
+ expect(lastCreateSessionBody!.playbook_id).toBe("pb-adapter-001");
306
+ });
307
+
308
+ test("playbook cache returns same ID for same content hash", async () => {
309
+ pollResponse.status = "exit";
310
+ pollResponse.status_detail = "finished";
311
+
312
+ const adapter = new DevinAdapter();
313
+ const prompt = "Identical system prompt for caching";
314
+
315
+ // First call
316
+ const config1 = testConfig({ systemPrompt: prompt });
317
+ await runUntilSettled(adapter, config1);
318
+ expect(lastCreateSessionBody!.playbook_id).toBe("pb-adapter-001");
319
+
320
+ // Second call with same prompt — playbook cache should return the cached
321
+ // id without creating a new playbook. Since the mock always returns the
322
+ // same playbook_id, the key assertion is that it doesn't error.
323
+ const config2 = testConfig({ systemPrompt: prompt });
324
+ await runUntilSettled(adapter, config2);
325
+ expect(lastCreateSessionBody!.playbook_id).toBe("pb-adapter-001");
326
+ });
327
+ });
328
+
329
+ describe("DevinAdapter.createSession — repos", () => {
330
+ test("vcsRepo from config is passed to session creation", async () => {
331
+ pollResponse.status = "exit";
332
+ pollResponse.status_detail = "finished";
333
+
334
+ const adapter = new DevinAdapter();
335
+ await runUntilSettled(adapter, testConfig({ vcsRepo: "owner/repo1" }));
336
+
337
+ expect(lastCreateSessionBody!.repos).toEqual(["owner/repo1"]);
338
+ });
339
+
340
+ test("no repos when vcsRepo is not set", async () => {
341
+ pollResponse.status = "exit";
342
+ pollResponse.status_detail = "finished";
343
+
344
+ const adapter = new DevinAdapter();
345
+ await runUntilSettled(adapter, testConfig());
346
+
347
+ expect(lastCreateSessionBody!.repos).toBeUndefined();
348
+ });
349
+ });
350
+
351
+ describe("Polling loop — lifecycle events", () => {
352
+ test("new -> running -> exit emits correct ProviderEvent sequence", async () => {
353
+ // We change pollResponse in a timer to simulate state transitions.
354
+ const transitions = setTimeout(() => {
355
+ pollResponse.status = "running";
356
+ pollResponse.status_detail = "working";
357
+ }, 80);
358
+
359
+ const exitTimer = setTimeout(() => {
360
+ pollResponse.status = "exit";
361
+ pollResponse.status_detail = "finished";
362
+ pollResponse.acus_consumed = 3.5;
363
+ }, 200);
364
+
365
+ const adapter = new DevinAdapter();
366
+ const { events, result } = await runUntilSettled(adapter, testConfig());
367
+
368
+ clearTimeout(transitions);
369
+ clearTimeout(exitTimer);
370
+
371
+ // Must have session_init.
372
+ const sessionInit = events.find((e) => e.type === "session_init");
373
+ expect(sessionInit).toBeDefined();
374
+
375
+ // Must have a result event.
376
+ const resultEvent = events.findLast((e) => e.type === "result");
377
+ expect(resultEvent).toBeDefined();
378
+ if (resultEvent?.type === "result") {
379
+ expect(resultEvent.isError).toBe(false);
380
+ }
381
+
382
+ expect(result.isError).toBe(false);
383
+ expect(result.exitCode).toBe(0);
384
+ });
385
+
386
+ test("waiting_for_approval emits progress event", async () => {
387
+ // Start with approval state, then transition to exit.
388
+ pollResponse.status = "running";
389
+ pollResponse.status_detail = "waiting_for_approval";
390
+
391
+ const exitTimer = setTimeout(() => {
392
+ pollResponse.status = "exit";
393
+ pollResponse.status_detail = "finished";
394
+ }, 200);
395
+
396
+ const adapter = new DevinAdapter();
397
+ const { events } = await runUntilSettled(adapter, testConfig());
398
+ clearTimeout(exitTimer);
399
+
400
+ const progressEvent = events.find(
401
+ (e) => e.type === "progress" && e.message === "waiting for approval",
402
+ );
403
+ expect(progressEvent).toBeDefined();
404
+ });
405
+ });
406
+
407
+ describe("session_init — provider metadata", () => {
408
+ test("session_init event includes provider and providerMeta with sessionUrl", async () => {
409
+ pollResponse.status = "exit";
410
+ pollResponse.status_detail = "finished";
411
+
412
+ const adapter = new DevinAdapter();
413
+ const { events } = await runUntilSettled(adapter, testConfig());
414
+
415
+ const sessionInit = events.find((e) => e.type === "session_init");
416
+ expect(sessionInit).toBeDefined();
417
+ if (sessionInit?.type === "session_init") {
418
+ expect(sessionInit.provider).toBe("devin");
419
+ expect(sessionInit.providerMeta).toBeDefined();
420
+ expect(sessionInit.providerMeta?.sessionUrl).toBe(
421
+ "https://app.devin.ai/sessions/ses-test-001",
422
+ );
423
+ }
424
+ });
425
+ });
426
+
427
+ describe("Polling loop — suspended states", () => {
428
+ test("suspended/inactivity settles with suspended_inactivity", async () => {
429
+ pollResponse.status = "suspended";
430
+ pollResponse.status_detail = "inactivity";
431
+ pollResponse.acus_consumed = 2;
432
+
433
+ const adapter = new DevinAdapter();
434
+ const { events, result } = await runUntilSettled(adapter, testConfig());
435
+
436
+ expect(result.isError).toBe(true);
437
+ expect(result.exitCode).toBe(1);
438
+
439
+ const resultEvent = events.findLast((e) => e.type === "result");
440
+ if (resultEvent?.type === "result") {
441
+ expect(resultEvent.errorCategory).toBe("suspended_inactivity");
442
+ }
443
+ });
444
+
445
+ test("suspended/user_request settles with suspended_user", async () => {
446
+ pollResponse.status = "suspended";
447
+ pollResponse.status_detail = "user_request";
448
+
449
+ const adapter = new DevinAdapter();
450
+ const { result } = await runUntilSettled(adapter, testConfig());
451
+
452
+ expect(result.isError).toBe(true);
453
+ expect(result.errorCategory).toBe("suspended_user");
454
+ });
455
+
456
+ test("suspended/usage_limit_exceeded settles with suspended_cost", async () => {
457
+ pollResponse.status = "suspended";
458
+ pollResponse.status_detail = "usage_limit_exceeded";
459
+
460
+ const adapter = new DevinAdapter();
461
+ const { events, result } = await runUntilSettled(adapter, testConfig());
462
+
463
+ expect(result.isError).toBe(true);
464
+
465
+ const resultEvent = events.findLast((e) => e.type === "result");
466
+ if (resultEvent?.type === "result") {
467
+ expect(resultEvent.errorCategory).toBe("suspended_cost");
468
+ }
469
+ });
470
+
471
+ test("suspended/out_of_credits settles with suspended_cost", async () => {
472
+ pollResponse.status = "suspended";
473
+ pollResponse.status_detail = "out_of_credits";
474
+
475
+ const adapter = new DevinAdapter();
476
+ const { result } = await runUntilSettled(adapter, testConfig());
477
+
478
+ expect(result.isError).toBe(true);
479
+ });
480
+ });
481
+
482
+ describe("Polling loop — error and poll failures", () => {
483
+ test("error status settles with devin_error", async () => {
484
+ pollResponse.status = "error";
485
+ pollResponse.acus_consumed = 1;
486
+
487
+ const adapter = new DevinAdapter();
488
+ const { events, result } = await runUntilSettled(adapter, testConfig());
489
+
490
+ expect(result.isError).toBe(true);
491
+ expect(result.exitCode).toBe(1);
492
+
493
+ const resultEvent = events.findLast((e) => e.type === "result");
494
+ if (resultEvent?.type === "result") {
495
+ expect(resultEvent.errorCategory).toBe("devin_error");
496
+ }
497
+ });
498
+
499
+ test("10 consecutive poll errors settles with poll_failure", async () => {
500
+ pollErrorResponse = { status: 500, body: JSON.stringify({ error: "server error" }) };
501
+ pollErrorCount = 20; // More than enough — adapter gives up at 10.
502
+
503
+ const adapter = new DevinAdapter();
504
+ const { events, result } = await runUntilSettled(adapter, testConfig(), { timeout: 10000 });
505
+
506
+ expect(result.isError).toBe(true);
507
+
508
+ const resultEvent = events.findLast((e) => e.type === "result");
509
+ if (resultEvent?.type === "result") {
510
+ expect(resultEvent.errorCategory).toBe("poll_failure");
511
+ }
512
+
513
+ // Should have emitted raw_stderr warnings for each failed poll.
514
+ const stderrEvents = events.filter((e) => e.type === "raw_stderr");
515
+ expect(stderrEvents.length).toBeGreaterThanOrEqual(1);
516
+ });
517
+ });
518
+
519
+ describe("DevinAdapter.canResume", () => {
520
+ test("returns true for suspended session", async () => {
521
+ pollResponse.status = "suspended";
522
+ pollResponse.status_detail = "inactivity";
523
+
524
+ const adapter = new DevinAdapter();
525
+ const result = await adapter.canResume("ses-test-001");
526
+ expect(result).toBe(true);
527
+ });
528
+
529
+ test("returns false for exit session", async () => {
530
+ pollResponse.status = "exit";
531
+
532
+ const adapter = new DevinAdapter();
533
+ const result = await adapter.canResume("ses-test-001");
534
+ expect(result).toBe(false);
535
+ });
536
+
537
+ test("returns false for error session (conservative — not all errors are recoverable)", async () => {
538
+ pollResponse.status = "error";
539
+
540
+ const adapter = new DevinAdapter();
541
+ const result = await adapter.canResume("ses-test-001");
542
+ expect(result).toBe(false);
543
+ });
544
+
545
+ test("returns false for empty session ID", async () => {
546
+ const adapter = new DevinAdapter();
547
+ expect(await adapter.canResume("")).toBe(false);
548
+ });
549
+
550
+ test("returns false without DEVIN_API_KEY", async () => {
551
+ delete process.env.DEVIN_API_KEY;
552
+ const adapter = new DevinAdapter();
553
+ expect(await adapter.canResume("ses-test-001")).toBe(false);
554
+ process.env.DEVIN_API_KEY = API_KEY;
555
+ });
556
+ });
557
+
558
+ describe("DevinAdapter.abort", () => {
559
+ test("abort does NOT archive the session (keeps alive for resume)", async () => {
560
+ // Start running, then abort before it exits.
561
+ pollResponse.status = "running";
562
+ pollResponse.status_detail = "working";
563
+
564
+ const adapter = new DevinAdapter();
565
+ const session = await adapter.createSession(testConfig());
566
+ const events: ProviderEvent[] = [];
567
+ session.onEvent((e) => events.push(e));
568
+
569
+ // Wait a tick for the first poll, then abort.
570
+ await new Promise((resolve) => setTimeout(resolve, 100));
571
+ await session.abort();
572
+ const result = await session.waitForCompletion();
573
+
574
+ expect(result.isError).toBe(true);
575
+ expect(result.exitCode).toBe(130);
576
+ expect(result.failureReason).toBe("cancelled");
577
+
578
+ // The abort path should emit a result with cancelled category.
579
+ const resultEvent = events.findLast((e) => e.type === "result");
580
+ if (resultEvent?.type === "result") {
581
+ expect(resultEvent.errorCategory).toBe("cancelled");
582
+ }
583
+ });
584
+ });
585
+
586
+ describe("CostData mapping", () => {
587
+ test("ACUs mapped with default cost ($2.25 per ACU)", async () => {
588
+ pollResponse.status = "exit";
589
+ pollResponse.status_detail = "finished";
590
+ pollResponse.acus_consumed = 4;
591
+
592
+ const adapter = new DevinAdapter();
593
+ const { events } = await runUntilSettled(adapter, testConfig());
594
+
595
+ const resultEvent = events.findLast((e) => e.type === "result");
596
+ if (resultEvent?.type === "result") {
597
+ expect(resultEvent.cost.totalCostUsd).toBeCloseTo(4 * 2.25, 4);
598
+ expect(resultEvent.cost.model).toBe("devin");
599
+ expect(resultEvent.cost.inputTokens).toBe(0);
600
+ expect(resultEvent.cost.outputTokens).toBe(0);
601
+ }
602
+ });
603
+
604
+ test("ACUs mapped with custom DEVIN_ACU_COST_USD", async () => {
605
+ process.env.DEVIN_ACU_COST_USD = "3.50";
606
+ pollResponse.status = "exit";
607
+ pollResponse.status_detail = "finished";
608
+ pollResponse.acus_consumed = 2;
609
+
610
+ const adapter = new DevinAdapter();
611
+ const { events } = await runUntilSettled(adapter, testConfig());
612
+
613
+ const resultEvent = events.findLast((e) => e.type === "result");
614
+ if (resultEvent?.type === "result") {
615
+ expect(resultEvent.cost.totalCostUsd).toBeCloseTo(2 * 3.5, 4);
616
+ }
617
+
618
+ delete process.env.DEVIN_ACU_COST_USD;
619
+ });
620
+ });
621
+
622
+ describe("DevinAdapter.formatCommand", () => {
623
+ test("returns @skills:name format", () => {
624
+ const adapter = new DevinAdapter();
625
+ expect(adapter.formatCommand("lint-fix")).toBe("@skills:lint-fix");
626
+ expect(adapter.formatCommand("deploy")).toBe("@skills:deploy");
627
+ });
628
+ });
629
+
630
+ describe("Structured output and PR tracking", () => {
631
+ test("structured output changes emitted as custom events", async () => {
632
+ pollResponse.status = "running";
633
+ pollResponse.status_detail = "working";
634
+ pollResponse.structured_output = { result: "partial" };
635
+
636
+ // After a bit, change the output and finish.
637
+ const timer = setTimeout(() => {
638
+ pollResponse.structured_output = { result: "final" };
639
+ pollResponse.status = "exit";
640
+ pollResponse.status_detail = "finished";
641
+ }, 150);
642
+
643
+ const adapter = new DevinAdapter();
644
+ const { events } = await runUntilSettled(adapter, testConfig());
645
+ clearTimeout(timer);
646
+
647
+ const outputEvents = events.filter(
648
+ (e) => e.type === "custom" && e.name === "devin.structured_output",
649
+ );
650
+ expect(outputEvents.length).toBeGreaterThanOrEqual(1);
651
+ });
652
+
653
+ test("pull request events emitted for new PRs", async () => {
654
+ pollResponse.status = "running";
655
+ pollResponse.status_detail = "working";
656
+ pollResponse.pull_requests = [
657
+ { pr_url: "https://github.com/org/repo/pull/42", pr_state: "open" },
658
+ ];
659
+
660
+ const timer = setTimeout(() => {
661
+ pollResponse.status = "exit";
662
+ pollResponse.status_detail = "finished";
663
+ }, 150);
664
+
665
+ const adapter = new DevinAdapter();
666
+ const { events } = await runUntilSettled(adapter, testConfig());
667
+ clearTimeout(timer);
668
+
669
+ const prEvents = events.filter((e) => e.type === "custom" && e.name === "devin.pull_request");
670
+ expect(prEvents.length).toBeGreaterThanOrEqual(1);
671
+ if (prEvents[0]?.type === "custom") {
672
+ const data = prEvents[0].data as { prUrl: string; prState: string };
673
+ expect(data.prUrl).toBe("https://github.com/org/repo/pull/42");
674
+ expect(data.prState).toBe("open");
675
+ }
676
+ });
677
+ });