@desplega.ai/agent-swarm 1.83.1 → 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 (55) hide show
  1. package/openapi.json +139 -8
  2. package/package.json +1 -1
  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 +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/jira/client.ts +3 -5
  19. package/src/jira/oauth.ts +1 -0
  20. package/src/jira/sync.ts +2 -2
  21. package/src/oauth/ensure-token.ts +1 -0
  22. package/src/oauth/wrapper.ts +38 -7
  23. package/src/providers/claude-adapter.ts +7 -2
  24. package/src/providers/claude-managed-adapter.ts +1 -1
  25. package/src/providers/codex-adapter.ts +30 -0
  26. package/src/providers/opencode-adapter.ts +149 -14
  27. package/src/providers/pi-mono-adapter.ts +41 -1
  28. package/src/providers/types.ts +1 -1
  29. package/src/server-user.ts +117 -0
  30. package/src/tests/artifact-sdk.test.ts +23 -19
  31. package/src/tests/budget-user-scope.test.ts +376 -0
  32. package/src/tests/claude-managed-adapter.test.ts +6 -0
  33. package/src/tests/codex-adapter.test.ts +192 -0
  34. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  35. package/src/tests/db-queries-oauth.test.ts +43 -0
  36. package/src/tests/ensure-token.test.ts +93 -0
  37. package/src/tests/error-tracker.test.ts +52 -0
  38. package/src/tests/fetch-resolved-env.test.ts +33 -20
  39. package/src/tests/http-users.test.ts +29 -1
  40. package/src/tests/mcp-user-route.test.ts +325 -0
  41. package/src/tests/opencode-adapter.test.ts +75 -0
  42. package/src/tests/pi-mono-adapter.test.ts +21 -1
  43. package/src/tests/rate-limit-event.test.ts +69 -6
  44. package/src/tests/resume-session.test.ts +93 -0
  45. package/src/tests/task-tools-ctx.test.ts +100 -0
  46. package/src/tests/task-tools-ownership.test.ts +167 -0
  47. package/src/tests/user-token-routes.test.ts +221 -0
  48. package/src/tools/cancel-task.ts +137 -83
  49. package/src/tools/get-task-details.ts +73 -59
  50. package/src/tools/get-tasks.ts +134 -126
  51. package/src/tools/send-task.ts +312 -312
  52. package/src/tools/task-action.ts +464 -367
  53. package/src/tools/task-tool-ctx.ts +43 -0
  54. package/src/types.ts +6 -2
  55. package/src/utils/error-tracker.ts +122 -9
