@desplega.ai/agent-swarm 1.86.0 → 1.87.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 (47) hide show
  1. package/openapi.json +72 -1
  2. package/package.json +3 -1
  3. package/src/be/db-queries/tracker.ts +21 -0
  4. package/src/be/db.ts +235 -14
  5. package/src/be/migrations/079_task_followup_config.sql +1 -0
  6. package/src/be/modelsdev-cache.json +77663 -74073
  7. package/src/cli.tsx +26 -0
  8. package/src/commands/context-preamble.ts +272 -0
  9. package/src/commands/e2b.ts +728 -0
  10. package/src/commands/resume-session.ts +35 -78
  11. package/src/commands/runner.ts +125 -13
  12. package/src/e2b/dispatch.ts +429 -0
  13. package/src/e2b/env.ts +206 -0
  14. package/src/heartbeat/heartbeat.ts +145 -30
  15. package/src/heartbeat/templates.ts +11 -7
  16. package/src/http/session-data.ts +8 -1
  17. package/src/http/tasks.ts +152 -3
  18. package/src/jira/sync.ts +4 -4
  19. package/src/linear/sync.ts +6 -5
  20. package/src/providers/claude-adapter.ts +10 -76
  21. package/src/providers/claude-managed-adapter.ts +61 -75
  22. package/src/providers/codex-adapter.ts +15 -18
  23. package/src/providers/codex-oauth/auth-json.ts +18 -1
  24. package/src/providers/codex-oauth/flow.ts +24 -1
  25. package/src/providers/types.ts +6 -0
  26. package/src/tasks/worker-follow-up.ts +162 -2
  27. package/src/telemetry.ts +11 -1
  28. package/src/tests/claude-adapter.test.ts +5 -27
  29. package/src/tests/claude-managed-adapter.test.ts +38 -52
  30. package/src/tests/codex-adapter.test.ts +6 -31
  31. package/src/tests/codex-oauth.test.ts +149 -3
  32. package/src/tests/codex-pool.test.ts +14 -3
  33. package/src/tests/e2b-dispatch.test.ts +330 -0
  34. package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
  35. package/src/tests/heartbeat.test.ts +26 -16
  36. package/src/tests/prompt-template-remaining.test.ts +4 -0
  37. package/src/tests/resume-session.test.ts +42 -50
  38. package/src/tests/structured-output.test.ts +69 -0
  39. package/src/tests/task-completion-idempotency.test.ts +185 -2
  40. package/src/tests/task-supersede-resume.test.ts +722 -0
  41. package/src/tests/telemetry-init.test.ts +69 -0
  42. package/src/tests/vcs-tracking.test.ts +39 -0
  43. package/src/tools/send-task.ts +12 -1
  44. package/src/tools/store-progress.ts +2 -2
  45. package/src/tools/templates.ts +14 -2
  46. package/src/types.ts +46 -1
  47. package/src/workflows/executors/agent-task.ts +3 -0
@@ -54,13 +54,13 @@ describe("ClaudeSession CLI argument construction", () => {
54
54
  expect(config.systemPrompt).toBe("You are a test agent");
55
55
  });
56
56
 
57
- test("config with additionalArgs including --resume is accepted", () => {
57
+ test("config with arbitrary additionalArgs is accepted", () => {
58
+ // Native resume is deprecated — the adapter no longer special-cases
59
+ // --resume in additionalArgs. The config shape just round-trips opaquely.
58
60
  const config = makeConfig({
59
- additionalArgs: ["--resume", "session-abc-123"],
60
- resumeSessionId: "session-abc-123",
61
+ additionalArgs: ["--max-turns", "10"],
61
62
  });
62
- expect(config.additionalArgs).toContain("--resume");
63
- expect(config.additionalArgs).toContain("session-abc-123");
63
+ expect(config.additionalArgs).toEqual(["--max-turns", "10"]);
64
64
  });
65
65
  });
66
66
 
@@ -403,25 +403,3 @@ describe("createSessionMcpConfig", () => {
403
403
  expect(written.mcpServers["from-api"]).toBeDefined();
404
404
  });
