@desplega.ai/agent-swarm 1.80.0 → 1.80.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 (100) hide show
  1. package/openapi.json +399 -14
  2. package/package.json +3 -1
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +1 -1
  5. package/src/be/migrations/064_scripts.sql +39 -0
  6. package/src/be/migrations/065_script_embeddings.sql +7 -0
  7. package/src/be/migrations/066_scripts_args_json_schema.sql +1 -0
  8. package/src/be/scripts/db.ts +417 -0
  9. package/src/be/scripts/embeddings.ts +233 -0
  10. package/src/be/scripts/extract-schema.ts +55 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +199 -0
  13. package/src/cli.tsx +22 -5
  14. package/src/commands/artifact.ts +3 -2
  15. package/src/commands/claude-managed-setup.ts +2 -1
  16. package/src/commands/codex-login.ts +5 -3
  17. package/src/commands/onboard.tsx +2 -1
  18. package/src/commands/runner.ts +153 -20
  19. package/src/commands/setup.tsx +5 -3
  20. package/src/hooks/hook.ts +4 -3
  21. package/src/http/index.ts +40 -29
  22. package/src/http/memory.ts +28 -0
  23. package/src/http/openapi.ts +1 -0
  24. package/src/http/page-proxy.ts +2 -1
  25. package/src/http/route-def.ts +1 -0
  26. package/src/http/schedules.ts +37 -0
  27. package/src/http/scripts.ts +388 -0
  28. package/src/linear/outbound.ts +9 -2
  29. package/src/otel.ts +5 -0
  30. package/src/providers/claude-adapter.ts +23 -1
  31. package/src/providers/types.ts +8 -0
  32. package/src/scripts-runtime/ctx.ts +23 -0
  33. package/src/scripts-runtime/eval-harness.ts +63 -0
  34. package/src/scripts-runtime/executors/native.ts +232 -0
  35. package/src/scripts-runtime/executors/registry.ts +16 -0
  36. package/src/scripts-runtime/executors/types.ts +63 -0
  37. package/src/scripts-runtime/extract-args-schema.ts +69 -0
  38. package/src/scripts-runtime/extract-signature.ts +81 -0
  39. package/src/scripts-runtime/import-allowlist.ts +109 -0
  40. package/src/scripts-runtime/loader.ts +96 -0
  41. package/src/scripts-runtime/redacted.ts +48 -0
  42. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  43. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  44. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  45. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  46. package/src/scripts-runtime/stdlib/index.ts +16 -0
  47. package/src/scripts-runtime/stdlib/table.ts +17 -0
  48. package/src/scripts-runtime/swarm-config.ts +35 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  50. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  52. package/src/server.ts +12 -0
  53. package/src/tests/api-key.test.ts +33 -0
  54. package/src/tests/codex-login.test.ts +1 -1
  55. package/src/tests/error-tracker.test.ts +44 -0
  56. package/src/tests/linear-outbound-sync.test.ts +109 -0
  57. package/src/tests/mcp-tools.test.ts +69 -0
  58. package/src/tests/rate-limit-event.test.ts +292 -0
  59. package/src/tests/redacted.test.ts +29 -0
  60. package/src/tests/runner-tool-spans.test.ts +268 -0
  61. package/src/tests/script-executor-conformance.test.ts +142 -0
  62. package/src/tests/script-executor-registry.test.ts +17 -0
  63. package/src/tests/scripts-db.test.ts +329 -0
  64. package/src/tests/scripts-embeddings.test.ts +291 -0
  65. package/src/tests/scripts-extract-signature.test.ts +47 -0
  66. package/src/tests/scripts-http.test.ts +403 -0
  67. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  68. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  69. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  70. package/src/tests/scripts-runtime.test.ts +344 -0
  71. package/src/tests/sdk-allowlist.test.ts +59 -0
  72. package/src/tests/secret-scrubber.test.ts +35 -1
  73. package/src/tests/swarm-config.test.ts +38 -0
  74. package/src/tests/tool-annotations.test.ts +2 -2
  75. package/src/tests/tool-call-progress.test.ts +30 -0
  76. package/src/tests/workflow-e2e.test.ts +218 -0
  77. package/src/tests/workflow-executors.test.ts +32 -2
  78. package/src/tests/workflow-input-redaction.test.ts +232 -0
  79. package/src/tests/workflow-swarm-script.test.ts +273 -0
  80. package/src/tools/memory-rate.ts +2 -1
  81. package/src/tools/script-common.ts +88 -0
  82. package/src/tools/script-delete.ts +35 -0
  83. package/src/tools/script-query-types.ts +37 -0
  84. package/src/tools/script-run.ts +43 -0
  85. package/src/tools/script-search.ts +32 -0
  86. package/src/tools/script-upsert.ts +43 -0
  87. package/src/tools/tool-config.ts +7 -0
  88. package/src/types.ts +61 -1
  89. package/src/utils/api-key.ts +28 -0
  90. package/src/utils/error-tracker.ts +58 -0
  91. package/src/utils/page-session.ts +8 -6
  92. package/src/utils/secret-scrubber.ts +22 -1
  93. package/src/workflows/engine.ts +12 -4
  94. package/src/workflows/executors/index.ts +1 -0
  95. package/src/workflows/executors/registry.ts +2 -0
  96. package/src/workflows/executors/script.ts +12 -1
  97. package/src/workflows/executors/swarm-script.ts +170 -0
  98. package/src/workflows/input.ts +65 -0
  99. package/src/workflows/recovery.ts +31 -3
  100. package/src/workflows/resume.ts +43 -5
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getApiKey, setApiKey } from "../utils/api-key";
3
+
4
+ describe("getApiKey", () => {
5
+ test("returns empty string when neither var is set", () => {
6
+ expect(getApiKey({})).toBe("");
7
+ });
8
+
9
+ test("returns API_KEY when only legacy var is set", () => {
10
+ expect(getApiKey({ API_KEY: "legacy" })).toBe("legacy");
11
+ });
12
+
13
+ test("returns AGENT_SWARM_API_KEY when only preferred var is set", () => {
14
+ expect(getApiKey({ AGENT_SWARM_API_KEY: "preferred" })).toBe("preferred");
15
+ });
16
+
17
+ test("prefers AGENT_SWARM_API_KEY over API_KEY when both set", () => {
18
+ expect(getApiKey({ AGENT_SWARM_API_KEY: "preferred", API_KEY: "legacy" })).toBe("preferred");
19
+ });
20
+
21
+ test("falls back to API_KEY if AGENT_SWARM_API_KEY is undefined", () => {
22
+ expect(getApiKey({ AGENT_SWARM_API_KEY: undefined, API_KEY: "x" })).toBe("x");
23
+ });
24
+ });
25
+
26
+ describe("setApiKey", () => {
27
+ test("populates both env var names", () => {
28
+ const env: Record<string, string | undefined> = {};
29
+ setApiKey("k", env);
30
+ expect(env.AGENT_SWARM_API_KEY).toBe("k");
31
+ expect(env.API_KEY).toBe("k");
32
+ });
33
+ });
@@ -70,7 +70,7 @@ describe("resolveCodexLoginConfig", () => {
70
70
  expect(promptSecret).toHaveBeenCalledWith(
71
71
  "Swarm API key",
72
72
  "env-secret",
73
- "Press Enter to use API_KEY from the environment",
73
+ "Press Enter to use AGENT_SWARM_API_KEY/API_KEY from the environment",
74
74
  );
75
75
  });