@@ -0,0 +1,256 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ MAX_RATE_LIMIT_RESET_MS,
4
+ parseCodexRateLimitResetTime,
5
+ SessionErrorTracker,
6
+ } from "../utils/error-tracker";
7
+
8
+ // Verbatim from Linear CAI-1284 issue body (Team/Business plan fixture)
9
+ const VERBATIM_ERROR_MESSAGE =
10
+ "You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.";
11
+
12
+ describe("parseCodexRateLimitResetTime — verbatim CAI-1284 fixture", () => {
13
+ test("parses '8:35 PM' as 20:35 UTC same day when now is 18:00 UTC", () => {
14
+ const now = new Date("2026-05-25T18:00:00Z");
15
+ const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
16
+ expect(result).toBe("2026-05-25T20:35:00.000Z");
17
+ });
18
+
19
+ test("rolls to next day when 'now' is past the parsed wall-clock", () => {
20
+ const now = new Date("2026-05-25T21:00:00Z"); // 9:00 PM UTC — past 8:35 PM
21
+ const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
22
+ expect(result).toBe("2026-05-26T20:35:00.000Z");
23
+ });
24
+
25
+ test("keeps same day when 'now' equals the parsed wall-clock exactly (clock-skew window)", () => {
26
+ const now = new Date("2026-05-25T20:35:00Z");
27
+ const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
28
+ // Within 2-min skew window → same day; clampRateLimitResetMs applies now+60s floor.
29
+ expect(result).toBe("2026-05-25T20:35:00.000Z");
30
+ });
31
+
32
+ test("clock-skew regression: 30s past 8:35 PM does NOT roll to tomorrow (CAI-1284)", () => {
33
+ // Worker receives the usage-limit event 30 seconds after the wall-clock reset time.
34
+ // Should stay same-day; clampRateLimitResetMs applies now+60s floor instead.
35
+ const now = new Date("2026-05-25T20:35:30Z");
36
+ const result = parseCodexRateLimitResetTime(VERBATIM_ERROR_MESSAGE, now);
37
+ expect(result).toBe("2026-05-25T20:35:00.000Z");
38
+ });
39
+ });
40
+
41
+ describe("parseCodexRateLimitResetTime — same-day format variants", () => {
42
+ test.each([
43
+ // [time string, expected ISO, now ISO]
44
+ ["12:00 AM", "2026-05-25T00:00:00.000Z", "2026-05-25T00:00:00Z"], // midnight == now: within skew, stays same day
45
+ ["12:00 PM", "2026-05-25T12:00:00.000Z", "2026-05-25T00:00:00Z"], // noon: future
46
+ ["1:00 PM", "2026-05-25T13:00:00.000Z", "2026-05-25T00:00:00Z"],
47
+ ["11:59 PM", "2026-05-25T23:59:00.000Z", "2026-05-25T00:00:00Z"],
48
+ ])("'Try again at %s.' → %s", (time, expected, nowISO) => {
49
+ const now = new Date(nowISO);
50
+ const msg = `You've hit your usage limit. Try again at ${time}.`;
51
+ expect(parseCodexRateLimitResetTime(msg, now)).toBe(expected);
52
+ });
53
+
54
+ test("'or try again at' prefix (lowercase) also parses", () => {
55
+ const now = new Date("2026-05-25T18:00:00Z");
56
+ const msg =
57
+ "You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.";
58
+ expect(parseCodexRateLimitResetTime(msg, now)).toBe("2026-05-25T20:35:00.000Z");
59
+ });
60
+ });
61
+
62
+ describe("parseCodexRateLimitResetTime — different-day format", () => {
63
+ test("'May 26th, 2026 8:35 PM' parses with ordinal suffix", () => {
64
+ const now = new Date("2026-05-25T22:00:00Z");
65
+ const msg =
66
+ "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), " +
67
+ "visit https://chatgpt.com/codex/settings/usage to purchase more credits or " +
68
+ "try again at May 26th, 2026 8:35 PM.";
69
+ expect(parseCodexRateLimitResetTime(msg, now)).toBe("2026-05-26T20:35:00.000Z");
70
+ });
71
+
72
+ test("ordinal-less form 'May 26, 2026 8:35 PM' also parses (defensive)", () => {
73
+ const now = new Date("2026-05-25T22:00:00Z");
74
+ const msg = "You've hit your usage limit. Try again at May 26, 2026 8:35 PM.";
75
+ expect(parseCodexRateLimitResetTime(msg, now)).toBe("2026-05-26T20:35:00.000Z");
76
+ });
77
+
78
+ test.each([
79
+ ["1st", 1],
80
+ ["2nd", 2],
81
+ ["3rd", 3],
82
+ ["4th", 4],
83
+ ["11th", 11],
84
+ ["21st", 21],
85
+ ["22nd", 22],
86
+ ["23rd", 23],
87
+ ])("ordinal suffix %s parses", (ord, day) => {
88
+ const now = new Date("2026-04-01T00:00:00Z");
89
+ const msg = `You've hit your usage limit. Try again at May ${day}${ord.replace(/\d+/, "")}, 2026 8:35 PM.`;
90
+ const result = parseCodexRateLimitResetTime(msg, now);
91
+ expect(result).toBe(`2026-05-${String(day).padStart(2, "0")}T20:35:00.000Z`);
92
+ });
93
+
94
+ test("12-hour edge: midnight cross-day 'Jan 1st, 2027 12:00 AM'", () => {
95
+ const now = new Date("2026-12-31T23:00:00Z");
96
+ const msg = "You've hit your usage limit. Try again at Jan 1st, 2027 12:00 AM.";
97
+ expect(parseCodexRateLimitResetTime(msg, now)).toBe("2027-01-01T00:00:00.000Z");
98
+ });
99
+
100
+ test("12-hour edge: noon cross-day 'Jan 2nd, 2027 12:00 PM'", () => {
101
+ const now = new Date("2026-12-31T23:00:00Z");
102
+ const msg = "You've hit your usage limit. Try again at Jan 2nd, 2027 12:00 PM.";
103
+ expect(parseCodexRateLimitResetTime(msg, now)).toBe("2027-01-02T12:00:00.000Z");
104
+ });
105
+ });
106
+
107
+ describe("parseCodexRateLimitResetTime — negative cases", () => {
108
+ test("'Try again later.' (no time) → undefined", () => {
109
+ const msg = "You've hit your usage limit. Try again later.";
110
+ expect(parseCodexRateLimitResetTime(msg)).toBeUndefined();
111
+ });
112
+
113
+ test("'or try again later.' (no time) → undefined", () => {
114
+ const msg =
115
+ "You've hit your usage limit. To get more access now, send a request to your admin or try again later.";
116
+ expect(parseCodexRateLimitResetTime(msg)).toBeUndefined();
117
+ });
118
+
119
+ test("workspace-credit-depleted (no retry suffix at all) → undefined", () => {
120
+ expect(
121
+ parseCodexRateLimitResetTime("Your workspace is out of credits. Add credits to continue."),
122
+ ).toBeUndefined();
123
+ });
124
+
125
+ test("workspace member out of credits → undefined", () => {
126
+ expect(
127
+ parseCodexRateLimitResetTime(
128
+ "Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
129
+ ),
130
+ ).toBeUndefined();
131
+ });
132
+
133
+ test("non-codex error message → undefined", () => {
134
+ expect(parseCodexRateLimitResetTime("Connection failed: ECONNREFUSED")).toBeUndefined();
135
+ });
136
+
137
+ test("empty string → undefined", () => {
138
+ expect(parseCodexRateLimitResetTime("")).toBeUndefined();
139
+ });
140
+ });
141
+
142
+ describe("parseCodexRateLimitResetTime — invalid component rejection (CAI-1284 review)", () => {
143
+ const now = new Date("2026-05-25T18:00:00Z");
144
+
145
+ test("'Try again at 99:99 PM.' → undefined (hour and minute out of range)", () => {
146
+ expect(
147
+ parseCodexRateLimitResetTime("usage limit. Try again at 99:99 PM.", now),
148
+ ).toBeUndefined();
149
+ });
150
+
151
+ test("'Try again at 13:99 PM.' → undefined (hour out of range)", () => {
152
+ expect(
153
+ parseCodexRateLimitResetTime("usage limit. Try again at 13:99 PM.", now),
154
+ ).toBeUndefined();
155
+ });
156
+
157
+ test("'Try again at 0:30 PM.' → undefined (hour 0 is not 1–12)", () => {
158
+ expect(parseCodexRateLimitResetTime("usage limit. Try again at 0:30 PM.", now)).toBeUndefined();
159
+ });
160
+
161
+ test("'Try again at 12:60 PM.' → undefined (minute 60 out of range)", () => {
162
+ expect(
163
+ parseCodexRateLimitResetTime("usage limit. Try again at 12:60 PM.", now),
164
+ ).toBeUndefined();
165
+ });
166
+
167
+ test("'Try again at May 32nd, 2026 8:35 PM.' → undefined (day overflow)", () => {
168
+ expect(
169
+ parseCodexRateLimitResetTime("usage limit. Try again at May 32nd, 2026 8:35 PM.", now),
170
+ ).toBeUndefined();
171
+ });
172
+
173
+ test("'Try again at Feb 30th, 2026 8:35 PM.' → undefined (Feb has ≤29 days)", () => {
174
+ expect(
175
+ parseCodexRateLimitResetTime("usage limit. Try again at Feb 30th, 2026 8:35 PM.", now),
176
+ ).toBeUndefined();
177
+ });
178
+
179
+ test("'Try again at May 26th, 2026 8:35 PM.' → valid (positive control)", () => {
180
+ expect(
181
+ parseCodexRateLimitResetTime("usage limit. Try again at May 26th, 2026 8:35 PM.", now),
182
+ ).toBe("2026-05-26T20:35:00.000Z");
183
+ });
184
+ });
185
+
186
+ describe("parseCodexRateLimitResetTime — case-insensitive", () => {
187
+ test("'TRY AGAIN AT 8:35 PM' (all caps) parses", () => {
188
+ const now = new Date("2026-05-25T00:00:00Z");
189
+ const result = parseCodexRateLimitResetTime("usage limit. TRY AGAIN AT 8:35 PM.", now);
190
+ expect(result).toBe("2026-05-25T20:35:00.000Z");
191
+ });
192
+ });
193
+
194
+ describe("SessionErrorTracker — Codex usage-limit integration", () => {
195
+ test("stashes clamped reset time from verbatim CAI-1284 fixture", () => {
196
+ const tracker = new SessionErrorTracker();
197
+ tracker.processCodexUsageLimitMessage(VERBATIM_ERROR_MESSAGE);
198
+ const iso = tracker.getRateLimitResetAt();
199
+ expect(iso).toBeDefined();
200
+ const ms = new Date(iso!).getTime();
201
+ const nowMs = Date.now();
202
+ // Bounded to [now+60s, now+7d]. The same-day wall-clock fixture may be >6h away.
203
+ expect(ms).toBeGreaterThanOrEqual(nowMs + 59_000);
204
+ expect(ms).toBeLessThanOrEqual(nowMs + MAX_RATE_LIMIT_RESET_MS + 1000);
205
+ });
206
+
207
+ test("non-usage-limit error does not stash", () => {
208
+ const tracker = new SessionErrorTracker();
209
+ tracker.processCodexUsageLimitMessage("Connection failed.");
210
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
211
+ });
212
+
213
+ test("workspace-credit message does not stash (no parseable time)", () => {
214
+ const tracker = new SessionErrorTracker();
215
+ tracker.processCodexUsageLimitMessage(
216
+ "Your workspace is out of credits. Add credits to continue.",
217
+ );
218
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
219
+ });
220
+
221
+ test("'try again later' variant does not stash (no parseable time)", () => {
222
+ const tracker = new SessionErrorTracker();
223
+ tracker.processCodexUsageLimitMessage(
224
+ "You've hit your usage limit. To get more access now, send a request to your admin or try again later.",
225
+ );
226
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
227
+ });
228
+
229
+ test("last call wins on multiple usage-limit events", () => {
230
+ const tracker = new SessionErrorTracker();
231
+ // First: a past-sounding time that would be clamped to now+60s
232
+ tracker.processCodexUsageLimitMessage("You've hit your usage limit. Try again at 1:00 AM.");
233
+ const firstIso = tracker.getRateLimitResetAt();
234
+ expect(firstIso).toBeDefined();
235
+
236
+ // Second: clear future time
237
+ const future = new Date(Date.now() + 3 * 60 * 60 * 1000); // +3h
238
+ const h = future.getUTCHours();
239
+ const m = future.getUTCMinutes();
240
+ const hh = h % 12 || 12;
241
+ const ampm = h >= 12 ? "PM" : "AM";
242
+ const mm = String(m).padStart(2, "0");
243
+ tracker.processCodexUsageLimitMessage(
244
+ `You've hit your usage limit. Try again at ${hh}:${mm} ${ampm}.`,
245
+ );
246
+ const secondIso = tracker.getRateLimitResetAt();
247
+ expect(secondIso).toBeDefined();
248
+ // Last call wins — value changed (may be same or different after clamp, but defined)
249
+ });
250
+
251
+ test("empty string does not stash", () => {
252
+ const tracker = new SessionErrorTracker();
253
+ tracker.processCodexUsageLimitMessage("");
254
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
255
+ });
256
+ });
@@ -7,6 +7,7 @@ import {
7
7
  getOAuthTokens,
8
8
  isTokenExpiringSoon,
9
9
  storeOAuthTokens,
10
+ updateOAuthTokensAfterRefresh,
10
11
  upsertOAuthApp,
11
12
  } from "../be/db-queries/oauth";