405
405
  });
406
-
407
- describe("Stale session retry logic", () => {
408
- test("--resume args are stripped correctly", () => {
409
- const args = ["--max-turns", "10", "--resume", "session-abc", "--verbose"];
410
- const freshArgs = args.filter((arg, idx, arr) => {
411
- if (arg === "--resume") return false;
412
- if (idx > 0 && arr[idx - 1] === "--resume") return false;
413
- return true;
414
- });
415
- expect(freshArgs).toEqual(["--max-turns", "10", "--verbose"]);
416
- });
417
-
418
- test("args without --resume remain unchanged", () => {
419
- const args = ["--max-turns", "10", "--verbose"];
420
- const freshArgs = args.filter((arg, idx, arr) => {
421
- if (arg === "--resume") return false;
422
- if (idx > 0 && arr[idx - 1] === "--resume") return false;
423
- return true;
424
- });
425
- expect(freshArgs).toEqual(["--max-turns", "10", "--verbose"]);
426
- });
427
- });
@@ -517,71 +517,57 @@ describe("ClaudeManagedAdapter (Phase 3) — session lifecycle", () => {
517
517
  }
518
518
  });
519
519
 
520
- test("resume: prefetches events.list, dedupes against live stream, skips sessions.create + user.message send", async () => {
521
- // Historical events the resume path will pre-fetch via events.list.
522
- const historical: Array<{ id: string }> = [{ id: "hist-1" }, { id: "hist-2" }];
523
- // Live stream replays one historical event + emits one new event +
524
- // status_idle.
520
+ test("native resume deprecated: resumeSessionId is ignored adapter creates a fresh session", async () => {
521
+ // Pre-Phase-2 the adapter would have skipped sessions.create when
522
+ // resumeSessionId was set. Native resume is now deprecated follow-up
523
+ // continuity flows via the context preamble. The adapter must ignore the
524
+ // field, emit a warn, and create a fresh session.
525
525
  const liveEvents: Array<Record<string, unknown>> = [
526
- {
527
- type: "session.status_running",
528
- id: "hist-2", // duplicate from history — must be skipped
529
- processed_at: "2026-01-01T00:00:00Z",
530
- },
531
- {
532
- type: "agent.message",
533
- id: "new-1",
534
- processed_at: "2026-01-01T00:00:01Z",
535
- content: [{ type: "text", text: "Resumed message" }],
536
- },
537
526
  {
538
527
  type: "session.status_idle",
539
- id: "new-2",
540
- processed_at: "2026-01-01T00:00:02Z",
528
+ id: "evt-idle",
529
+ processed_at: "2026-01-01T00:00:00Z",
541
530
  stop_reason: { type: "end_turn" },
542
531
  },
543
532
  ];
544
533
 
545
534
  const spy = makeFakeClient({
546
- sessionId: "sesn_resume_xyz",
547
- listEvents: async function* () {
548
- for (const h of historical) yield h;
549
- },
535
+ sessionId: "sesn_fresh_after_ignored_resume",
550
536
  streamEvents: async function* () {
551
537
  for (const e of liveEvents) yield e;
552
538
  },
553
539
  });
554
540
 
555
- const adapter = new ClaudeManagedAdapter({ client: spy.client });
556
- const session = await adapter.createSession(
557
- tConfig({
558
- logFile: join(tmpLogDir, "resume.log"),
559
- resumeSessionId: "sesn_resume_xyz",
560
- }),
561
- );
562
- const emitted: ProviderEvent[] = [];
563
- session.onEvent((e) => emitted.push(e));
564
- await session.waitForCompletion();
565
-
566
- // No sessions.create call — pure resume.
567
- expect(spy.created).toHaveLength(0);
568
- // No user.message send — resume reattaches to an in-flight prompt.
569
- expect(spy.sent).toHaveLength(0);
570
-
571
- // The duplicate `hist-2` event was filtered, but `new-1`'s message did
572
- // make it through.
573
- const messages = emitted.filter((e) => e.type === "message");
574
- expect(messages).toHaveLength(1);
575
- if (messages[0]?.type === "message") {
576
- expect(messages[0].content).toBe("Resumed message");
577
- }
578
-
579
- // session_init still fires with the resume's sessionId.
580
- const sessionInit = emitted.find((e) => e.type === "session_init");
581
- if (sessionInit?.type === "session_init") {
582
- expect(sessionInit.sessionId).toBe("sesn_resume_xyz");
583
- expect(sessionInit.provider).toBe("claude-managed");
584
- expect(sessionInit.providerMeta).toEqual({ managed: true });
541
+ const originalWarn = console.warn;
542
+ const warnCalls: string[] = [];
543
+ console.warn = (msg: unknown) => {
544
+ warnCalls.push(String(msg));
545
+ };
546
+ try {
547
+ const adapter = new ClaudeManagedAdapter({ client: spy.client });
548
+ const session = await adapter.createSession(
549
+ tConfig({
550
+ logFile: join(tmpLogDir, "resume-ignored.log"),
551
+ resumeSessionId: "sesn_should_be_ignored",
552
+ }),
553
+ );
554
+ const emitted: ProviderEvent[] = [];
555
+ session.onEvent((e) => emitted.push(e));
556
+ await session.waitForCompletion();
557
+
558
+ // Adapter still calls sessions.create — resume is ignored.
559
+ expect(spy.created).toHaveLength(1);
560
+ // And still sends the user.message — fresh sessions need the prompt.
561
+ expect(spy.sent).toHaveLength(1);
562
+ // The warn fired so operators can spot the misuse in logs.
563
+ expect(warnCalls.some((m) => m.includes("resumeSessionId ignored"))).toBe(true);
564
+ // session_init carries the FRESH session id, not the requested one.
565
+ const sessionInit = emitted.find((e) => e.type === "session_init");
566
+ if (sessionInit?.type === "session_init") {
567
+ expect(sessionInit.sessionId).toBe("sesn_fresh_after_ignored_resume");
568
+ }
569
+ } finally {
570
+ console.warn = originalWarn;
585
571
  }
586
572
  });
587
573
 
@@ -611,42 +611,17 @@ describe("CodexSession event mapping", () => {
611
611
  });
612
612
 
613
613
  describe("CodexAdapter.canResume", () => {
614
- test("returns false for empty / non-string session ids", async () => {
614
+ // Native resume is deprecated. The runner no longer threads resumeSessionId
615
+ // to adapters; canResume returns false unconditionally so any stray caller
616
+ // gets a fresh-session start. Follow-up continuity flows via the context
617
+ // preamble (see src/commands/context-preamble.ts).
618
+ test("always returns false now that native resume is deprecated", async () => {
615
619
  const adapter = new CodexAdapter({ bypassSubprocess: true });
616
620
  expect(await adapter.canResume("")).toBe(false);
621
+ expect(await adapter.canResume("thread-anything")).toBe(false);
617
622
  // @ts-expect-error: deliberate runtime check for non-string input
618
623
  expect(await adapter.canResume(undefined)).toBe(false);
619
624
  });
620
-
621
- test("returns true when resumeThread succeeds and false when it throws", async () => {
622
- const sdk = await import("@openai/codex-sdk");
623
- const originalResume = (
624
- sdk.Codex.prototype as unknown as { resumeThread: (...args: unknown[]) => unknown }
625
- ).resumeThread;
626
-
627
- try {
628
- // Success path
629
- (
630
- sdk.Codex.prototype as unknown as { resumeThread: (...args: unknown[]) => unknown }
631
- ).resumeThread = function resumeThread(): unknown {
632
- return { id: "thread-resumed" };
633
- };
634
- const adapter = new CodexAdapter({ bypassSubprocess: true });
635
- expect(await adapter.canResume("thread-resumed")).toBe(true);
636
-
637
- // Failure path
638
- (
639
- sdk.Codex.prototype as unknown as { resumeThread: (...args: unknown[]) => unknown }
640
- ).resumeThread = function resumeThread(): unknown {
641
- throw new Error("not found");
642
- };
643
- expect(await adapter.canResume("thread-missing")).toBe(false);
644
- } finally {
645
- (
646
- sdk.Codex.prototype as unknown as { resumeThread: (...args: unknown[]) => unknown }
647
- ).resumeThread = originalResume;
648
- }
649
- });
650
625
  });
651
626
 
652
627
  describe("writeCodexAgentsMd round-trip", () => {
@@ -11,6 +11,7 @@ import {
11
11
  createState,
12
12
  decodeJwt,
13
13
  exchangeAuthorizationCode,
14
+ extractChatgptUserId,
14
15
  getAccountId,
15
16
  JWT_CLAIM_PATH,
16
17
  parseAuthorizationInput,
@@ -125,6 +126,22 @@ describe("decodeJwt", () => {
125
126
  expect(decodeJwt("not-a-jwt")).toBeNull();
126
127
  expect(decodeJwt("a.b")).toBeNull();
127
128
  });
129
+
130
+ it("handles base64url-encoded payload containing URL-safe '-' chars", () => {
131
+ // Real JWTs use base64url (RFC 7515): '-' replaces '+', '_' replaces '/'.
132
+ // atob() throws on these chars; decodeJwt() must normalize before calling atob().
133
+ // This payload encodes {"https://api.openai.com/auth":{"chatgpt_user_id":"user-abc>def"}}
134
+ // The '>' in the user_id forces a '+' → '-' substitution when base64url-encoded.
135
+ const b64urlPayload =
136
+ "eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlci1hYmM-ZGVmIn19";
137
+ expect(b64urlPayload).toContain("-"); // sanity: this test actually exercises the fix
138
+ const token = `header.${b64urlPayload}.signature`;
139
+ const decoded = decodeJwt(token);
140
+ expect(decoded).not.toBeNull();
141
+ expect(
142
+ (decoded?.["https://api.openai.com/auth"] as Record<string, unknown>)?.chatgpt_user_id,
143
+ ).toBe("user-abc>def");
144
+ });
128
145
  });
129
146
 
130
147
  describe("getAccountId", () => {
@@ -150,6 +167,77 @@ describe("getAccountId", () => {
150
167
  });
151
168
  });
152
169
 
170
+ describe("extractChatgptUserId", () => {
171
+ it("extracts chatgpt_user_id from JWT auth namespace", () => {
172
+ const payload = {
173
+ "https://api.openai.com/auth": {
174
+ chatgpt_account_id: "acc-team-abc",
175
+ chatgpt_user_id: "user-zzz-12345",
176
+ },
177
+ };
178
+ const token = `header.${btoa(JSON.stringify(payload))}.signature`;
179
+ expect(extractChatgptUserId(token)).toBe("user-zzz-12345");
180
+ });
181
+
182
+ it("returns null when chatgpt_user_id is absent", () => {
183
+ const payload = { "https://api.openai.com/auth": { chatgpt_account_id: "acc-only" } };
184
+ const token = `header.${btoa(JSON.stringify(payload))}.signature`;
185
+ expect(extractChatgptUserId(token)).toBeNull();
186
+ });
187
+
188
+ it("returns null when the entire OpenAI auth namespace is absent", () => {
189
+ const payload = { sub: "user-from-some-other-claim" };
190
+ const token = `header.${btoa(JSON.stringify(payload))}.signature`;
191
+ expect(extractChatgptUserId(token)).toBeNull();
192
+ });
193
+
194
+ it("returns null when chatgpt_user_id is empty string", () => {
195
+ const payload = { "https://api.openai.com/auth": { chatgpt_user_id: "" } };
196
+ const token = `header.${btoa(JSON.stringify(payload))}.signature`;
197
+ expect(extractChatgptUserId(token)).toBeNull();
198
+ });
199
+
200
+ it("returns null when JWT is malformed", () => {
201
+ expect(extractChatgptUserId("not.a.jwt-payload")).toBeNull();
202
+ expect(extractChatgptUserId("only-two.parts")).toBeNull();
203
+ expect(extractChatgptUserId("")).toBeNull();
204
+ });
205
+
206
+ it("is independent of chatgpt_account_id (slot-unique-vs-account-shared invariant)", () => {
207
+ const payloadA = {
208
+ "https://api.openai.com/auth": {
209
+ chatgpt_account_id: "team-shared-id",
210
+ chatgpt_user_id: "user-daniel-001",
211
+ },
212
+ };
213
+ const payloadB = {
214
+ "https://api.openai.com/auth": {
215
+ chatgpt_account_id: "team-shared-id",
216
+ chatgpt_user_id: "user-lorenzo-002",
217
+ },
218
+ };
219
+ const tokenA = `h.${btoa(JSON.stringify(payloadA))}.s`;
220
+ const tokenB = `h.${btoa(JSON.stringify(payloadB))}.s`;
221
+ expect(extractChatgptUserId(tokenA)).toBe("user-daniel-001");
222
+ expect(extractChatgptUserId(tokenB)).toBe("user-lorenzo-002");
223
+ expect(extractChatgptUserId(tokenA)).not.toBe(extractChatgptUserId(tokenB));
224
+ });
225
+
226
+ it("extracts user_id from a base64url-encoded JWT payload containing '-' chars", () => {
227
+ // Regression: decodeJwt() previously called atob() on raw base64url segments.
228
+ // atob() throws on '-' or '_' (base64url chars not in standard base64 alphabet),
229
+ // causing extractChatgptUserId() to silently return null and fall back to account_id,
230
+ // reintroducing the slot-collision bug this PR fixes.
231
+ // This payload is base64url-encoded (contains '-') and decodes to:
232
+ // {"https://api.openai.com/auth":{"chatgpt_user_id":"user-abc>def"}}
233
+ const b64urlPayload =
234
+ "eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlci1hYmM-ZGVmIn19";
235
+ expect(b64urlPayload).toContain("-"); // sanity: payload actually has base64url chars
236
+ const token = `header.${b64urlPayload}.signature`;
237
+ expect(extractChatgptUserId(token)).toBe("user-abc>def");
238
+ });
239
+ });
240
+
153
241
  describe("exchangeAuthorizationCode", () => {
154
242
  afterEach(() => {
155
243
  resetFetchForTesting();
@@ -283,18 +371,76 @@ describe("authJsonToCredentials", () => {
283
371
  });
284
372
 
285
373
  describe("authJsonToCredentialSelection", () => {
286
- it("maps chatgpt auth.json to CODEX_OAUTH tracking info", () => {
374
+ function tokenFor(payload: Record<string, unknown>): string {
375
+ return `header.${btoa(JSON.stringify(payload))}.signature`;
376
+ }
377
+
378
+ it("derives keySuffix from chatgpt_user_id when present", () => {
379
+ const userId = "user-MYUYWj0C9zVPo6TX2NjodCyi";
380
+ const access = tokenFor({
381
+ "https://api.openai.com/auth": {
382
+ chatgpt_account_id: "3a730921-cc80-4759-8fdd-242d8e80c847",
383
+ chatgpt_user_id: userId,
384
+ },
385
+ });
287
386
  const creds: CodexOAuthCredentials = {
288
- access: "at_123",
387
+ access,
289
388
  refresh: "rt_456",
290
389
  expires: Date.now() + 3600000,
291
- accountId: "c724a178-3621-41bb-bdb5-7b6ca848c965",
390
+ accountId: "3a730921-cc80-4759-8fdd-242d8e80c847",
292
391
  };
293
392
 
294
393
  const selection = authJsonToCredentialSelection(credentialsToAuthJson(creds));
295
394
  expect(selection.keyType).toBe("CODEX_OAUTH");
296
395
  expect(selection.index).toBe(0);
297
396
  expect(selection.total).toBe(1);
397
+ expect(selection.keySuffix).toBe(userId.slice(-5));
398
+ expect(selection.selected).toBe(creds.accountId);
399
+ });
400
+
401
+ it("two slots on the same account get distinct suffixes when user_ids differ", () => {
402
+ const userIdDaniel = "user-MYUYWj0C9zVPo6TX2NjodCyi";
403
+ const userIdLorenzo = "user-5M89tz8tAYHIaByagMVd3Ove";
404
+ const sharedAccountId = "3a730921-cc80-4759-8fdd-242d8e80c847";
405
+
406
+ const makeCredsForUser = (userId: string): CodexOAuthCredentials => ({
407
+ access: tokenFor({
408
+ "https://api.openai.com/auth": {
409
+ chatgpt_account_id: sharedAccountId,
410
+ chatgpt_user_id: userId,
411
+ },
412
+ }),
413
+ refresh: "rt_x",
414
+ expires: Date.now() + 3600000,
415
+ accountId: sharedAccountId,
416
+ });
417
+
418
+ const selDaniel = authJsonToCredentialSelection(
419
+ credentialsToAuthJson(makeCredsForUser(userIdDaniel)),
420
+ 0,
421
+ 2,
422
+ );
423
+ const selLorenzo = authJsonToCredentialSelection(
424
+ credentialsToAuthJson(makeCredsForUser(userIdLorenzo)),
425
+ 1,
426
+ 2,
427
+ );
428
+
429
+ expect(selDaniel.keySuffix).toBe(userIdDaniel.slice(-5));
430
+ expect(selLorenzo.keySuffix).toBe(userIdLorenzo.slice(-5));
431
+ expect(selDaniel.keySuffix).not.toBe(selLorenzo.keySuffix);
432
+ });
433
+
434
+ it("falls back to account_id suffix when JWT lacks chatgpt_user_id", () => {
435
+ const creds: CodexOAuthCredentials = {
436
+ access: "at_no_user_id_claim",
437
+ refresh: "rt_456",
438
+ expires: Date.now() + 3600000,
439
+ accountId: "c724a178-3621-41bb-bdb5-7b6ca848c965",
440
+ };
441
+
442
+ const selection = authJsonToCredentialSelection(credentialsToAuthJson(creds));
443
+ expect(selection.keyType).toBe("CODEX_OAUTH");
298
444
  expect(selection.keySuffix).toBe("8c965");
299
445
  expect(selection.selected).toBe(creds.accountId);
300
446
  });
@@ -37,9 +37,19 @@ const MOCK_API_URL = "http://localhost:3013";
37
37
  const MOCK_API_KEY = "test-api-key";
38
38
  const FUTURE = Date.now() + 3_600_000;
39
39
 
40
+ function makeJwt(userId: string, accountId: string): string {
41
+ const payload = {
42
+ "https://api.openai.com/auth": {
43
+ chatgpt_account_id: accountId,
44
+ chatgpt_user_id: userId,
45
+ },
46
+ };
47
+ return `header.${btoa(JSON.stringify(payload))}.signature`;
48
+ }
49
+
40
50
  function makeCreds(suffix: string): CodexOAuthCredentials {
41
51
  return {
42
- access: `at_${suffix}`,
52
+ access: makeJwt(`user-${suffix}`, `acc-${suffix}`),
43
53
  refresh: `rt_${suffix}`,
44
54
  expires: FUTURE,
45
55
  accountId: `acc-${suffix}`,
@@ -166,8 +176,9 @@ describe("Scenario 1 — 3-slot round-trip with availability filter", () => {
166
176
  expect(sel.index).toBe(selectedSlot);
167
177
  expect(sel.total).toBe(3);
168
178
  expect(sel.keyType).toBe("CODEX_OAUTH");
169
- // keySuffix is derived from accountId.
170
- expect(slotEntry.creds.accountId.endsWith(sel.keySuffix)).toBe(true);
179
+ // keySuffix is derived from chatgpt_user_id (slot-unique), not accountId.
180
+ const expectedUserId = `user-slot${selectedSlot}`;
181
+ expect(sel.keySuffix).toBe(expectedUserId.slice(-5));
171
182
  });
172
183
  });
173
184