76
76
 
@@ -6,6 +6,38 @@ import {
6
6
  trackErrorFromJson,
7
7
  } from "../utils/error-tracker";
8
8
 
9
+ describe("SessionErrorTracker — getRateLimitResetAt", () => {
10
+ test("returns undefined when no rate_limit_event was processed", () => {
11
+ const tracker = new SessionErrorTracker();
12
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
13
+ });
14
+
15
+ test("returns ISO string after a rejected rate_limit_event", () => {
16
+ const tracker = new SessionErrorTracker();
17
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
18
+ tracker.processRateLimitEvent({
19
+ type: "rate_limit_event",
20
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
21
+ });
22
+ const result = tracker.getRateLimitResetAt();
23
+ expect(result).toBeDefined();
24
+ expect(() => new Date(result!).toISOString()).not.toThrow();
25
+ });
26
+
27
+ test("returns undefined after only allowed/allowed_warning events", () => {
28
+ const tracker = new SessionErrorTracker();
29
+ tracker.processRateLimitEvent({
30
+ type: "rate_limit_event",
31
+ rate_limit_info: { status: "allowed", resetsAt: 1779202200 },
32
+ });
33
+ tracker.processRateLimitEvent({
34
+ type: "rate_limit_event",
35
+ rate_limit_info: { status: "allowed_warning", resetsAt: 1779202200 },
36
+ });
37
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
38
+ });
39
+ });
40
+
9
41
  describe("SessionErrorTracker", () => {
10
42
  test("hasErrors returns false when no errors tracked", () => {
11
43
  const tracker = new SessionErrorTracker();
@@ -263,6 +295,18 @@ describe("trackErrorFromJson", () => {
263
295
  trackErrorFromJson({ type: "content_block_delta", delta: {} }, tracker);
264
296
  expect(tracker.hasErrors()).toBe(false);
265
297
  });
298
+
299
+ test("rate_limit_event is not treated as an error signal", () => {
300
+ const tracker = new SessionErrorTracker();
301
+ trackErrorFromJson(
302
+ {
303
+ type: "rate_limit_event",
304
+ rate_limit_info: { status: "rejected", resetsAt: 1779202200 },
305
+ },
306
+ tracker,
307
+ );
308
+ expect(tracker.hasErrors()).toBe(false);
309
+ });
266
310
  });
267
311
 
268
312
  describe("parseStderrForErrors", () => {
@@ -3,6 +3,7 @@ import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
4
  import { createTrackerSync, getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
5
5
  import { initLinearOutboundSync, teardownLinearOutboundSync } from "../linear/outbound";
6
+ import { taskSessionMap } from "../linear/sync";
6
7
  import { workflowEventBus } from "../workflows/event-bus";
7
8
 
8
9
  const TEST_DB_PATH = "./test-linear-outbound-sync.sqlite";
@@ -17,6 +18,19 @@ mock.module("../linear/client", () => ({
17
18
  resetLinearClient: () => {},
18
19
  }));
19
20
 
21
+ // Mock the AgentSession helpers in linear/sync so we can assert which activity type
22
+ // the outbound handlers post (`action` vs `thought` vs `response`/`error`).
23
+ const mockPostAgentSessionThought = mock(() => Promise.resolve());
24
+ const mockPostAgentSessionAction = mock(() => Promise.resolve());
25
+ const mockEndAgentSession = mock(() => Promise.resolve());
26
+
27
+ mock.module("../linear/sync", () => ({
28
+ postAgentSessionThought: mockPostAgentSessionThought,
29
+ postAgentSessionAction: mockPostAgentSessionAction,
30
+ endAgentSession: mockEndAgentSession,
31
+ taskSessionMap,
32
+ }));
33
+
20
34
  beforeAll(() => {
21
35
  initDb(TEST_DB_PATH);
22
36
  });
@@ -31,11 +45,16 @@ afterAll(async () => {
31
45
  describe("Linear Outbound Sync", () => {
32
46
  beforeEach(() => {
33
47
  mockCreateComment.mockClear();
48
+ mockPostAgentSessionThought.mockClear();
49
+ mockPostAgentSessionAction.mockClear();
50
+ mockEndAgentSession.mockClear();
51
+ taskSessionMap.clear();
34
52
  initLinearOutboundSync();
35
53
  });
36
54
 
37
55
  afterEach(() => {
38
56
  teardownLinearOutboundSync();
57
+ taskSessionMap.clear();
39
58
  });
40
59
 
41
60
  test("task.completed posts comment to Linear when mapping exists", async () => {
@@ -177,6 +196,96 @@ describe("Linear Outbound Sync", () => {
177
196
  expect(mockCreateComment).toHaveBeenCalledTimes(1);
178
197
  });
179
198
 
199
+ test("task.progress posts an action activity with both action AND parameter when sessionId is mapped", async () => {
200
+ const taskId = "outbound-task-progress";
201
+ taskSessionMap.set(taskId, "linear-session-123");
202
+
203
+ workflowEventBus.emit("task.progress", {
204
+ taskId,
205
+ progress: "📋 Reviewing task details",
206
+ });
207
+
208
+ await new Promise((resolve) => setTimeout(resolve, 10));
209
+
210
+ // Posts as `action` so the update renders as a structured card in Linear's AgentSession
211
+ // panel. Linear's spec requires BOTH `action` AND `parameter` for action-type activities;
212
+ // the original bug was calling postAgentSessionAction with only a single string (parameter
213
+ // undefined), which Linear silently rejected.
214
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
215
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
216
+
217
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
218
+ expect(args[0]).toBe("linear-session-123");
219
+ // Both action label and parameter must be present and non-empty
220
+ expect(typeof args[1]).toBe("string");
221
+ expect((args[1] as string).length).toBeGreaterThan(0);
222
+ expect(typeof args[2]).toBe("string");
223
+ expect((args[2] as string).length).toBeGreaterThan(0);
224
+ // Parameter carries the actual progress text
225
+ expect(args[2] as string).toBe("📋 Reviewing task details");
226
+ });
227
+
228
+ test("task.progress slices long progress strings into the parameter (cap at 2000)", async () => {
229
+ const taskId = "outbound-task-progress-long";
230
+ taskSessionMap.set(taskId, "linear-session-long");
231
+
232
+ const longProgress = "x".repeat(5000);
233
+ workflowEventBus.emit("task.progress", { taskId, progress: longProgress });
234
+
235
+ await new Promise((resolve) => setTimeout(resolve, 10));
236
+
237
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
238
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
239
+ expect((args[2] as string).length).toBe(2000);
240
+ });
241
+
242
+ test("task.progress is a no-op when no sessionId is mapped for the task", async () => {
243
+ workflowEventBus.emit("task.progress", {
244
+ taskId: "outbound-task-progress-no-session",
245
+ progress: "should be dropped",
246
+ });
247
+
248
+ await new Promise((resolve) => setTimeout(resolve, 10));
249
+
250
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
251
+ expect(mockPostAgentSessionAction).not.toHaveBeenCalled();
252
+ });
253
+
254
+ test("task.progress is a no-op when progress string is missing", async () => {
255
+ taskSessionMap.set("outbound-task-progress-empty", "linear-session-empty");
256
+
257
+ workflowEventBus.emit("task.progress", {
258
+ taskId: "outbound-task-progress-empty",
259
+ });
260
+
261
+ await new Promise((resolve) => setTimeout(resolve, 10));
262
+
263
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
264
+ expect(mockPostAgentSessionAction).not.toHaveBeenCalled();
265
+ });
266
+
267
+ test("task.created for Linear-sourced tasks still posts an action activity (with parameter)", async () => {
268
+ const taskId = "outbound-task-created-linear";
269
+ taskSessionMap.set(taskId, "linear-session-created");
270
+
271
+ workflowEventBus.emit("task.created", {
272
+ taskId,
273
+ source: "linear",
274
+ });
275
+
276
+ await new Promise((resolve) => setTimeout(resolve, 10));
277
+
278
+ expect(mockPostAgentSessionAction).toHaveBeenCalledTimes(1);
279
+ expect(mockPostAgentSessionThought).not.toHaveBeenCalled();
280
+
281
+ const args = mockPostAgentSessionAction.mock.calls[0] as unknown[];
282
+ expect(args[0]).toBe("linear-session-created");
283
+ expect(args[1]).toBe("Processing");
284
+ // parameter (3rd positional arg) must be present for `action` activities to be valid
285
+ expect(typeof args[2]).toBe("string");
286
+ expect(args[2] as string).toContain(taskId);
287
+ });
288
+
180
289
  test("teardown removes event listeners", async () => {
181
290
  teardownLinearOutboundSync();
182
291
 
@@ -0,0 +1,69 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb } from "../be/db";
4
+ import { createServer } from "../server";
5
+
6
+ const TEST_DB_PATH = "./test-mcp-tools.sqlite";
7
+
8
+ type RegisteredTool = {
9
+ title?: string;
10
+ description?: string;
11
+ inputSchema?: unknown;
12
+ outputSchema?: unknown;
13
+ annotations?: Record<string, unknown>;
14
+ };
15
+
16
+ async function removeDbFiles(path: string): Promise<void> {
17
+ for (const suffix of ["", "-wal", "-shm"]) {
18
+ try {
19
+ await unlink(path + suffix);
20
+ } catch (error) {
21
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
22
+ }
23
+ }
24
+ }
25
+
26
+ describe("script MCP tools", () => {
27
+ let tools: Record<string, RegisteredTool>;
28
+ let savedDatabasePath: string | undefined;
29
+
30
+ beforeAll(async () => {
31
+ savedDatabasePath = process.env.DATABASE_PATH;
32
+ process.env.DATABASE_PATH = TEST_DB_PATH;
33
+ await removeDbFiles(TEST_DB_PATH);
34
+ const server = createServer();
35
+ tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
36
+ ._registeredTools;
37
+ });
38
+
39
+ afterAll(async () => {
40
+ closeDb();
41
+ if (savedDatabasePath === undefined) delete process.env.DATABASE_PATH;
42
+ else process.env.DATABASE_PATH = savedDatabasePath;
43
+ await removeDbFiles(TEST_DB_PATH);
44
+ });
45
+
46
+ test("registers all script tools with schemas and documented descriptions", () => {
47
+ const expected = {
48
+ "script-search":
49
+ "Semantic search over swarm-shared TypeScript scripts (catalog persisted in the agent-swarm DB; callable from agents and workflows). For ephemeral throwaway TS on your local machine, use code-mode instead.",
50
+ "script-run":
51
+ "Run a named swarm-shared script (callable across agents and from workflow `swarm-script` nodes), OR inline source (auto-saved as scratch to the catalog). Use for swarm-visible, durable scripts. For local-only throwaway TS, use code-mode `run`.",
52
+ "script-upsert":
53
+ "Persist a TypeScript script to the swarm catalog under your agent scope (or global if you're a lead). Other agents and workflow nodes will be able to find and run it. For local-only scripts, use code-mode `save`.",
54
+ "script-delete":
55
+ "Remove a swarm-shared script from the catalog. Versions table preserves history.",
56
+ "script-query-types":
57
+ "Fetch the signature + the auto-generated `swarm-sdk.d.ts` (derived from the live MCP tool registry) + the `stdlib.d.ts` blobs — for IDE-style introspection before authoring or running a script. The same types are used by `script-upsert`'s typecheck pass, so they are authoritative.",
58
+ };
59
+
60
+ for (const [name, description] of Object.entries(expected)) {
61
+ expect(tools[name]).toBeDefined();
62
+ expect(tools[name].title).toBeTruthy();
63
+ expect(tools[name].description).toBe(description);
64
+ expect(tools[name].inputSchema).toBeTruthy();
65
+ expect(tools[name].outputSchema).toBeTruthy();
66
+ expect(tools[name].annotations).toBeTruthy();
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,292 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SessionErrorTracker, trackErrorFromJson } from "../utils/error-tracker";
3
+
4
+ // Verbatim fixture from Linear CAI-1279 (session logs for task b7fbbdb9-4922-41d9-88ec-21febd6c4fec)
5
+ const FIXTURE_REJECTED = {
6
+ type: "rate_limit_event",
7
+ rate_limit_info: {
8
+ status: "rejected",
9
+ resetsAt: 1779202200, // seconds since epoch — 2026-05-19T14:50:00Z
10
+ rateLimitType: "five_hour",
11
+ overageStatus: "rejected",
12
+ overageDisabledReason: "group_zero_credit_limit",
13
+ isUsingOverage: false,
14
+ },
15
+ uuid: "ff6e5299-429c-4fcb-ab34-0ce4e8fa6202",
16
+ session_id: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
17
+ };
18
+
19
+ describe("SessionErrorTracker — rate_limit_event processing", () => {
20
+ test("stashes resetsAt (seconds) correctly as ms — verbatim CAI-1279 fixture", () => {
21
+ const tracker = new SessionErrorTracker();
22
+ tracker.processRateLimitEvent(FIXTURE_REJECTED);
23
+
24
+ const result = tracker.getRateLimitResetAt();
25
+ expect(result).toBeDefined();
26
+
27
+ // 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,
29
+ // the value will be clamped to now+60s. What matters is the sec→ms conversion works.
30
+ // We verify the unit is correct by checking that 1779202200 * 1000 = ms,
31
+ // which is NOT the same as treating it as ms (would be 1970-01-21).
32
+ const parsedMs = new Date(result!).getTime();
33
+ const nowMs = Date.now();
34
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 59_000); // clamped to at least now+60s
35
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 7 * 60 * 60 * 1000); // not absurdly far
36
+ });
37
+
38
+ test("resetsAt treated as seconds, not milliseconds (unit conversion boundary)", () => {
39
+ const tracker = new SessionErrorTracker();
40
+ // A future resetsAt value (in seconds) — 1 hour from now
41
+ const oneHourFromNowSec = Math.floor(Date.now() / 1000) + 3600;
42
+ tracker.processRateLimitEvent({
43
+ type: "rate_limit_event",
44
+ rate_limit_info: {
45
+ status: "rejected",
46
+ resetsAt: oneHourFromNowSec,
47
+ },
48
+ });
49
+
50
+ const result = tracker.getRateLimitResetAt();
51
+ expect(result).toBeDefined();
52
+
53
+ const parsedMs = new Date(result!).getTime();
54
+ const nowMs = Date.now();
55
+ // Should be ~1h from now (not 1970 if treated as ms, not year 57,000 if multiplied wrong)
56
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 50 * 60_000); // at least 50 min from now
57
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 70 * 60_000); // at most 70 min from now
58
+ });
59
+
60
+ test("status: rejected → stashes resetsAt", () => {
61
+ const tracker = new SessionErrorTracker();
62
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
63
+ tracker.processRateLimitEvent({
64
+ type: "rate_limit_event",
65
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
66
+ });
67
+ expect(tracker.getRateLimitResetAt()).toBeDefined();
68
+ });
69
+
70
+ test("status: allowed → does NOT stash (no cooldown needed)", () => {
71
+ const tracker = new SessionErrorTracker();
72
+ tracker.processRateLimitEvent({
73
+ type: "rate_limit_event",
74
+ rate_limit_info: { status: "allowed", resetsAt: 1779202200 },
75
+ });
76
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
77
+ });
78
+
79
+ test("status: allowed_warning → does NOT stash", () => {
80
+ const tracker = new SessionErrorTracker();
81
+ tracker.processRateLimitEvent({
82
+ type: "rate_limit_event",
83
+ rate_limit_info: { status: "allowed_warning", resetsAt: 1779202200 },
84
+ });
85
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
86
+ });
87
+
88
+ test("malformed event (missing rate_limit_info) → does NOT stash, no throw", () => {
89
+ const tracker = new SessionErrorTracker();
90
+ tracker.processRateLimitEvent({ type: "rate_limit_event" });
91
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
92
+ });
93
+
94
+ test("malformed event (resetsAt is string) → does NOT stash, no throw", () => {
95
+ const tracker = new SessionErrorTracker();
96
+ tracker.processRateLimitEvent({
97
+ type: "rate_limit_event",
98
+ rate_limit_info: { status: "rejected", resetsAt: "not-a-number" },
99
+ });
100
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
101
+ });
102
+
103
+ test("malformed event (resetsAt is negative) → does NOT stash", () => {
104
+ const tracker = new SessionErrorTracker();
105
+ tracker.processRateLimitEvent({
106
+ type: "rate_limit_event",
107
+ rate_limit_info: { status: "rejected", resetsAt: -1 },
108
+ });
109
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
110
+ });
111
+
112
+ test("resetsAt already in the past → clamped to now+60s (clock skew defense)", () => {
113
+ const tracker = new SessionErrorTracker();
114
+ // Use a known-past timestamp (year 2020)
115
+ tracker.processRateLimitEvent({
116
+ type: "rate_limit_event",
117
+ rate_limit_info: { status: "rejected", resetsAt: 1577836800 }, // 2020-01-01T00:00:00Z
118
+ });
119
+
120
+ const result = tracker.getRateLimitResetAt();
121
+ expect(result).toBeDefined();
122
+ const parsedMs = new Date(result!).getTime();
123
+ const nowMs = Date.now();
124
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 59_000);
125
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 65_000);
126
+ });
127
+
128
+ test("resetsAt absurdly far in future → clamped to now+6h (malformed defense)", () => {
129
+ const tracker = new SessionErrorTracker();
130
+ // Year 2099 in seconds
131
+ tracker.processRateLimitEvent({
132
+ type: "rate_limit_event",
133
+ rate_limit_info: { status: "rejected", resetsAt: 4102444800 }, // 2100-01-01 in seconds
134
+ });
135
+
136
+ const result = tracker.getRateLimitResetAt();
137
+ expect(result).toBeDefined();
138
+ const parsedMs = new Date(result!).getTime();
139
+ const nowMs = Date.now();
140
+ const sixHoursMs = 6 * 60 * 60 * 1000;
141
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + sixHoursMs + 1000); // within 6h (+1s tolerance)
142
+ });
143
+
144
+ test("multiple rate_limit_event lines → last rejected one wins", () => {
145
+ const tracker = new SessionErrorTracker();
146
+ const firstResetsAtSec = Math.floor(Date.now() / 1000) + 1800; // 30 min from now
147
+ const secondResetsAtSec = Math.floor(Date.now() / 1000) + 3600; // 60 min from now
148
+
149
+ tracker.processRateLimitEvent({
150
+ type: "rate_limit_event",
151
+ rate_limit_info: { status: "rejected", resetsAt: firstResetsAtSec },
152
+ });
153
+ tracker.processRateLimitEvent({
154
+ type: "rate_limit_event",
155
+ rate_limit_info: { status: "rejected", resetsAt: secondResetsAtSec },
156
+ });
157
+
158
+ const result = tracker.getRateLimitResetAt();
159
+ expect(result).toBeDefined();
160
+ const parsedMs = new Date(result!).getTime();
161
+ const nowMs = Date.now();
162
+ // Should reflect the SECOND event (~60 min), not the first (~30 min)
163
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 55 * 60_000);
164
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 65 * 60_000);
165
+ });
166
+
167
+ test("allowed event between two rejected events → last rejected wins", () => {
168
+ const tracker = new SessionErrorTracker();
169
+ const firstSec = Math.floor(Date.now() / 1000) + 1800;
170
+ const secondSec = Math.floor(Date.now() / 1000) + 3600;
171
+
172
+ tracker.processRateLimitEvent({
173
+ type: "rate_limit_event",
174
+ rate_limit_info: { status: "rejected", resetsAt: firstSec },
175
+ });
176
+ tracker.processRateLimitEvent({
177
+ type: "rate_limit_event",
178
+ rate_limit_info: { status: "allowed", resetsAt: 9999999999 }, // should be ignored
179
+ });
180
+ tracker.processRateLimitEvent({
181
+ type: "rate_limit_event",
182
+ rate_limit_info: { status: "rejected", resetsAt: secondSec },
183
+ });
184
+
185
+ const result = tracker.getRateLimitResetAt();
186
+ expect(result).toBeDefined();
187
+ const parsedMs = new Date(result!).getTime();
188
+ const nowMs = Date.now();
189
+ // Should reflect the third (second rejected) event (~60 min)
190
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 55 * 60_000);
191
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 65 * 60_000);
192
+ });
193
+
194
+ test("no rate_limit_event at all → getRateLimitResetAt returns undefined", () => {
195
+ const tracker = new SessionErrorTracker();
196
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
197
+ });
198
+ });
199
+
200
+ describe("trackErrorFromJson — rate_limit_event routing", () => {
201
+ test("routes rate_limit_event to processRateLimitEvent, stashes reset time", () => {
202
+ const tracker = new SessionErrorTracker();
203
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
204
+
205
+ trackErrorFromJson(
206
+ {
207
+ type: "rate_limit_event",
208
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
209
+ },
210
+ tracker,
211
+ );
212
+
213
+ expect(tracker.getRateLimitResetAt()).toBeDefined();
214
+ // rate_limit_event itself is NOT an error signal — it's informational
215
+ expect(tracker.hasErrors()).toBe(false);
216
+ });
217
+
218
+ test("rate_limit_event with allowed status → no reset stashed, no errors", () => {
219
+ const tracker = new SessionErrorTracker();
220
+ trackErrorFromJson(
221
+ {
222
+ type: "rate_limit_event",
223
+ rate_limit_info: { status: "allowed", resetsAt: 1779202200 },
224
+ },
225
+ tracker,
226
+ );
227
+
228
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
229
+ expect(tracker.hasErrors()).toBe(false);
230
+ });
231
+
232
+ test("rate_limit_event does not block subsequent event processing", () => {
233
+ const tracker = new SessionErrorTracker();
234
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
235
+
236
+ trackErrorFromJson(
237
+ {
238
+ type: "rate_limit_event",
239
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
240
+ },
241
+ tracker,
242
+ );
243
+ trackErrorFromJson(
244
+ { type: "result", is_error: true, result: "Your group's usage limit is set to $0" },
245
+ tracker,
246
+ );
247
+
248
+ expect(tracker.getRateLimitResetAt()).toBeDefined();
249
+ expect(tracker.hasErrors()).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe("three-tier resolver logic (unit test via clamp helper)", () => {
254
+ // Mirrors the clampResetTime inline helper in runner.ts
255
+ function clampResetTime(isoString: string): string {
256
+ const nowMs = Date.now();
257
+ const minMs = nowMs + 60_000;
258
+ const maxMs = nowMs + 6 * 60 * 60 * 1000;
259
+ const candidateMs = new Date(isoString).getTime();
260
+ return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
261
+ }
262
+
263
+ test("tier 1: rateLimitResetAt from structured event → used directly (after clamp)", () => {
264
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
265
+ const tracker = new SessionErrorTracker();
266
+ tracker.processRateLimitEvent({
267
+ type: "rate_limit_event",
268
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
269
+ });
270
+
271
+ const rateLimitResetAt = tracker.getRateLimitResetAt();
272
+ expect(rateLimitResetAt).toBeDefined();
273
+
274
+ // Simulate tier-1 branch: result.rateLimitResetAt is set
275
+ const rateLimitedUntil = clampResetTime(rateLimitResetAt!);
276
+ expect(rateLimitedUntil).toBeDefined();
277
+ const resolvedMs = new Date(rateLimitedUntil).getTime();
278
+ const nowMs = Date.now();
279
+ expect(resolvedMs).toBeGreaterThanOrEqual(nowMs + 59_000);
280
+ });
281
+
282
+ test("tier 3 fallback: no structured event, no parseable message → 5-min default", () => {
283
+ // Simulate: rateLimitResetAt is undefined, parseRateLimitResetTime returns undefined
284
+ const defaultCooldownMs = 5 * 60 * 1000;
285
+ const rateLimitedUntil = new Date(Date.now() + defaultCooldownMs).toISOString();
286
+
287
+ const resolvedMs = new Date(rateLimitedUntil).getTime();
288
+ const nowMs = Date.now();
289
+ expect(resolvedMs).toBeGreaterThanOrEqual(nowMs + 4 * 60_000);
290
+ expect(resolvedMs).toBeLessThanOrEqual(nowMs + 6 * 60_000);
291
+ });
292
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { inspect } from "node:util";
3
+ import { Redacted } from "../scripts-runtime/redacted";
4
+
5
+ describe("Redacted", () => {
6
+ test("stringification surfaces are redacted", () => {
7
+ const secret = Redacted.make("hunter2", { type: "user", isSecret: true });
8
+ expect(String(secret)).toBe("<redacted>");
9
+ expect(JSON.stringify({ secret })).toBe('{"secret":"<redacted>"}');
10
+ expect(inspect(secret)).toContain("<redacted>");
11
+ expect(inspect(secret)).not.toContain("hunter2");
12
+ });
13
+
14
+ test("value round-trips the original value", () => {
15
+ const value = { nested: true };
16
+ const wrapped = Redacted.make(value);
17
+ expect(Redacted.value(wrapped)).toBe(value);
18
+ });
19
+
20
+ test("meta returns the stored metadata", () => {
21
+ const wrapped = Redacted.make("abc", { type: "system", isSecret: false });
22
+ expect(Redacted.meta(wrapped)).toEqual({ type: "system", isSecret: false });
23
+ expect(Redacted.isSecret(wrapped)).toBe(false);
24
+ });
25
+
26
+ test("unregistered objects throw", () => {
27
+ expect(() => Redacted.value({} as never)).toThrow("Redacted value was not in registry");
28
+ });
29
+ });