12
13
 
@@ -140,6 +141,48 @@ describe("OAuth Tokens CRUD", () => {
140
141
  expect(tokens!.refreshToken).toBe("refresh-xyz");
141
142
  });
142
143
 
144
+ test("updateOAuthTokensAfterRefresh replaces the rotated refresh token atomically", () => {
145
+ const futureDate = new Date(Date.now() + 7200000).toISOString();
146
+ storeOAuthTokens("token-test", {
147
+ accessToken: "access-before-refresh",
148
+ refreshToken: "refresh-before-refresh",
149
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
150
+ });
151
+
152
+ updateOAuthTokensAfterRefresh("token-test", "refresh-before-refresh", {
153
+ accessToken: "access-after-refresh",
154
+ refreshToken: "refresh-after-refresh",
155
+ expiresAt: futureDate,
156
+ scope: "read,write",
157
+ });
158
+
159
+ const tokens = getOAuthTokens("token-test");
160
+ expect(tokens!.accessToken).toBe("access-after-refresh");
161
+ expect(tokens!.refreshToken).toBe("refresh-after-refresh");
162
+ expect(tokens!.expiresAt).toBe(futureDate);
163
+ expect(tokens!.scope).toBe("read,write");
164
+ });
165
+
166
+ test("updateOAuthTokensAfterRefresh refuses to overwrite a concurrently rotated token", () => {
167
+ storeOAuthTokens("token-test", {
168
+ accessToken: "access-current",
169
+ refreshToken: "refresh-current",
170
+ expiresAt: new Date(Date.now() + 60000).toISOString(),
171
+ });
172
+
173
+ expect(() =>
174
+ updateOAuthTokensAfterRefresh("token-test", "refresh-stale", {
175
+ accessToken: "access-stale-result",
176
+ refreshToken: "refresh-stale-result",
177
+ expiresAt: new Date(Date.now() + 7200000).toISOString(),
178
+ }),
179
+ ).toThrow(/stored refresh token changed during refresh/);
180
+
181
+ const tokens = getOAuthTokens("token-test");
182
+ expect(tokens!.accessToken).toBe("access-current");
183
+ expect(tokens!.refreshToken).toBe("refresh-current");
184
+ });
185
+
143
186
  test("deleteOAuthTokens removes tokens", () => {
144
187
  deleteOAuthTokens("token-test");
145
188
  const tokens = getOAuthTokens("token-test");
@@ -25,10 +25,15 @@ const originalFetch = globalThis.fetch;
25
25
  beforeAll(() => {
26
26
  initDb(TEST_DB_PATH);
27
27
  upsertOAuthApp("test-provider", testApp);
28
+ upsertOAuthApp("jira", {
29
+ ...testApp,
30
+ tokenUrl: "https://example.com/jira/oauth/token",
31
+ });
28
32
  });
29
33
 
30
34
  beforeEach(() => {
31
35
  deleteOAuthTokens("test-provider");
36
+ deleteOAuthTokens("jira");
32
37
  globalThis.fetch = originalFetch;
33
38
  });
34
39
 
@@ -266,4 +271,92 @@ describe("ensureTokenOrThrow", () => {
266
271
  expect(tokens?.accessToken).toBe("rotated-token");
267
272
  expect(tokens?.refreshToken).toBe("rotated-refresh");
268
273
  });
274
+
275
+ test("persists Jira's rotated refresh token before reporting refresh success", async () => {
276
+ storeOAuthTokens("jira", {
277
+ accessToken: "old-jira-access",
278
+ refreshToken: "old-jira-refresh",
279
+ expiresAt: new Date(Date.now() + 50 * 60 * 1000).toISOString(),
280
+ });
281
+
282
+ globalThis.fetch = mock(() =>
283
+ Promise.resolve(
284
+ new Response(
285
+ JSON.stringify({
286
+ access_token: "new-jira-access",
287
+ token_type: "Bearer",
288
+ expires_in: 3600,
289
+ refresh_token: "new-jira-refresh",
290
+ }),
291
+ { status: 200, headers: { "Content-Type": "application/json" } },
292
+ ),
293
+ ),
294
+ );
295
+
296
+ await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
297
+
298
+ const tokens = getOAuthTokens("jira");
299
+ expect(tokens?.accessToken).toBe("new-jira-access");
300
+ expect(tokens?.refreshToken).toBe("new-jira-refresh");
301
+ });
302
+
303
+ test("rejects a Jira refresh response that omits the rotated refresh token", async () => {
304
+ storeOAuthTokens("jira", {
305
+ accessToken: "old-jira-access",
306
+ refreshToken: "old-jira-refresh",
307
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
308
+ });
309
+
310
+ globalThis.fetch = mock(() =>
311
+ Promise.resolve(
312
+ new Response(
313
+ JSON.stringify({
314
+ access_token: "new-jira-access",
315
+ token_type: "Bearer",
316
+ expires_in: 3600,
317
+ }),
318
+ { status: 200, headers: { "Content-Type": "application/json" } },
319
+ ),
320
+ ),
321
+ );
322
+
323
+ await expect(ensureTokenOrThrow("jira")).rejects.toThrow(/rotated refresh_token/);
324
+
325
+ const tokens = getOAuthTokens("jira");
326
+ expect(tokens?.accessToken).toBe("old-jira-access");
327
+ expect(tokens?.refreshToken).toBe("old-jira-refresh");
328
+ });
329
+
330
+ test("does not use a refreshed Jira access token when persistence loses the CAS race", async () => {
331
+ storeOAuthTokens("jira", {
332
+ accessToken: "old-jira-access",
333
+ refreshToken: "old-jira-refresh",
334
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
335
+ });
336
+
337
+ globalThis.fetch = mock(() => {
338
+ storeOAuthTokens("jira", {
339
+ accessToken: "concurrent-jira-access",
340
+ refreshToken: "concurrent-jira-refresh",
341
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
342
+ });
343
+ return Promise.resolve(
344
+ new Response(
345
+ JSON.stringify({
346
+ access_token: "stale-result-access",
347
+ token_type: "Bearer",
348
+ expires_in: 3600,
349
+ refresh_token: "stale-result-refresh",
350
+ }),
351
+ { status: 200, headers: { "Content-Type": "application/json" } },
352
+ ),
353
+ );
354
+ });
355
+
356
+ await expect(ensureTokenOrThrow("jira")).rejects.toThrow(/stored refresh token changed/);
357
+
358
+ const tokens = getOAuthTokens("jira");
359
+ expect(tokens?.accessToken).toBe("concurrent-jira-access");
360
+ expect(tokens?.refreshToken).toBe("concurrent-jira-refresh");
361
+ });
269
362
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ parseCodexRateLimitResetTime,
3
4
  parseRateLimitResetTime,
4
5
  parseStderrForErrors,
5
6
  SessionErrorTracker,
@@ -89,6 +90,23 @@ describe("SessionErrorTracker", () => {
89
90
  expect(errors[0]!.type).toBe("stderr_error");
90
91
  expect(errors[0]!.message).toBe("fatal: connection refused");
91
92
  });
93
+
94
+ test("detects Claude CLI invalid --resume session errors as stale sessions", () => {
95
+ const tracker = new SessionErrorTracker();
96
+ trackErrorFromJson(
97
+ {
98
+ type: "result",
99
+ subtype: "error_during_execution",
100
+ is_error: true,
101
+ errors: [
102
+ 'Error during execution: Error: --resume requires a valid session ID or session title when used with --print. Usage: claude -p --resume <session-id|title>. Provided value "ses_19c145de3ffeD9qLlntj8SRO28" is not a UUID and does not match any session title.',
103
+ ],
104
+ },
105
+ tracker,
106
+ );
107
+
108
+ expect(tracker.isSessionNotFound()).toBe(true);
109
+ });
92
110
  });
