@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,325 @@
1
+ import { afterAll, 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 { closeDb, createTaskExtended, createUser, getDb, getTaskById, initDb } from "../be/db";
11
+ import { type IdentityActor, mintToken, revokeToken } from "../be/users";
12
+ import { handleCore } from "../http/core";
13
+ import { handleMcp } from "../http/mcp";
14
+ import { handleMcpUser } from "../http/mcp-user";
15
+
16
+ const TEST_DB_PATH = "./test-mcp-user-route.sqlite";
17
+ const API_KEY = "test-mcp-user-key";
18
+ const ACTOR: IdentityActor = { kind: "operator", id: "test" };
19
+
20
+ async function removeDbFiles(path: string): Promise<void> {
21
+ for (const suffix of ["", "-wal", "-shm"]) {
22
+ try {
23
+ await unlink(path + suffix);
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
26
+ }
27
+ }
28
+ }
29
+
30
+ async function listen(server: Server): Promise<number> {
31
+ const port = 15173;
32
+ await new Promise<void>((resolve, reject) => {
33
+ server.once("error", reject);
34
+ server.listen(port, "127.0.0.1", () => {
35
+ server.off("error", reject);
36
+ resolve();
37
+ });
38
+ });
39
+ const addr = server.address();
40
+ if (!addr || typeof addr === "string") throw new Error("no port");
41
+ return addr.port;
42
+ }
43
+
44
+ function createTestServer(): Server {
45
+ const transports: Record<string, StreamableHTTPServerTransport> = {};
46
+ const transportsUser: Record<string, StreamableHTTPServerTransport> = {};
47
+ const sessionUsers: Record<string, string> = {};
48
+
49
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
50
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
51
+ if (await handleCore(req, res, myAgentId, API_KEY)) return;
52
+ if (await handleMcp(req, res, transports)) return;
53
+ if (await handleMcpUser(req, res, transportsUser, sessionUsers)) return;
54
+ res.writeHead(404);
55
+ res.end("Not Found");
56
+ });
57
+ }
58
+
59
+ let server: Server;
60
+ let port: number;
61
+
62
+ beforeAll(async () => {
63
+ await removeDbFiles(TEST_DB_PATH);
64
+ initDb(TEST_DB_PATH);
65
+ server = createTestServer();
66
+ port = await listen(server);
67
+ });
68
+
69
+ afterAll(async () => {
70
+ await new Promise<void>((resolve) => server.close(() => resolve()));
71
+ closeDb();
72
+ await removeDbFiles(TEST_DB_PATH);
73
+ });
74
+
75
+ beforeEach(() => {
76
+ // Clean slate between tests for deterministic token and task state.
77
+ const db = getDb();
78
+ db.run("DELETE FROM user_identity_events");
79
+ db.run("DELETE FROM user_tokens");
80
+ db.run("DELETE FROM agent_tasks");
81
+ db.run("DELETE FROM users");
82
+ });
83
+
84
+ function endpoint(path = "/mcp-user"): string {
85
+ return `http://localhost:${port}${path}`;
86
+ }
87
+
88
+ function parseMcpPayload(text: string): unknown {
89
+ const trimmed = text.trim();
90
+ if (!trimmed) return null;
91
+ if (trimmed.startsWith("event:") || trimmed.startsWith("data:")) {
92
+ const data = trimmed
93
+ .split("\n")
94
+ .filter((line) => line.startsWith("data:"))
95
+ .map((line) => line.slice("data:".length).trim())
96
+ .join("\n");
97
+ return JSON.parse(data);
98
+ }
99
+ return JSON.parse(trimmed);
100
+ }
101
+
102
+ async function mcpPost(
103
+ token: string | null,
104
+ body: Record<string, unknown>,
105
+ sessionId?: string,
106
+ path = "/mcp-user",
107
+ extraHeaders?: Record<string, string>,
108
+ ): Promise<{ response: Response; payload: unknown; text: string }> {
109
+ const headers: Record<string, string> = {
110
+ Accept: "application/json, text/event-stream",
111
+ "Content-Type": "application/json",
112
+ ...extraHeaders,
113
+ };
114
+ if (token) headers.Authorization = `Bearer ${token}`;
115
+ if (sessionId) headers["mcp-session-id"] = sessionId;
116
+
117
+ const response = await fetch(endpoint(path), {
118
+ method: "POST",
119
+ headers,
120
+ body: JSON.stringify(body),
121
+ });
122
+ const text = await response.text();
123
+ const payload = text ? parseMcpPayload(text) : null;
124
+ return { response, payload, text };
125
+ }
126
+
127
+ async function initialize(
128
+ token: string,
129
+ path = "/mcp-user",
130
+ extraHeaders?: Record<string, string>,
131
+ ): Promise<string> {
132
+ const { response, text } = await mcpPost(
133
+ token,
134
+ {
135
+ jsonrpc: "2.0",
136
+ id: 1,
137
+ method: "initialize",
138
+ params: {
139
+ protocolVersion: "2024-11-05",
140
+ clientInfo: { name: "test", version: "1" },
141
+ capabilities: {},
142
+ },
143
+ },
144
+ undefined,
145
+ path,
146
+ extraHeaders,
147
+ );
148
+ expect(response.status).toBe(200);
149
+ const sessionId = response.headers.get("mcp-session-id");
150
+ if (!sessionId) throw new Error(`missing mcp-session-id from initialize response: ${text}`);
151
+ return sessionId;
152
+ }
153
+
154
+ async function notifyInitialized(
155
+ token: string,
156
+ sessionId: string,
157
+ path = "/mcp-user",
158
+ extraHeaders?: Record<string, string>,
159
+ ): Promise<void> {
160
+ const { response } = await mcpPost(
161
+ token,
162
+ { jsonrpc: "2.0", method: "notifications/initialized" },
163
+ sessionId,
164
+ path,
165
+ extraHeaders,
166
+ );
167
+ expect([200, 202]).toContain(response.status);
168
+ }
169
+
170
+ describe("/mcp-user auth and tool surface", () => {
171
+ test("request to /mcp-user with no token returns 401", async () => {
172
+ const { response } = await mcpPost(null, {
173
+ jsonrpc: "2.0",
174
+ id: 1,
175
+ method: "initialize",
176
+ params: {
177
+ protocolVersion: "2024-11-05",
178
+ clientInfo: { name: "test", version: "1" },
179
+ capabilities: {},
180
+ },
181
+ });
182
+
183
+ expect(response.status).toBe(401);
184
+ });
185
+
186
+ test("request to /mcp-user with a revoked token returns 401", async () => {
187
+ const user = createUser({ name: "Revoked User" });
188
+ const token = mintToken(user.id, "revoked", ACTOR);
189
+ revokeToken(token.tokenId, ACTOR);
190
+
191
+ const { response } = await mcpPost(token.plaintext, {
192
+ jsonrpc: "2.0",
193
+ id: 1,
194
+ method: "initialize",
195
+ params: {
196
+ protocolVersion: "2024-11-05",
197
+ clientInfo: { name: "test", version: "1" },
198
+ capabilities: {},
199
+ },
200
+ });
201
+
202
+ expect(response.status).toBe(401);
203
+ });
204
+
205
+ test("request to /mcp-user with a suspended user's valid token returns 401", async () => {
206
+ const user = createUser({ name: "Suspended User", status: "suspended" });
207
+ const token = mintToken(user.id, "suspended", ACTOR);
208
+
209
+ const { response } = await mcpPost(token.plaintext, {
210
+ jsonrpc: "2.0",
211
+ id: 1,
212
+ method: "initialize",
213
+ params: {
214
+ protocolVersion: "2024-11-05",
215
+ clientInfo: { name: "test", version: "1" },
216
+ capabilities: {},
217
+ },
218
+ });
219
+
220
+ expect(response.status).toBe(401);
221
+ });
222
+
223
+ test("request with a different user token than the opening session returns 401", async () => {
224
+ const userA = createUser({ name: "Session A" });
225
+ const userB = createUser({ name: "Session B" });
226
+ const tokenA = mintToken(userA.id, "a", ACTOR).plaintext;
227
+ const tokenB = mintToken(userB.id, "b", ACTOR).plaintext;
228
+ const sessionId = await initialize(tokenA);
229
+
230
+ const { response } = await mcpPost(
231
+ tokenB,
232
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
233
+ sessionId,
234
+ );
235
+
236
+ expect(response.status).toBe(401);
237
+ });
238
+
239
+ test("valid active-user token initializes and tools/list returns exactly the 5 task tools", async () => {
240
+ const user = createUser({ name: "Active User" });
241
+ const token = mintToken(user.id, "active", ACTOR).plaintext;
242
+ const sessionId = await initialize(token);
243
+ await notifyInitialized(token, sessionId);
244
+
245
+ const { response, payload } = await mcpPost(
246
+ token,
247
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
248
+ sessionId,
249
+ );
250
+
251
+ expect(response.status).toBe(200);
252
+ const result = payload as { result: { tools: Array<{ name: string }> } };
253
+ const names = result.result.tools.map((tool) => tool.name).sort();
254
+ expect(names).toEqual(
255
+ ["cancel-task", "get-task-details", "get-tasks", "send-task", "task-action"].sort(),
256
+ );
257
+ });
258
+
259
+ test("send-task over /mcp-user records requestedByUserId and get-tasks returns only that user's tasks", async () => {
260
+ const user = createUser({ name: "Task Requester" });
261
+ const otherUser = createUser({ name: "Other Task Requester" });
262
+ const token = mintToken(user.id, "task", ACTOR).plaintext;
263
+ const sessionId = await initialize(token);
264
+ await notifyInitialized(token, sessionId);
265
+
266
+ const { response, payload } = await mcpPost(
267
+ token,
268
+ {
269
+ jsonrpc: "2.0",
270
+ id: 3,
271
+ method: "tools/call",
272
+ params: { name: "send-task", arguments: { task: "user mcp task" } },
273
+ },
274
+ sessionId,
275
+ );
276
+
277
+ expect(response.status).toBe(200);
278
+ const result = payload as { result: { structuredContent: { task: { id: string } } } };
279
+ const taskId = result.result.structuredContent.task.id;
280
+ expect(getTaskById(taskId)?.requestedByUserId).toBe(user.id);
281
+ const foreignTask = createTaskExtended("foreign user mcp task", {
282
+ requestedByUserId: otherUser.id,
283
+ });
284
+ createTaskExtended("owner-only task");
285
+
286
+ const listResponse = await mcpPost(
287
+ token,
288
+ {
289
+ jsonrpc: "2.0",
290
+ id: 4,
291
+ method: "tools/call",
292
+ params: { name: "get-tasks", arguments: { includeFull: true, limit: 50 } },
293
+ },
294
+ sessionId,
295
+ );
296
+
297
+ expect(listResponse.response.status).toBe(200);
298
+ const listResult = listResponse.payload as {
299
+ result: { structuredContent: { tasks: Array<{ id: string; task?: string }> } };
300
+ };
301
+ const ids = listResult.result.structuredContent.tasks.map((task) => task.id);
302
+ expect(ids).toContain(taskId);
303
+ expect(ids).not.toContain(foreignTask.id);
304
+ expect(listResult.result.structuredContent.tasks).toHaveLength(1);
305
+ });
306
+
307
+ test("owner /mcp path still initializes with swarm API key", async () => {
308
+ const ownerHeaders = { "X-Agent-ID": "00000000-0000-4000-8000-000000000001" };
309
+ const sessionId = await initialize(API_KEY, "/mcp", ownerHeaders);
310
+ await notifyInitialized(API_KEY, sessionId, "/mcp", ownerHeaders);
311
+
312
+ const { response, payload } = await mcpPost(
313
+ API_KEY,
314
+ { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
315
+ sessionId,
316
+ "/mcp",
317
+ ownerHeaders,
318
+ );
319
+
320
+ expect(response.status).toBe(200);
321
+ const result = payload as { result: { tools: Array<{ name: string }> } };
322
+ const names = result.result.tools.map((tool) => tool.name);
323
+ expect(names).toContain("send-task");
324
+ });
325
+ });
@@ -141,6 +141,81 @@ describe("OpencodeSession — SSE→ProviderEvent mapping", () => {
141
141
  expect(result.failureReason).toContain("provider overloaded");
142
142
  });
143
143
 
144
+ test("prompt Model not found refreshes OpenRouter cache and retries once", async () => {
145
+ const emitted: ProviderEvent[] = [];
146
+ const refreshCalls: Array<{ model?: string; configFilePath: string; dataHomePath: string }> =
147
+ [];
148
+ const fakeSessionId = "sess-abc-123";
149
+ let promptCalls = 0;
150
+ let resolveSecondPrompt!: () => void;
151
+ const secondPromptSent = new Promise<void>((resolve) => {
152
+ resolveSecondPrompt = resolve;
153
+ });
154
+
155
+ const fakeClient = {
156
+ session: {
157
+ create: async () => ({ data: { id: fakeSessionId }, error: undefined }),
158
+ prompt: async (args: unknown) => {
159
+ lastPromptArgs = args;
160
+ promptCalls += 1;
161
+ if (promptCalls === 1) {
162
+ throw new Error(
163
+ "Model not found: openrouter/x-ai/grok-4.3. Did you mean: x-ai/grok-4.3?",
164
+ );
165
+ }
166
+ resolveSecondPrompt();
167
+ return { data: {}, error: undefined };
168
+ },
169
+ },
170
+ event: {
171
+ subscribe: async () => ({
172
+ stream: (async function* (): AsyncGenerator<OpencodeEvent> {
173
+ await secondPromptSent;
174
+ yield { type: "session.idle", properties: { sessionID: fakeSessionId } };
175
+ })(),
176
+ }),
177
+ },
178
+ };
179
+ const fakeServer = { url: "http://127.0.0.1:12345", close: mock(() => {}) };
180
+
181
+ mock.module("@opencode-ai/sdk", () => ({
182
+ createOpencode: async () => ({ client: fakeClient, server: fakeServer }),
183
+ }));
184
+
185
+ const { OpencodeAdapter, _setOpenRouterModelCacheRefreshForTests } = await import(
186
+ "../providers/opencode-adapter"
187
+ );
188
+ _setOpenRouterModelCacheRefreshForTests(
189
+ async (opencodeConfig, configFilePath, dataHomePath) => {
190
+ refreshCalls.push({ model: opencodeConfig.model, configFilePath, dataHomePath });
191
+ },
192
+ );
193
+ try {
194
+ const adapter = new OpencodeAdapter();
195
+ const session = await adapter.createSession(
196
+ testConfig({ model: "openrouter/x-ai/grok-4.3", taskId: "task-refresh" }),
197
+ );
198
+ session.onEvent((e) => emitted.push(e));
199
+
200
+ const result = await session.waitForCompletion();
201
+
202
+ expect(result.isError).toBe(false);
203
+ expect(promptCalls).toBe(2);
204
+ expect(refreshCalls).toEqual([
205
+ {
206
+ model: "openrouter/x-ai/grok-4.3",
207
+ configFilePath: "/tmp/opencode-task-refresh.json",
208
+ dataHomePath: "/tmp/opencode-data-task-refresh",
209
+ },
210
+ ]);
211
+ expect(emitted.some((e) => e.type === "progress" && e.message.includes("refreshing"))).toBe(
212
+ true,
213
+ );
214
+ } finally {
215
+ _setOpenRouterModelCacheRefreshForTests(null);
216
+ }
217
+ });
218
+
144
219
  test("permission.updated → emits error (headless cannot approve)", async () => {
145
220
  const events: OpencodeEvent[] = [
146
221
  {
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
4
+ import { createPiRuntimeAuth, PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
5
5
 
6
6
  describe("PiMonoAdapter", () => {
7
7
  test("name is 'pi'", () => {
@@ -177,6 +177,26 @@ describe("resolveModel — OpenRouter reroute for anthropic shortnames", () => {
177
177
  });
178
178
  });
179
179
 
180
+ describe("createPiRuntimeAuth", () => {
181
+ test("threads resolved OpenRouter key into pi runtime auth without process.env", async () => {
182
+ const { modelRegistry } = createPiRuntimeAuth({ OPENROUTER_API_KEY: "sk-or-runtime" });
183
+
184
+ await expect(modelRegistry.getApiKeyForProvider("openrouter")).resolves.toBe("sk-or-runtime");
185
+ });
186
+
187
+ test("supports all pi env-backed providers", async () => {
188
+ const { modelRegistry } = createPiRuntimeAuth({
189
+ ANTHROPIC_API_KEY: "sk-ant-runtime",
190
+ OPENAI_API_KEY: "sk-openai-runtime",
191
+ GOOGLE_API_KEY: "sk-google-runtime",
192
+ });
193
+
194
+ await expect(modelRegistry.getApiKeyForProvider("anthropic")).resolves.toBe("sk-ant-runtime");
195
+ await expect(modelRegistry.getApiKeyForProvider("openai")).resolves.toBe("sk-openai-runtime");
196
+ await expect(modelRegistry.getApiKeyForProvider("google")).resolves.toBe("sk-google-runtime");
197
+ });
198
+ });
199
+
180
200
  describe("Pi-mono event normalization", () => {
181
201
  test("message_update with text content produces raw_log-style data", () => {
182
202
  // Simulates what PiMonoSession.handleAgentEvent does
@@ -1,5 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { SessionErrorTracker, trackErrorFromJson } from "../utils/error-tracker";
2
+ import {
3
+ isRateLimitMessage,
4
+ MAX_RATE_LIMIT_RESET_MS,
5
+ parseStderrForErrors,
6
+ SessionErrorTracker,
7
+ trackErrorFromJson,
8
+ } from "../utils/error-tracker";
3
9
 
4
10
  // Verbatim fixture from Linear CAI-1279 (session logs for task b7fbbdb9-4922-41d9-88ec-21febd6c4fec)
5
11
  const FIXTURE_REJECTED = {
@@ -25,7 +31,7 @@ describe("SessionErrorTracker — rate_limit_event processing", () => {
25
31
  expect(result).toBeDefined();
26
32
 
27
33
  // resetsAt: 1779202200 sec → 2026-05-19T14:50:00.000Z
28
- // But since we clamp to [now+60s, now+6h] and this is a past timestamp,
34
+ // But since we clamp to [now+60s, now+7d] and this is a past timestamp,
29
35
  // the value will be clamped to now+60s. What matters is the sec→ms conversion works.
30
36
  // We verify the unit is correct by checking that 1779202200 * 1000 = ms,
31
37
  // which is NOT the same as treating it as ms (would be 1970-01-21).
@@ -125,7 +131,7 @@ describe("SessionErrorTracker — rate_limit_event processing", () => {
125
131
  expect(parsedMs).toBeLessThanOrEqual(nowMs + 65_000);
126
132
  });
127
133
 
128
- test("resetsAt absurdly far in future → clamped to now+6h (malformed defense)", () => {
134
+ test("resetsAt absurdly far in future → clamped to now+7d (malformed defense)", () => {
129
135
  const tracker = new SessionErrorTracker();
130
136
  // Year 2099 in seconds
131
137
  tracker.processRateLimitEvent({
@@ -137,8 +143,25 @@ describe("SessionErrorTracker — rate_limit_event processing", () => {
137
143
  expect(result).toBeDefined();
138
144
  const parsedMs = new Date(result!).getTime();
139
145
  const nowMs = Date.now();
140
- const sixHoursMs = 6 * 60 * 60 * 1000;
141
- expect(parsedMs).toBeLessThanOrEqual(nowMs + sixHoursMs + 1000); // within 6h (+1s tolerance)
146
+ const sevenDaysMs = MAX_RATE_LIMIT_RESET_MS;
147
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + sevenDaysMs + 1000); // within 7d (+1s tolerance)
148
+ });
149
+
150
+ test("legitimate weekly reset (~2 days out) is honored, not clamped to 6h", () => {
151
+ const tracker = new SessionErrorTracker();
152
+ const twoDaysFromNowSec = Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60;
153
+ tracker.processRateLimitEvent({
154
+ type: "rate_limit_event",
155
+ rate_limit_info: { status: "rejected", resetsAt: twoDaysFromNowSec, rateLimitType: "weekly" },
156
+ });
157
+
158
+ const result = tracker.getRateLimitResetAt();
159
+ expect(result).toBeDefined();
160
+ const parsedMs = new Date(result!).getTime();
161
+ const nowMs = Date.now();
162
+ // The real reset is ~2 days out — must be honored, not capped at 6h.
163
+ expect(parsedMs).toBeGreaterThan(nowMs + 6 * 60 * 60 * 1000);
164
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 2 * 24 * 60 * 60 * 1000 + 1000);
142
165
  });
143
166
 
144
167
  test("multiple rate_limit_event lines → last rejected one wins", () => {
@@ -255,7 +278,7 @@ describe("three-tier resolver logic (unit test via clamp helper)", () => {
255
278
  function clampResetTime(isoString: string): string {
256
279
  const nowMs = Date.now();
257
280
  const minMs = nowMs + 60_000;
258
- const maxMs = nowMs + 6 * 60 * 60 * 1000;
281
+ const maxMs = nowMs + MAX_RATE_LIMIT_RESET_MS;
259
282
  const candidateMs = new Date(isoString).getTime();
260
283
  return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
261
284
  }
@@ -290,3 +313,43 @@ describe("three-tier resolver logic (unit test via clamp helper)", () => {
290
313
  expect(resolvedMs).toBeLessThanOrEqual(nowMs + 6 * 60_000);
291
314
  });
292
315
  });
316
+
317
+ describe("isRateLimitMessage — shared matcher (runner gate + stderr parser)", () => {
318
+ test.each([
319
+ "You've hit your weekly limit · resets May 28, 5pm (UTC)",
320
+ "hit your 5-hour limit",
321
+ "hit your limit",
322
+ "Claude usage limit reached",
323
+ "hit your daily limit",
324
+ "rate limit exceeded",
325
+ "rate_limit error",
326
+ "429 Too Many Requests",
327
+ "Error: too many requests, slow down",
328
+ "[rate-limit] codex prefix",
329
+ "[usage-limit] codex prefix",
330
+ ])("matches rate-limit signal: %s", (msg) => {
331
+ expect(isRateLimitMessage(msg)).toBe(true);
332
+ });
333
+
334
+ test.each([
335
+ "No conversation found with session ID abc",
336
+ "Max turns exceeded",
337
+ "Authentication failed: invalid token",
338
+ "Error during execution: file not found",
339
+ "Read 4290 bytes from stream",
340
+ ])("does NOT match non-rate-limit text: %s", (msg) => {
341
+ expect(isRateLimitMessage(msg)).toBe(false);
342
+ });
343
+
344
+ test("parseStderrForErrors flags a weekly-limit stderr line as an error", () => {
345
+ const tracker = new SessionErrorTracker();
346
+ parseStderrForErrors("You've hit your weekly limit · resets May 28, 5pm (UTC)", tracker);
347
+ expect(tracker.hasErrors()).toBe(true);
348
+ });
349
+
350
+ test("parseStderrForErrors still flags a bare 429 stderr line", () => {
351
+ const tracker = new SessionErrorTracker();
352
+ parseStderrForErrors("HTTP 429 returned by upstream", tracker);
353
+ expect(tracker.hasErrors()).toBe(true);
354
+ });
355
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveResumeSession } from "../commands/resume-session";
3
+
4
+ describe("resolveResumeSession", () => {
5
+ test("allows local Claude resume for UUID session ids", () => {
6
+ const resolution = resolveResumeSession("claude", [
7
+ {
8
+ source: "task",
9
+ sessionId: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
10
+ provider: "claude",
11
+ },
12
+ ]);
13
+
14
+ expect(resolution.resumeSessionId).toBe("69dbe5a1-1130-45eb-983f-58a7a13c9c3c");
15
+ expect(resolution.source).toBe("task");
16
+ expect(resolution.provider).toBe("claude");
17
+ expect(resolution.skipped).toEqual([]);
18
+ });
19
+
20
+ test("rejects non-UUID ids for local Claude resume", () => {
21
+ const resolution = resolveResumeSession("claude", [
22
+ {
23
+ source: "task",
24
+ sessionId: "ses_19c145de3ffeD9qLlntj8SRO28",
25
+ provider: "claude",
26
+ },
27
+ ]);
28
+
29
+ expect(resolution.resumeSessionId).toBeUndefined();
30
+ expect(resolution.skipped).toHaveLength(1);
31
+ expect(resolution.skipped[0]?.reason).toBe("Claude CLI --resume requires a UUID session id");
32
+ });
33
+
34
+ test("normalizes legacy managed Claude rows to claude-managed", () => {
35
+ const resolution = resolveResumeSession("claude-managed", [
36
+ {
37
+ source: "parent",
38
+ sessionId: "sesn_resume_xyz",
39
+ provider: "claude",
40
+ providerMeta: { managed: true },
41
+ },
42
+ ]);
43
+
44
+ expect(resolution.resumeSessionId).toBe("sesn_resume_xyz");
45
+ expect(resolution.source).toBe("parent");
46
+ expect(resolution.provider).toBe("claude-managed");
47
+ });
48
+
49
+ test("skips mismatched provider sessions and falls back to parent", () => {
50
+ const resolution = resolveResumeSession("claude", [
51
+ {
52
+ source: "task",
53
+ sessionId: "thread-codex",
54
+ provider: "codex",
55
+ },
56
+ {
57
+ source: "parent",
58
+ sessionId: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
59
+ provider: "claude",
60
+ },
61
+ ]);
62
+
63
+ expect(resolution.resumeSessionId).toBe("69dbe5a1-1130-45eb-983f-58a7a13c9c3c");
64
+ expect(resolution.source).toBe("parent");
65
+ expect(resolution.skipped).toHaveLength(1);
66
+ expect(resolution.skipped[0]?.reason).toContain("does not match current provider");
67
+ });
68
+
69
+ test("rejects legacy unknown non-UUID Claude session ids", () => {
70
+ const resolution = resolveResumeSession("claude", [
71
+ {
72
+ source: "task",
73
+ sessionId: "ses_19c145de3ffeD9qLlntj8SRO28",
74
+ },
75
+ ]);
76
+
77
+ expect(resolution.resumeSessionId).toBeUndefined();
78
+ expect(resolution.skipped[0]?.reason).toBe("legacy Claude resume requires a UUID session id");
79
+ });
80
+
81
+ test("does not resume providers without runner resume support", () => {
82
+ const resolution = resolveResumeSession("pi", [
83
+ {
84
+ source: "task",
85
+ sessionId: "pi-session",
86
+ provider: "pi",
87
+ },
88
+ ]);
89
+
90
+ expect(resolution.resumeSessionId).toBeUndefined();
91
+ expect(resolution.skipped[0]?.reason).toBe("provider pi does not support runner resume");
92
+ });
93
+ });