@desplega.ai/agent-swarm 1.83.0 → 1.83.2

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 (67) hide show
  1. package/openapi.json +177 -10
  2. package/package.json +6 -6
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +37 -4
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/be/schedules/validate.ts +21 -0
  10. package/src/be/skill-sync.ts +65 -15
  11. package/src/commands/resume-session.ts +118 -0
  12. package/src/commands/runner.ts +178 -121
  13. package/src/http/core.ts +4 -1
  14. package/src/http/index.ts +16 -0
  15. package/src/http/integrations.ts +26 -0
  16. package/src/http/mcp-user.ts +111 -0
  17. package/src/http/poll.ts +19 -5
  18. package/src/http/schedules.ts +35 -10
  19. package/src/http/skills.ts +27 -2
  20. package/src/http/users.ts +107 -2
  21. package/src/jira/client.ts +3 -5
  22. package/src/jira/oauth.ts +1 -0
  23. package/src/jira/sync.ts +2 -2
  24. package/src/oauth/ensure-token.ts +1 -0
  25. package/src/oauth/wrapper.ts +38 -7
  26. package/src/providers/claude-adapter.ts +7 -2
  27. package/src/providers/claude-managed-adapter.ts +1 -1
  28. package/src/providers/codex-adapter.ts +30 -0
  29. package/src/providers/opencode-adapter.ts +149 -14
  30. package/src/providers/pi-mono-adapter.ts +41 -1
  31. package/src/providers/types.ts +1 -1
  32. package/src/server-user.ts +117 -0
  33. package/src/tests/artifact-sdk.test.ts +23 -19
  34. package/src/tests/budget-user-scope.test.ts +376 -0
  35. package/src/tests/claude-managed-adapter.test.ts +6 -0
  36. package/src/tests/codex-adapter.test.ts +192 -0
  37. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  38. package/src/tests/db-queries-oauth.test.ts +43 -0
  39. package/src/tests/ensure-token.test.ts +93 -0
  40. package/src/tests/error-tracker.test.ts +52 -0
  41. package/src/tests/fetch-resolved-env.test.ts +33 -20
  42. package/src/tests/http-api-integration.test.ts +36 -0
  43. package/src/tests/http-users.test.ts +29 -1
  44. package/src/tests/mcp-user-route.test.ts +325 -0
  45. package/src/tests/opencode-adapter.test.ts +75 -0
  46. package/src/tests/pi-mono-adapter.test.ts +21 -1
  47. package/src/tests/rate-limit-event.test.ts +69 -6
  48. package/src/tests/resume-session.test.ts +93 -0
  49. package/src/tests/runner-skills-refresh.test.ts +200 -0
  50. package/src/tests/schedule-validation-helper.test.ts +51 -0
  51. package/src/tests/skill-sync.test.ts +73 -9
  52. package/src/tests/skills-signature.test.ts +141 -0
  53. package/src/tests/task-tools-ctx.test.ts +100 -0
  54. package/src/tests/task-tools-ownership.test.ts +167 -0
  55. package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
  56. package/src/tests/user-token-routes.test.ts +221 -0
  57. package/src/tools/cancel-task.ts +137 -83
  58. package/src/tools/get-task-details.ts +73 -59
  59. package/src/tools/get-tasks.ts +134 -126
  60. package/src/tools/schedules/update-schedule.ts +48 -8
  61. package/src/tools/send-task.ts +312 -312
  62. package/src/tools/slack-upload-file.ts +17 -5
  63. package/src/tools/task-action.ts +464 -367
  64. package/src/tools/task-tool-ctx.ts +43 -0
  65. package/src/types.ts +6 -2
  66. package/src/utils/error-tracker.ts +122 -9
  67. package/src/utils/skills-refresh.ts +123 -0