93
111
 
94
112
  describe("buildFailureReason", () => {
@@ -548,3 +566,37 @@ describe("parseRateLimitResetTime", () => {
548
566
  expect(parseRateLimitResetTime("wait 2000 minutes")).toBeUndefined();
549
567
  });
550
568
  });
569
+
570
+ describe("Codex/Claude coexistence — single tracker handles both providers", () => {
571
+ test("processRateLimitEvent (Claude) and processCodexUsageLimitMessage (Codex) both stash into getRateLimitResetAt", () => {
572
+ // Claude path: processRateLimitEvent
573
+ const claudeTracker = new SessionErrorTracker();
574
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
575
+ claudeTracker.processRateLimitEvent({
576
+ type: "rate_limit_event",
577
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
578
+ });
579
+ expect(claudeTracker.getRateLimitResetAt()).toBeDefined();
580
+
581
+ // Codex path: processCodexUsageLimitMessage
582
+ const codexTracker = new SessionErrorTracker();
583
+ codexTracker.processCodexUsageLimitMessage(
584
+ "You've hit your usage limit. To get more access now, send a request to your admin or try again at 8:35 PM.",
585
+ );
586
+ expect(codexTracker.getRateLimitResetAt()).toBeDefined();
587
+
588
+ // A tracker that received a Claude event does NOT get cross-contaminated by
589
+ // an independent Codex call on a different instance.
590
+ const iso = claudeTracker.getRateLimitResetAt();
591
+ expect(iso).toBeDefined();
592
+ const ms = new Date(iso!).getTime();
593
+ expect(ms).toBeCloseTo(futureResetsAtSec * 1000, -2); // within 100ms tolerance
594
+ });
595
+
596
+ test("parseCodexRateLimitResetTime does not interfere with parseRateLimitResetTime fixtures", () => {
597
+ // Claude format: "resets 3pm (UTC)" — must NOT be matched by Codex parser
598
+ expect(parseCodexRateLimitResetTime("resets 3pm (UTC)")).toBeUndefined();
599
+ // Codex format: "try again at 8:35 PM" — must NOT be matched by Claude parser
600
+ expect(parseRateLimitResetTime("try again at 8:35 PM.")).toBeUndefined();
601
+ });
602
+ });
@@ -8,22 +8,26 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
8
  */
