@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.
- package/openapi.json +72 -1
- package/package.json +3 -1
- package/src/be/db-queries/tracker.ts +21 -0
- package/src/be/db.ts +235 -14
- package/src/be/migrations/079_task_followup_config.sql +1 -0
- package/src/be/modelsdev-cache.json +77663 -74073
- package/src/cli.tsx +26 -0
- package/src/commands/context-preamble.ts +272 -0
- package/src/commands/e2b.ts +728 -0
- package/src/commands/resume-session.ts +35 -78
- package/src/commands/runner.ts +125 -13
- package/src/e2b/dispatch.ts +429 -0
- package/src/e2b/env.ts +206 -0
- package/src/heartbeat/heartbeat.ts +145 -30
- package/src/heartbeat/templates.ts +11 -7
- package/src/http/session-data.ts +8 -1
- package/src/http/tasks.ts +152 -3
- package/src/jira/sync.ts +4 -4
- package/src/linear/sync.ts +6 -5
- package/src/providers/claude-adapter.ts +10 -76
- package/src/providers/claude-managed-adapter.ts +61 -75
- package/src/providers/codex-adapter.ts +15 -18
- package/src/providers/codex-oauth/auth-json.ts +18 -1
- package/src/providers/codex-oauth/flow.ts +24 -1
- package/src/providers/types.ts +6 -0
- package/src/tasks/worker-follow-up.ts +162 -2
- package/src/telemetry.ts +11 -1
- package/src/tests/claude-adapter.test.ts +5 -27
- package/src/tests/claude-managed-adapter.test.ts +38 -52
- package/src/tests/codex-adapter.test.ts +6 -31
- package/src/tests/codex-oauth.test.ts +149 -3
- package/src/tests/codex-pool.test.ts +14 -3
- package/src/tests/e2b-dispatch.test.ts +330 -0
- package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
- package/src/tests/heartbeat.test.ts +26 -16
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/resume-session.test.ts +42 -50
- package/src/tests/structured-output.test.ts +69 -0
- package/src/tests/task-completion-idempotency.test.ts +185 -2
- package/src/tests/task-supersede-resume.test.ts +722 -0
- package/src/tests/telemetry-init.test.ts +69 -0
- package/src/tests/vcs-tracking.test.ts +39 -0
- package/src/tools/send-task.ts +12 -1
- package/src/tools/store-progress.ts +2 -2
- package/src/tools/templates.ts +14 -2
- package/src/types.ts +46 -1
- 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
|
|
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: ["--
|
|
60
|
-
resumeSessionId: "session-abc-123",
|
|
61
|
+
additionalArgs: ["--max-turns", "10"],
|
|
61
62
|
});
|
|
62
|
-
expect(config.additionalArgs).
|
|
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:
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
//
|
|
524
|
-
//
|
|
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: "
|
|
540
|
-
processed_at: "2026-01-01T00:00:
|
|
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: "
|
|
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
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
387
|
+
access,
|
|
289
388
|
refresh: "rt_456",
|
|
290
389
|
expires: Date.now() + 3600000,
|
|
291
|
-
accountId: "
|
|
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: `
|
|
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
|
-
|
|
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
|
|