@@ -0,0 +1,376 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
+ import { __resetKillSwitchWarnedForTests, canClaim } from "../be/budget-admission";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createSessionCost,
15
+ createTaskExtended,
16
+ createUser,
17
+ getDailySpendForUser,
18
+ getDb,
19
+ getTaskById,
20
+ initDb,
21
+ upsertBudget,
22
+ } from "../be/db";
23
+ import { type IdentityActor, mintToken } from "../be/users";
24
+ import { handleCore } from "../http/core";
25
+ import { handleMcpUser } from "../http/mcp-user";
26
+ import { handlePoll } from "../http/poll";
27
+
28
+ const TEST_DB_PATH = "./test-budget-user-scope.sqlite";
29
+ const NOW = new Date("2026-04-28T15:30:00.000Z");
30
+ const TODAY = "2026-04-28";
31
+ const API_KEY = "test-budget-user-scope-key";
32
+ const ACTOR: IdentityActor = { kind: "operator", id: "phase6-test" };
33
+
34
+ async function removeDbFiles(path: string): Promise<void> {
35
+ for (const suffix of ["", "-wal", "-shm"]) {
36
+ try {
37
+ await unlink(path + suffix);
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
40
+ throw error;
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ beforeAll(() => {
47
+ initDb(TEST_DB_PATH);
48
+ });
49
+
50
+ afterAll(async () => {
51
+ closeDb();
52
+ await removeDbFiles(TEST_DB_PATH);
53
+ });
54
+
55
+ beforeEach(() => {
56
+ const db = getDb();
57
+ db.prepare("DELETE FROM session_costs").run();
58
+ db.prepare("DELETE FROM budget_refusal_notifications").run();
59
+ db.prepare("DELETE FROM agent_tasks").run();
60
+ db.prepare("DELETE FROM budgets").run();
61
+ db.prepare("DELETE FROM user_identity_events").run();
62
+ db.prepare("DELETE FROM user_tokens").run();
63
+ db.prepare("DELETE FROM users").run();
64
+ db.prepare("DELETE FROM agents").run();
65
+ createAgent({
66
+ id: "agent-1",
67
+ name: "agent-1",
68
+ isLead: false,
69
+ status: "idle",
70
+ });
71
+ });
72
+
73
+ afterEach(() => {
74
+ delete process.env.BUDGET_ADMISSION_DISABLED;
75
+ __resetKillSwitchWarnedForTests();
76
+ });
77
+
78
+ function insertUserTaskSpend(
79
+ userId: string,
80
+ totalCostUsd: number,
81
+ createdAt = `${TODAY}T12:00:00.000Z`,
82
+ ) {
83
+ const task = createTaskExtended(`task for ${userId}`, {
84
+ requestedByUserId: userId,
85
+ status: "unassigned",
86
+ });
87
+ const cost = createSessionCost({
88
+ sessionId: `sess-${crypto.randomUUID()}`,
89
+ taskId: task.id,
90
+ agentId: "agent-1",
91
+ totalCostUsd,
92
+ durationMs: 1000,
93
+ numTurns: 1,
94
+ model: "test-model",
95
+ });
96
+ getDb().prepare("UPDATE session_costs SET createdAt = ? WHERE id = ?").run(createdAt, cost.id);
97
+ return { task, cost };
98
+ }
99
+
100
+ async function listen(server: Server): Promise<number> {
101
+ await new Promise<void>((resolve, reject) => {
102
+ server.once("error", reject);
103
+ server.listen(0, "127.0.0.1", () => {
104
+ server.off("error", reject);
105
+ resolve();
106
+ });
107
+ });
108
+ const addr = server.address();
109
+ if (!addr || typeof addr === "string") throw new Error("no port");
110
+ return addr.port;
111
+ }
112
+
113
+ function createMcpUserTestServer(): Server {
114
+ const transportsUser: Record<string, StreamableHTTPServerTransport> = {};
115
+ const sessionUsers: Record<string, string> = {};
116
+
117
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
118
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
119
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
120
+ if (await handleMcpUser(req, res, transportsUser, sessionUsers)) return;
121
+ res.writeHead(404);
122
+ res.end("Not Found");
123
+ });
124
+ }
125
+
126
+ function parseMcpPayload(text: string): unknown {
127
+ const trimmed = text.trim();
128
+ if (!trimmed) return null;
129
+ if (trimmed.startsWith("event:") || trimmed.startsWith("data:")) {
130
+ const data = trimmed
131
+ .split("\n")
132
+ .filter((line) => line.startsWith("data:"))
133
+ .map((line) => line.slice("data:".length).trim())
134
+ .join("\n");
135
+ return JSON.parse(data);
136
+ }
137
+ return JSON.parse(trimmed);
138
+ }
139
+
140
+ async function mcpPost(
141
+ baseUrl: string,
142
+ token: string,
143
+ body: Record<string, unknown>,
144
+ sessionId?: string,
145
+ ): Promise<{ response: Response; payload: unknown }> {
146
+ const headers: Record<string, string> = {
147
+ Accept: "application/json, text/event-stream",
148
+ Authorization: `Bearer ${token}`,
149
+ "Content-Type": "application/json",
150
+ };
151
+ if (sessionId) headers["mcp-session-id"] = sessionId;
152
+
153
+ const response = await fetch(`${baseUrl}/mcp-user`, {
154
+ method: "POST",
155
+ headers,
156
+ body: JSON.stringify(body),
157
+ });
158
+ const text = await response.text();
159
+ return { response, payload: text ? parseMcpPayload(text) : null };
160
+ }
161
+
162
+ async function initializeMcpUser(baseUrl: string, token: string): Promise<string> {
163
+ const { response } = await mcpPost(baseUrl, token, {
164
+ jsonrpc: "2.0",
165
+ id: 1,
166
+ method: "initialize",
167
+ params: {
168
+ protocolVersion: "2024-11-05",
169
+ clientInfo: { name: "budget-user-scope-test", version: "1" },
170
+ capabilities: {},
171
+ },
172
+ });
173
+ expect(response.status).toBe(200);
174
+ const sessionId = response.headers.get("mcp-session-id");
175
+ if (!sessionId) throw new Error("missing mcp-session-id");
176
+
177
+ const initialized = await mcpPost(
178
+ baseUrl,
179
+ token,
180
+ { jsonrpc: "2.0", method: "notifications/initialized" },
181
+ sessionId,
182
+ );
183
+ expect([200, 202]).toContain(initialized.response.status);
184
+ return sessionId;
185
+ }
186
+
187
+ async function callPoll(agentId: string): Promise<{
188
+ status: number;
189
+ body: { trigger: { type: string; [key: string]: unknown } | null } | { error: string };
190
+ }> {
191
+ let status = 200;
192
+ let bodyStr = "";
193
+ const headers: Record<string, string> = {};
194
+
195
+ const req = {
196
+ method: "GET",
197
+ url: "/api/poll",
198
+ headers: { "x-agent-id": agentId },
199
+ } as unknown as Parameters<typeof handlePoll>[0];
200
+
201
+ const res = {
202
+ setHeader(name: string, value: string) {
203
+ headers[name.toLowerCase()] = value;
204
+ },
205
+ writeHead(code: number, h?: Record<string, string>) {
206
+ status = code;
207
+ if (h) {
208
+ for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = v;
209
+ }
210
+ },
211
+ end(body?: string) {
212
+ bodyStr = body ?? "";
213
+ },
214
+ } as unknown as Parameters<typeof handlePoll>[1];
215
+
216
+ const handled = await handlePoll(req, res, ["api", "poll"], new URLSearchParams(), agentId);
217
+ if (!handled) throw new Error("handlePoll did not handle the request");
218
+ return { status, body: bodyStr ? JSON.parse(bodyStr) : { trigger: null } };
219
+ }
220
+
221
+ describe("user budget scope", () => {
222
+ test("getDailySpendForUser sums only costs for that user's tasks on that UTC day", () => {
223
+ const userA = createUser({ name: "User A" });
224
+ const userB = createUser({ name: "User B" });
225
+
226
+ insertUserTaskSpend(userA.id, 1.25);
227
+ insertUserTaskSpend(userA.id, 2.75);
228
+ insertUserTaskSpend(userA.id, 99, "2026-04-27T23:59:59.999Z");
229
+ insertUserTaskSpend(userB.id, 10);
230
+
231
+ const unownedTask = createTaskExtended("unowned", { status: "unassigned" });
232
+ createSessionCost({
233
+ sessionId: `sess-${crypto.randomUUID()}`,
234
+ taskId: unownedTask.id,
235
+ agentId: "agent-1",
236
+ totalCostUsd: 100,
237
+ durationMs: 1000,
238
+ numTurns: 1,
239
+ model: "test-model",
240
+ });
241
+
242
+ expect(getDailySpendForUser(userA.id, TODAY)).toBe(4);
243
+ expect(getDailySpendForUser(userB.id, TODAY)).toBe(10);
244
+ });
245
+
246
+ test("canClaim refuses with cause='user' when requested user's spend is at the cap", () => {
247
+ const user = createUser({ name: "Budgeted User" });
248
+ upsertBudget("user", user.id, 2);
249
+ insertUserTaskSpend(user.id, 2);
250
+
251
+ const result = canClaim("agent-1", NOW, user.id);
252
+
253
+ expect(result.allowed).toBe(false);
254
+ if (result.allowed) throw new Error("unreachable");
255
+ expect(result.cause).toBe("user");
256
+ expect(result.userSpend).toBe(2);
257
+ expect(result.userBudget).toBe(2);
258
+ expect(result.agentSpend).toBeUndefined();
259
+ expect(result.globalSpend).toBeUndefined();
260
+ });
261
+
262
+ test("canClaim allows user-scoped tasks when user spend is below the cap", () => {
263
+ const user = createUser({ name: "Budgeted User" });
264
+ upsertBudget("user", user.id, 2);
265
+ insertUserTaskSpend(user.id, 1.99);
266
+
267
+ const result = canClaim("agent-1", NOW, user.id);
268
+
269
+ expect(result.allowed).toBe(true);
270
+ });
271
+
272
+ test("agent and global gates keep their existing precedence", () => {
273
+ const user = createUser({ name: "Budgeted User" });
274
+ upsertBudget("global", "", 1);
275
+ upsertBudget("agent", "agent-1", 1);
276
+ upsertBudget("user", user.id, 1);
277
+ insertUserTaskSpend(user.id, 1);
278
+
279
+ const globalResult = canClaim("agent-1", NOW, user.id);
280
+ expect(globalResult.allowed).toBe(false);
281
+ if (globalResult.allowed) throw new Error("unreachable");
282
+ expect(globalResult.cause).toBe("global");
283
+
284
+ getDb().prepare("DELETE FROM budgets WHERE scope = 'global'").run();
285
+ const agentResult = canClaim("agent-1", NOW, user.id);
286
+ expect(agentResult.allowed).toBe(false);
287
+ if (agentResult.allowed) throw new Error("unreachable");
288
+ expect(agentResult.cause).toBe("agent");
289
+ });
290
+
291
+ test("user gate is skipped when the candidate task has no requested user", () => {
292
+ const user = createUser({ name: "Budgeted User" });
293
+ upsertBudget("user", user.id, 0);
294
+
295
+ const result = canClaim("agent-1", NOW);
296
+
297
+ expect(result.allowed).toBe(true);
298
+ });
299
+
300
+ test("/mcp-user task is refused at worker admission when user budget is spent", async () => {
301
+ const server = createMcpUserTestServer();
302
+ const port = await listen(server);
303
+ try {
304
+ const lead = createAgent({ name: "lead", isLead: true, status: "idle", maxTasks: 1 });
305
+ const worker = createAgent({ name: "worker", isLead: false, status: "idle", maxTasks: 1 });
306
+ const user = createUser({ name: "MCP Budget User", dailyBudgetUsd: 0.5 });
307
+ upsertBudget("user", user.id, 0.5);
308
+ const token = mintToken(user.id, "qa", ACTOR);
309
+ const baseUrl = `http://127.0.0.1:${port}`;
310
+ const sessionId = await initializeMcpUser(baseUrl, token.plaintext);
311
+
312
+ const sent = await mcpPost(
313
+ baseUrl,
314
+ token.plaintext,
315
+ {
316
+ jsonrpc: "2.0",
317
+ id: 2,
318
+ method: "tools/call",
319
+ params: {
320
+ name: "send-task",
321
+ arguments: { task: "Phase 6 budget QA task" },
322
+ },
323
+ },
324
+ sessionId,
325
+ );
326
+ expect(sent.response.status).toBe(200);
327
+ const payload = sent.payload as {
328
+ result: { structuredContent: { task: { id: string; requestedByUserId?: string } } };
329
+ };
330
+ const taskId = payload.result.structuredContent.task.id;
331
+ expect(payload.result.structuredContent.task.requestedByUserId).toBe(user.id);
332
+
333
+ createSessionCost({
334
+ sessionId: `sess-${crypto.randomUUID()}`,
335
+ taskId,
336
+ agentId: worker.id,
337
+ totalCostUsd: 0.5,
338
+ durationMs: 1000,
339
+ numTurns: 1,
340
+ model: "test-model",
341
+ });
342
+
343
+ const firstPoll = await callPoll(worker.id);
344
+ expect(firstPoll.status).toBe(200);
345
+ if ("error" in firstPoll.body) throw new Error("unexpected poll error");
346
+ expect(firstPoll.body.trigger?.type).toBe("budget_refused");
347
+ expect((firstPoll.body.trigger as { cause: string }).cause).toBe("user");
348
+ expect((firstPoll.body.trigger as { userSpend: number }).userSpend).toBe(0.5);
349
+ expect((firstPoll.body.trigger as { userBudget: number }).userBudget).toBe(0.5);
350
+ expect(getTaskById(taskId)?.status).toBe("unassigned");
351
+
352
+ const firstDedup = getDb()
353
+ .prepare<{ follow_up_task_id: string | null; user_spend_usd: number | null }, [string]>(
354
+ "SELECT follow_up_task_id, user_spend_usd FROM budget_refusal_notifications WHERE task_id = ?",
355
+ )
356
+ .get(taskId);
357
+ expect(firstDedup?.user_spend_usd).toBe(0.5);
358
+ expect(firstDedup?.follow_up_task_id).toBeTruthy();
359
+ const firstFollowUpId = firstDedup?.follow_up_task_id;
360
+ expect(firstFollowUpId ? getTaskById(firstFollowUpId)?.agentId : null).toBe(lead.id);
361
+
362
+ const secondPoll = await callPoll(worker.id);
363
+ expect(secondPoll.status).toBe(200);
364
+ if ("error" in secondPoll.body) throw new Error("unexpected poll error");
365
+ expect(secondPoll.body.trigger?.type).toBe("budget_refused");
366
+ const notificationCount = getDb()
367
+ .prepare<{ count: number }, [string]>(
368
+ "SELECT COUNT(*) AS count FROM budget_refusal_notifications WHERE task_id = ?",
369
+ )
370
+ .get(taskId);
371
+ expect(notificationCount?.count).toBe(1);
372
+ } finally {
373
+ await new Promise<void>((resolve) => server.close(() => resolve()));
374
+ }
375
+ });
376
+ });
@@ -308,6 +308,8 @@ describe("ClaudeManagedAdapter (Phase 3) — session lifecycle", () => {
308
308
  expect(sessionInit).toBeDefined();
309
309
  if (sessionInit && sessionInit.type === "session_init") {
310
310
  expect(sessionInit.sessionId).toBe("sesn_test_123");
311
+ expect(sessionInit.provider).toBe("claude-managed");
312
+ expect(sessionInit.providerMeta).toEqual({ managed: true });
311
313
  }
312
314
 
313
315
  // At least one assistant message.
@@ -578,6 +580,8 @@ describe("ClaudeManagedAdapter (Phase 3) — session lifecycle", () => {
578
580
  const sessionInit = emitted.find((e) => e.type === "session_init");
579
581
  if (sessionInit?.type === "session_init") {
580
582
  expect(sessionInit.sessionId).toBe("sesn_resume_xyz");
583
+ expect(sessionInit.provider).toBe("claude-managed");
584
+ expect(sessionInit.providerMeta).toEqual({ managed: true });
581
585
  }
582
586
  });
583
587
 
@@ -1252,6 +1256,8 @@ describe("ClaudeManagedAdapter (Phase 6) — full happy-path integration", () =>
1252
1256
  expect(sessionInit?.type).toBe("session_init");
1253
1257
  if (sessionInit?.type === "session_init") {
1254
1258
  expect(sessionInit.sessionId).toBe("sesn_test_123");
1259
+ expect(sessionInit.provider).toBe("claude-managed");
1260
+ expect(sessionInit.providerMeta).toEqual({ managed: true });
1255
1261
  }
1256
1262
 
1257
1263
  const message = emitted.find((e) => e.type === "message");
@@ -52,6 +52,63 @@ function makeFakeThread(events: ThreadEvent[]): {
52
52
  };
53
53
  }
54
54
 
55
+ /**
56
+ * Like `makeFakeThread` but throws the given error after all events have been
57
+ * yielded. Simulates the SDK's "Codex Exec exited with code 1: Reading prompt
58
+ * from stdin" throw that fires after the event stream closes.
59
+ */
60
+ function makeFakeThreadWithThrow(
61
+ events: ThreadEvent[],
62
+ throwAfterStream: Error,
63
+ ): ReturnType<typeof makeFakeThread> {
64
+ return {
65
+ id: null,
66
+ async runStreamed(_input, _opts) {
67
+ async function* generate(): AsyncGenerator<ThreadEvent> {
68
+ for (const event of events) {
69
+ yield event;
70
+ }
71
+ throw throwAfterStream;
72
+ }
73
+ return { events: generate() };
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Like `runSessionWithFakeThread` but injects a thread that throws after its
80
+ * event stream ends (simulating the SDK exit-code throw).
81
+ */
82
+ async function runSessionWithThrowingThread(
83
+ events: ThreadEvent[],
84
+ throwAfterStream: Error,
85
+ config: ProviderSessionConfig,
86
+ ): Promise<{ emitted: ProviderEvent[]; result: ProviderResult }> {
87
+ const sdk = await import("@openai/codex-sdk");
88
+ const originalStartThread = (
89
+ sdk.Codex.prototype as unknown as { startThread: (...args: unknown[]) => unknown }
90
+ ).startThread;
91
+
92
+ const fakeThread = makeFakeThreadWithThrow(events, throwAfterStream);
93
+ (sdk.Codex.prototype as unknown as { startThread: (...args: unknown[]) => unknown }).startThread =
94
+ function startThread(): unknown {
95
+ return fakeThread as unknown;
96
+ };
97
+
98
+ try {
99
+ const adapter = new CodexAdapter();
100
+ const session = await adapter.createSession(config);
101
+ const emitted: ProviderEvent[] = [];
102
+ session.onEvent((e) => emitted.push(e));
103
+ const result = await session.waitForCompletion();
104
+ return { emitted, result };
105
+ } finally {
106
+ (
107
+ sdk.Codex.prototype as unknown as { startThread: (...args: unknown[]) => unknown }
108
+ ).startThread = originalStartThread;
109
+ }
110
+ }
111
+
55
112
  /**
56
113
  * Drive a CodexSession manually by constructing the private class via the
57
114
  * adapter's own factory path. We can't import the class directly because it
@@ -1478,3 +1535,138 @@ describe("CodexSession session-end summarization", () => {
1478
1535
  expect(matching[0]![1]).toBe(500);
1479
1536
  });
1480
1537
  });
1538
+
1539
+ describe("CodexSession — rate-limit error preservation", () => {
1540
+ const tmpLogDir = `/tmp/codex-rate-limit-test-${Date.now()}`;
1541
+ let prevSkipEnv: string | undefined;
1542
+
1543
+ beforeAll(() => {
1544
+ mkdirSync(tmpLogDir, { recursive: true });
1545
+ prevSkipEnv = process.env.SKIP_SESSION_SUMMARY;
1546
+ process.env.SKIP_SESSION_SUMMARY = "1";
1547
+ });
1548
+
1549
+ afterAll(() => {
1550
+ rmSync(tmpLogDir, { recursive: true, force: true });
1551
+ if (prevSkipEnv === undefined) delete process.env.SKIP_SESSION_SUMMARY;
1552
+ else process.env.SKIP_SESSION_SUMMARY = prevSkipEnv;
1553
+ });
1554
+
1555
+ afterEach(() => {
1556
+ // Keep afterEach from the test runner clean
1557
+ });
1558
+
1559
+ test("terminalError survives SDK post-stream throw and surfaces as [usage-limit] failureReason", async () => {
1560
+ const usageLimitMsg =
1561
+ "You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.";
1562
+ const events: ThreadEvent[] = [
1563
+ { type: "thread.started", thread_id: "thread-ratelimit-1" },
1564
+ { type: "turn.started" },
1565
+ { type: "error", message: usageLimitMsg },
1566
+ { type: "turn.failed", error: { message: usageLimitMsg } },
1567
+ ];
1568
+ const sdkThrow = new Error("Codex Exec exited with code 1: Reading prompt from stdin");
1569
+
1570
+ const { result } = await runSessionWithThrowingThread(
1571
+ events,
1572
+ sdkThrow,
1573
+ testConfig({ logFile: join(tmpLogDir, "ratelimit-preserve.log"), cwd: "" }),
1574
+ );
1575
+
1576
+ // Bug #1 fix: structured failureReason must survive the SDK throw
1577
+ expect(result.failureReason).toMatch(/\[usage-limit\]/);
1578
+ expect(result.failureReason).not.toContain("Reading prompt from stdin");
1579
+ expect(result.isError).toBe(true);
1580
+ // Parser must have extracted a reset time
1581
+ expect(result.rateLimitResetAt).toBeDefined();
1582
+ const resetMs = new Date(result.rateLimitResetAt!).getTime();
1583
+ expect(resetMs).toBeGreaterThan(Date.now());
1584
+ });
1585
+
1586
+ test("AbortError still settles as cancelled even when terminalError is absent (regression guard)", async () => {
1587
+ // If the session is aborted before any error event, the AbortError path
1588
+ // must still win over the terminalError preservation branch.
1589
+ const sdk = await import("@openai/codex-sdk");
1590
+ const originalStartThread = (
1591
+ sdk.Codex.prototype as unknown as { startThread: (...args: unknown[]) => unknown }
1592
+ ).startThread;
1593
+
1594
+ const fakeThread = {
1595
+ id: null,
1596
+ runStreamed: async (_input: string, opts?: { signal?: AbortSignal }) => {
1597
+ async function* generate(): AsyncGenerator<ThreadEvent> {
1598
+ yield { type: "thread.started", thread_id: "thread-abort-guard" };
1599
+ yield { type: "turn.started" };
1600
+ await new Promise<void>((resolve) => {
1601
+ const onAbort = () => {
1602
+ opts?.signal?.removeEventListener("abort", onAbort);
1603
+ resolve();
1604
+ };
1605
+ if (opts?.signal?.aborted) {
1606
+ resolve();
1607
+ return;
1608
+ }
1609
+ opts?.signal?.addEventListener("abort", onAbort);
1610
+ setTimeout(resolve, 5000);
1611
+ });
1612
+ if (opts?.signal?.aborted) {
1613
+ const err = new Error("aborted");
1614
+ err.name = "AbortError";
1615
+ throw err;
1616
+ }
1617
+ }
1618
+ return { events: generate() };
1619
+ },
1620
+ };
1621
+
1622
+ (
1623
+ sdk.Codex.prototype as unknown as { startThread: (...args: unknown[]) => unknown }
1624
+ ).startThread = function startThread(): unknown {
1625
+ return fakeThread as unknown;
1626
+ };
1627
+
1628
+ try {
1629
+ const adapter = new CodexAdapter();
1630
+ const config = testConfig({
1631
+ logFile: join(tmpLogDir, "abort-guard.log"),
1632
+ cwd: "",
1633
+ taskId: "",
1634
+ apiUrl: "",
1635
+ apiKey: "",
1636
+ });
1637
+ const session = await adapter.createSession(config);
1638
+ const emitted: ProviderEvent[] = [];
1639
+ session.onEvent((e) => emitted.push(e));
1640
+ await new Promise((resolve) => setTimeout(resolve, 30));
1641
+ await session.abort();
1642
+ const result = await session.waitForCompletion();
1643
+
1644
+ expect(result.failureReason).toBe("cancelled");
1645
+ expect(result.exitCode).toBe(130);
1646
+ } finally {
1647
+ (
1648
+ sdk.Codex.prototype as unknown as { startThread: (...args: unknown[]) => unknown }
1649
+ ).startThread = originalStartThread;
1650
+ }
1651
+ });
1652
+
1653
+ test("real unexpected exception (no terminalError) still falls through to outer catch", async () => {
1654
+ // When the SDK throws before any error event, the outer catch must fire normally.
1655
+ const events: ThreadEvent[] = [
1656
+ { type: "thread.started", thread_id: "thread-unexpected" },
1657
+ { type: "turn.started" },
1658
+ ];
1659
+ const unexpectedErr = new Error("unexpected network failure");
1660
+
1661
+ const { result } = await runSessionWithThrowingThread(
1662
+ events,
1663
+ unexpectedErr,
1664
+ testConfig({ logFile: join(tmpLogDir, "unexpected-err.log"), cwd: "" }),
1665
+ );
1666
+
1667
+ // No terminalError → outer catch fires → failureReason is the raw exception message
1668
+ expect(result.failureReason).toBe("unexpected network failure");
1669
+ expect(result.isError).toBe(true);
1670
+ expect(result.rateLimitResetAt).toBeUndefined();
1671
+ });
1672
+ });