9
9
 
10
10
  let server: ReturnType<typeof Bun.serve>;
11
- const TEST_PORT = 13099;
12
- const TEST_URL = `http://localhost:${TEST_PORT}`;
11
+ let testUrl: string;
12
+ const nativeFetch = globalThis.fetch.bind(globalThis);
13
13
 
14
- // Configurable response for the mock server
15
- let mockResponse: { status: number; body: unknown } = {
14
+ type MockResponse = { status: number; body: unknown };
15
+
16
+ const defaultMockResponse: MockResponse = {
16
17
  status: 200,
17
18
  body: { configs: [] },
18
19
  };
20
+ const mockResponsesByAgentId = new Map<string, MockResponse>();
19
21
 
20
22
  beforeAll(() => {
21
23
  server = Bun.serve({
22
- port: TEST_PORT,
24
+ port: 0,
23
25
  fetch(req) {
24
26
  const url = new URL(req.url);
25
27
 
26
28
  if (url.pathname === "/api/config/resolved") {
29
+ const agentId = url.searchParams.get("agentId") ?? "";
30
+ const mockResponse = mockResponsesByAgentId.get(agentId) ?? defaultMockResponse;
27
31
  return new Response(JSON.stringify(mockResponse.body), {
28
32
  status: mockResponse.status,
29
33
  headers: { "Content-Type": "application/json" },
@@ -33,6 +37,7 @@ beforeAll(() => {
33
37
  return new Response("Not found", { status: 404 });
34
38
  },
35
39
  });
40
+ testUrl = server.url.toString().replace(/\/$/, "");
36
41
  });
37
42
 
38
43
  afterAll(() => {
@@ -56,7 +61,10 @@ async function fetchResolvedEnv(
56
61
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
57
62
 
58
63
  const url = `${apiUrl}/api/config/resolved?agentId=${encodeURIComponent(agentId)}&includeSecrets=true`;
59
- const response = await fetch(url, { headers });
64
+ const request = new Request(url, { headers });
65
+ const response = url.startsWith(testUrl)
66
+ ? await server.fetch(request)
67
+ : await nativeFetch(request);
60
68
 
61
69
  if (!response.ok) {
62
70
  return { ...baseEnv };
@@ -88,12 +96,13 @@ describe("fetchResolvedEnv", () => {
88
96
 
89
97
  test("returns baseEnv when agentId is empty", async () => {
90
98
  const baseEnv = { EXISTING: "value" };
91
- const result = await fetchResolvedEnv(TEST_URL, "key", "", baseEnv);
99
+ const result = await fetchResolvedEnv(testUrl, "key", "", baseEnv);
92
100
  expect(result).toEqual({ EXISTING: "value" });
93
101
  });
94
102
 
95
103
  test("merges API config over baseEnv", async () => {
96
- mockResponse = {
104
+ const agentId = "agent-merge";
105
+ mockResponsesByAgentId.set(agentId, {
97
106
  status: 200,
98
107
  body: {
99
108
  configs: [
@@ -101,10 +110,10 @@ describe("fetchResolvedEnv", () => {
101
110
  { key: "OVERRIDE_VAR", value: "api-wins" },
102
111
  ],
103
112
  },
104
- };
113
+ });
105
114
 
106
115
  const baseEnv = { EXISTING: "keep", OVERRIDE_VAR: "original" };
107
- const result = await fetchResolvedEnv(TEST_URL, "key", "agent-1", baseEnv);
116
+ const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
108
117
 
109
118
  expect(result.EXISTING).toBe("keep");
110
119
  expect(result.NEW_VAR).toBe("from-api");
@@ -112,18 +121,20 @@ describe("fetchResolvedEnv", () => {
112
121
  });
113
122
 
114
123
  test("returns baseEnv when API returns empty configs", async () => {
115
- mockResponse = { status: 200, body: { configs: [] } };
124
+ const agentId = "agent-empty";
125
+ mockResponsesByAgentId.set(agentId, { status: 200, body: { configs: [] } });
116
126
 
117
127
  const baseEnv = { EXISTING: "value" };
118
- const result = await fetchResolvedEnv(TEST_URL, "key", "agent-1", baseEnv);
128
+ const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
119
129
  expect(result).toEqual({ EXISTING: "value" });
120
130
  });
121
131
 
122
132
  test("returns baseEnv when API returns non-200", async () => {
123
- mockResponse = { status: 500, body: { error: "server error" } };
133
+ const agentId = "agent-500";
134
+ mockResponsesByAgentId.set(agentId, { status: 500, body: { error: "server error" } });
124
135
 
125
136
  const baseEnv = { EXISTING: "value" };
126
- const result = await fetchResolvedEnv(TEST_URL, "key", "agent-1", baseEnv);
137
+ const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
127
138
  expect(result).toEqual({ EXISTING: "value" });
128
139
  });
129
140
 
@@ -134,13 +145,14 @@ describe("fetchResolvedEnv", () => {
134
145
  });
135
146
 
136
147
  test("does not mutate the baseEnv object", async () => {
137
- mockResponse = {
148
+ const agentId = "agent-mutation";
149
+ mockResponsesByAgentId.set(agentId, {
138
150
  status: 200,
139
151
  body: { configs: [{ key: "NEW_VAR", value: "new" }] },
140
- };
152
+ });
141
153
 
142
154
  const baseEnv = { EXISTING: "value" };
143
- const result = await fetchResolvedEnv(TEST_URL, "key", "agent-1", baseEnv);
155
+ const result = await fetchResolvedEnv(testUrl, "key", agentId, baseEnv);
144
156
 
145
157
  // baseEnv should be untouched
146
158
  expect(baseEnv).toEqual({ EXISTING: "value" });
@@ -148,7 +160,8 @@ describe("fetchResolvedEnv", () => {
148
160
  });
149
161
 
150
162
  test("handles multiple configs correctly", async () => {
151
- mockResponse = {
163
+ const agentId = "agent-multiple";
164
+ mockResponsesByAgentId.set(agentId, {
152
165
  status: 200,
153
166
  body: {
154
167
  configs: [
@@ -157,9 +170,9 @@ describe("fetchResolvedEnv", () => {
157
170
  { key: "VAR_C", value: "c" },
158
171
  ],
159
172
  },
160
- };
173
+ });
161
174
 
162
- const result = await fetchResolvedEnv(TEST_URL, "key", "agent-1", {});
175
+ const result = await fetchResolvedEnv(testUrl, "key", agentId, {});
163
176
  expect(result.VAR_A).toBe("a");
164
177
  expect(result.VAR_B).toBe("b");
165
178
  expect(result.VAR_C).toBe("c");
@@ -20,7 +20,7 @@ import {
20
20
  type Server,
21
21
  type ServerResponse,
22
22
  } from "node:http";
23
- import { closeDb, createUser, getDb, initDb, upsertKv } from "../be/db";
23
+ import { closeDb, createUser, getBudget, getDb, initDb, upsertKv } from "../be/db";
24
24
  import { fingerprintApiKey, linkIdentity } from "../be/users";
25
25
  import { handleCore } from "../http/core";
26
26
  import { handleUsers } from "../http/users";
@@ -92,6 +92,7 @@ beforeEach(() => {
92
92
  db.run("DELETE FROM user_external_ids");
93
93
  db.run("DELETE FROM user_tokens");
94
94
  db.run("DELETE FROM users");
95
+ db.run("DELETE FROM budgets");
95
96
  db.run("DELETE FROM kv_entries");
96
97
  });
97
98
 
@@ -185,6 +186,33 @@ describe("POST /api/users", () => {
185
186
  });
186
187
 
187
188
  describe("PATCH /api/users/:id", () => {
189
+ test("dailyBudgetUsd mirrors into user-scoped budgets and null removes the mirror", async () => {
190
+ const create = await authedFetch("/api/users", {
191
+ method: "POST",
192
+ body: JSON.stringify({
193
+ name: "Budget Mirror",
194
+ dailyBudgetUsd: 1.25,
195
+ }),
196
+ });
197
+ expect(create.status).toBe(200);
198
+ const { user } = (await create.json()) as { user: { id: string } };
199
+ expect(getBudget("user", user.id)?.dailyBudgetUsd).toBe(1.25);
200
+
201
+ const update = await authedFetch(`/api/users/${user.id}`, {
202
+ method: "PATCH",
203
+ body: JSON.stringify({ dailyBudgetUsd: 2.5 }),
204
+ });
205
+ expect(update.status).toBe(200);
206
+ expect(getBudget("user", user.id)?.dailyBudgetUsd).toBe(2.5);
207
+
208
+ const remove = await authedFetch(`/api/users/${user.id}`, {
209
+ method: "PATCH",
210
+ body: JSON.stringify({ dailyBudgetUsd: null }),
211
+ });
212
+ expect(remove.status).toBe(200);
213
+ expect(getBudget("user", user.id)).toBeNull();
214
+ });
215
+
188
216
  test("budget / status / emailAliases diffs each emit the right event types", async () => {
189
217
  const u = createUser({
190
218
  name: "Patcher",