talon-agent 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -1,7 +1,18 @@
1
- import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeAll,
6
+ afterAll,
7
+ beforeEach,
8
+ vi,
9
+ } from "vitest";
2
10
 
3
11
  vi.mock("../util/log.js", () => ({
4
- log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
12
+ log: vi.fn(),
13
+ logError: vi.fn(),
14
+ logWarn: vi.fn(),
15
+ logDebug: vi.fn(),
5
16
  }));
6
17
 
7
18
  vi.mock("../core/plugin.js", () => ({
@@ -10,7 +21,10 @@ vi.mock("../core/plugin.js", () => ({
10
21
 
11
22
  vi.mock("../util/watchdog.js", () => ({
12
23
  getHealthStatus: vi.fn(() => ({
13
- healthy: true, totalMessagesProcessed: 0, recentErrorCount: 0, msSinceLastMessage: 0,
24
+ healthy: true,
25
+ totalMessagesProcessed: 0,
26
+ recentErrorCount: 0,
27
+ msSinceLastMessage: 0,
14
28
  })),
15
29
  }));
16
30
 
@@ -29,7 +43,10 @@ vi.mock("../storage/cron-store.js", () => ({
29
43
  getCronJobsForChat: vi.fn(() => []),
30
44
  updateCronJob: vi.fn(),
31
45
  deleteCronJob: vi.fn(),
32
- validateCronExpression: vi.fn(() => ({ valid: true, next: new Date().toISOString() })),
46
+ validateCronExpression: vi.fn(() => ({
47
+ valid: true,
48
+ next: new Date().toISOString(),
49
+ })),
33
50
  generateCronId: vi.fn(() => "test-id"),
34
51
  loadCronJobs: vi.fn(),
35
52
  }));
@@ -46,7 +63,8 @@ let port: number;
46
63
  // Mock frontend handler
47
64
  const mockFrontendHandler = vi.fn(async (body: Record<string, unknown>) => {
48
65
  const action = body.action as string;
49
- if (action === "send_message") return { ok: true, message_id: 42, text: "sent" };
66
+ if (action === "send_message")
67
+ return { ok: true, message_id: 42, text: "sent" };
50
68
  if (action === "get_chat_info") return { ok: true, id: 123, type: "private" };
51
69
  return null;
52
70
  });
@@ -70,13 +88,18 @@ beforeEach(() => {
70
88
  mockFrontendHandler.mockClear();
71
89
  });
72
90
 
73
- async function post(body: Record<string, unknown>): Promise<{ status: number; body: Record<string, unknown> }> {
91
+ async function post(
92
+ body: Record<string, unknown>,
93
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
74
94
  const resp = await fetch(`http://127.0.0.1:${port}/action`, {
75
95
  method: "POST",
76
96
  headers: { "Content-Type": "application/json" },
77
97
  body: JSON.stringify(body),
78
98
  });
79
- return { status: resp.status, body: await resp.json() as Record<string, unknown> };
99
+ return {
100
+ status: resp.status,
101
+ body: (await resp.json()) as Record<string, unknown>,
102
+ };
80
103
  }
81
104
 
82
105
  describe("gateway HTTP server", () => {
@@ -84,7 +107,7 @@ describe("gateway HTTP server", () => {
84
107
  it("returns health JSON", async () => {
85
108
  const resp = await fetch(`http://127.0.0.1:${port}/health`);
86
109
  expect(resp.status).toBe(200);
87
- const data = await resp.json() as Record<string, unknown>;
110
+ const data = (await resp.json()) as Record<string, unknown>;
88
111
  expect(data.ok).toBeDefined();
89
112
  expect(data.uptime).toBeDefined();
90
113
  expect(data.memory).toBeDefined();
@@ -111,7 +134,7 @@ describe("gateway HTTP server", () => {
111
134
  body: "not valid json{{{",
112
135
  });
113
136
  expect(resp.status).toBe(400);
114
- const data = await resp.json() as Record<string, unknown>;
137
+ const data = (await resp.json()) as Record<string, unknown>;
115
138
  expect(data.ok).toBe(false);
116
139
  expect(data.error).toContain("Invalid JSON");
117
140
  });
@@ -133,7 +156,11 @@ describe("gateway HTTP server", () => {
133
156
 
134
157
  it("routes to frontend handler", async () => {
135
158
  gateway.setContext(123);
136
- const { body } = await post({ action: "send_message", _chatId: "123", text: "hello" });
159
+ const { body } = await post({
160
+ action: "send_message",
161
+ _chatId: "123",
162
+ text: "hello",
163
+ });
137
164
  expect(body.ok).toBe(true);
138
165
  expect(body.message_id).toBe(42);
139
166
  expect(mockFrontendHandler).toHaveBeenCalled();
@@ -142,7 +169,10 @@ describe("gateway HTTP server", () => {
142
169
 
143
170
  it("returns error for unknown action", async () => {
144
171
  gateway.setContext(123);
145
- const { body } = await post({ action: "completely_unknown_action", _chatId: "123" });
172
+ const { body } = await post({
173
+ action: "completely_unknown_action",
174
+ _chatId: "123",
175
+ });
146
176
  expect(body.ok).toBe(false);
147
177
  expect(body.error).toContain("Unknown action");
148
178
  gateway.clearContext(123);
@@ -184,21 +214,35 @@ describe("gateway HTTP server", () => {
184
214
  it("one chat's context doesn't affect another", async () => {
185
215
  gateway.setContext(300);
186
216
  // Chat 400 has no context
187
- const { body } = await post({ action: "send_message", _chatId: "400", text: "no context" });
217
+ const { body } = await post({
218
+ action: "send_message",
219
+ _chatId: "400",
220
+ text: "no context",
221
+ });
188
222
  expect(body.ok).toBe(false);
189
223
  expect(body.error).toContain("No active chat context");
190
224
  // Chat 300 still works
191
- const r2 = await post({ action: "send_message", _chatId: "300", text: "still works" });
225
+ const r2 = await post({
226
+ action: "send_message",
227
+ _chatId: "300",
228
+ text: "still works",
229
+ });
192
230
  expect(r2.body.ok).toBe(true);
193
231
  gateway.clearContext(300);
194
232
  });
195
233
 
196
234
  it("frontend handler error returns error result (doesn't crash server)", async () => {
197
- const errorHandler = vi.fn(async () => { throw new Error("handler exploded"); });
235
+ const errorHandler = vi.fn(async () => {
236
+ throw new Error("handler exploded");
237
+ });
198
238
  gateway.setFrontendHandler(errorHandler);
199
239
  gateway.setContext(123);
200
240
 
201
- const { status, body } = await post({ action: "send_message", _chatId: "123", text: "boom" });
241
+ const { status, body } = await post({
242
+ action: "send_message",
243
+ _chatId: "123",
244
+ text: "boom",
245
+ });
202
246
 
203
247
  expect(status).toBe(200); // HTTP 200, error in body
204
248
  expect(body.ok).toBe(false);
@@ -210,17 +254,48 @@ describe("gateway HTTP server", () => {
210
254
 
211
255
  it("request without _chatId is rejected", async () => {
212
256
  gateway.setContext(123);
213
- const { body } = await post({ action: "send_message", text: "no chatId" });
257
+ const { body } = await post({
258
+ action: "send_message",
259
+ text: "no chatId",
260
+ });
214
261
  expect(body.ok).toBe(false);
215
262
  expect(body.error).toContain("No active chat context");
216
263
  gateway.clearContext(123);
217
264
  });
265
+
266
+ it("returns 500 when handleAction result cannot be JSON-serialized", async () => {
267
+ // Make frontendHandler return a circular reference so JSON.stringify throws
268
+ const circular: Record<string, unknown> = {};
269
+ circular["self"] = circular;
270
+ const circularHandler = vi.fn(
271
+ async () => circular,
272
+ ) as unknown as import("../core/types.js").FrontendActionHandler;
273
+ gateway.setFrontendHandler(circularHandler);
274
+ gateway.setContext(123);
275
+
276
+ const { status, body } = await post({
277
+ action: "send_message",
278
+ _chatId: "123",
279
+ text: "boom",
280
+ });
281
+
282
+ expect(status).toBe(500);
283
+ expect(body.ok).toBe(false);
284
+ expect(body.error).toBeTruthy();
285
+
286
+ gateway.clearContext(123);
287
+ gateway.setFrontendHandler(mockFrontendHandler); // restore
288
+ });
218
289
  });
219
290
 
220
291
  describe("shared actions via HTTP", () => {
221
292
  it("fetch_url rejects invalid URLs", async () => {
222
293
  gateway.setContext(123);
223
- const { body } = await post({ action: "fetch_url", _chatId: "123", url: "not-a-url" });
294
+ const { body } = await post({
295
+ action: "fetch_url",
296
+ _chatId: "123",
297
+ url: "not-a-url",
298
+ });
224
299
  expect(body.ok).toBe(false);
225
300
  gateway.clearContext(123);
226
301
  });
@@ -235,11 +310,127 @@ describe("gateway HTTP server", () => {
235
310
  it("create_cron_job works", async () => {
236
311
  gateway.setContext(123);
237
312
  const { body } = await post({
238
- action: "create_cron_job", _chatId: "123",
239
- name: "test", schedule: "0 9 * * *", type: "message", content: "hello",
313
+ action: "create_cron_job",
314
+ _chatId: "123",
315
+ name: "test",
316
+ schedule: "0 9 * * *",
317
+ type: "message",
318
+ content: "hello",
240
319
  });
241
320
  expect(body.ok).toBe(true);
242
321
  gateway.clearContext(123);
243
322
  });
244
323
  });
245
324
  });
325
+
326
+ // ── Plugin action route coverage ─────────────────────────────────────────────
327
+
328
+ describe("gateway routes to plugin handler", () => {
329
+ it("returns plugin result when handlePluginAction returns non-null", async () => {
330
+ const { handlePluginAction } = await import("../core/plugin.js");
331
+ (handlePluginAction as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
332
+ ok: true,
333
+ result: "from plugin",
334
+ });
335
+
336
+ gateway.setContext(123);
337
+ const { body } = await post({
338
+ action: "plugin_specific_action",
339
+ _chatId: "123",
340
+ });
341
+ expect(body.ok).toBe(true);
342
+ expect(body.result).toBe("from plugin");
343
+ gateway.clearContext(123);
344
+
345
+ // Restore to always-null for subsequent tests
346
+ (handlePluginAction as ReturnType<typeof vi.fn>).mockResolvedValue(null);
347
+ });
348
+ });
349
+
350
+ // ── Additional branch coverage ────────────────────────────────────────────
351
+
352
+ describe("gateway — no frontend handler falls through to shared actions (line 162 FALSE branch)", () => {
353
+ it("routes to shared action when frontendHandler is null", async () => {
354
+ // Clear frontend handler — FALSE branch of `if (this.frontendHandler)`
355
+ gateway.setFrontendHandler(null);
356
+ gateway.setContext(123);
357
+
358
+ // read_history is a shared action that should still work
359
+ const { body } = await post({ action: "read_history", _chatId: "123" });
360
+ expect(body.ok).toBe(true);
361
+
362
+ gateway.clearContext(123);
363
+ gateway.setFrontendHandler(mockFrontendHandler); // restore
364
+ });
365
+ });
366
+
367
+ describe("gateway — non-Error thrown in handleAction catch (line 186 FALSE branch)", () => {
368
+ it("returns error with String(err) when handler throws a non-Error", async () => {
369
+ const stringThrowHandler = vi.fn(async () => {
370
+ throw "plain string gateway error";
371
+ });
372
+ gateway.setFrontendHandler(stringThrowHandler);
373
+ gateway.setContext(123);
374
+
375
+ const { body } = await post({
376
+ action: "send_message",
377
+ _chatId: "123",
378
+ text: "test",
379
+ });
380
+ expect(body.ok).toBe(false);
381
+ expect(String(body.error)).toContain("plain string gateway error");
382
+
383
+ gateway.clearContext(123);
384
+ gateway.setFrontendHandler(mockFrontendHandler); // restore
385
+ });
386
+ });
387
+
388
+ describe("gateway — start() called twice returns same port (line 195 TRUE branch)", () => {
389
+ it("returns port without restarting when already running", async () => {
390
+ // gateway is already started — calling start() again should return same port immediately
391
+ const secondPort = await gateway.start();
392
+ expect(secondPort).toBe(port);
393
+ });
394
+ });
395
+
396
+ describe("gateway health endpoint — old activity shows minutes ago (line 211 FALSE branch)", () => {
397
+ it("shows 'Xm ago' when msSinceLastMessage >= 60000", async () => {
398
+ const { getHealthStatus } = await import("../util/watchdog.js");
399
+ (getHealthStatus as ReturnType<typeof vi.fn>).mockReturnValueOnce({
400
+ healthy: true,
401
+ totalMessagesProcessed: 5,
402
+ recentErrorCount: 0,
403
+ msSinceLastMessage: 5 * 60_000, // 5 minutes ago → > 60000
404
+ });
405
+
406
+ const resp = await fetch(`http://127.0.0.1:${port}/health`);
407
+ const data = (await resp.json()) as Record<string, unknown>;
408
+ expect(data.lastActivity).toMatch(/m ago$/);
409
+ });
410
+ });
411
+
412
+ // ── Port retry (EADDRINUSE) ───────────────────────────────────────────────
413
+
414
+ describe("gateway port retry — EADDRINUSE", () => {
415
+ it("rejects when all retry attempts are exhausted (6 consecutive ports in use)", async () => {
416
+ // The retry logic allows attempt 0-4 (5 retries), trying ports p, p+1, …, p+5
417
+ // Block all 6 consecutive ports so every attempt fails → should reject
418
+ const blockers: Array<import("node:http").Server> = [];
419
+ for (let p = 19950; p <= 19955; p++) {
420
+ const { createServer } = await import("node:http");
421
+ const s = createServer();
422
+ await new Promise<void>((resolve) => s.listen(p, "127.0.0.1", resolve));
423
+ blockers.push(s);
424
+ }
425
+
426
+ try {
427
+ const gw = new Gateway();
428
+ gw.setFrontendHandler(async () => null);
429
+ await expect(gw.start(19950)).rejects.toThrow();
430
+ } finally {
431
+ for (const s of blockers) {
432
+ await new Promise<void>((resolve) => s.close(() => resolve()));
433
+ }
434
+ }
435
+ });
436
+ });
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Tests for withRetry() exported from src/core/gateway.ts
3
+ *
4
+ * withRetry wraps p-retry. We mock p-retry so that:
5
+ * - The mock immediately drives the retry loop deterministically (no real
6
+ * delays), and
7
+ * - We can observe exactly how many times the inner function was called.
8
+ *
9
+ * The contract under test:
10
+ * 1. Success on first attempt — fn called once, result returned.
11
+ * 2. Retryable errors (network, overloaded, rate_limit) — fn retried up to
12
+ * 3 total attempts; result returned on the first successful call.
13
+ * 3. Non-retryable errors (auth, bad_request, context_length, forbidden,
14
+ * unknown) — fn called once and the error is re-thrown immediately.
15
+ * 4. All retries exhausted — the final error is thrown after 3 attempts.
16
+ * 5. TalonError.retryAfterMs is plumbed through classify.
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach } from "vitest";
20
+
21
+ // ── Mocks (before any dynamic import) ─────────────────────────────────────
22
+
23
+ vi.mock("../util/log.js", () => ({
24
+ log: vi.fn(),
25
+ logError: vi.fn(),
26
+ logWarn: vi.fn(),
27
+ logDebug: vi.fn(),
28
+ }));
29
+
30
+ vi.mock("../core/dispatcher.js", () => ({
31
+ getActiveCount: vi.fn(() => 0),
32
+ }));
33
+
34
+ vi.mock("../util/watchdog.js", () => ({
35
+ getHealthStatus: vi.fn(() => ({
36
+ healthy: true,
37
+ totalMessagesProcessed: 0,
38
+ recentErrorCount: 0,
39
+ msSinceLastMessage: 0,
40
+ })),
41
+ }));
42
+
43
+ vi.mock("../storage/sessions.js", () => ({
44
+ getActiveSessionCount: vi.fn(() => 0),
45
+ }));
46
+
47
+ vi.mock("../core/gateway-actions.js", () => ({
48
+ handleSharedAction: vi.fn(async () => null),
49
+ }));
50
+
51
+ vi.mock("../core/plugin.js", () => ({
52
+ handlePluginAction: vi.fn(async () => null),
53
+ }));
54
+
55
+ vi.mock("node:fs", () => ({
56
+ existsSync: vi.fn(() => false),
57
+ readFileSync: vi.fn(() => "{}"),
58
+ mkdirSync: vi.fn(),
59
+ writeFileSync: vi.fn(),
60
+ }));
61
+
62
+ vi.mock("write-file-atomic", () => ({
63
+ default: { sync: vi.fn() },
64
+ }));
65
+
66
+ // ── Real p-retry (we want to exercise the actual retry logic with fast
67
+ // timeouts — no mocking needed since we use very short delays).
68
+ // We DO NOT mock p-retry; instead tests that would be slow use
69
+ // non-retryable errors so they return immediately, and retryable-path
70
+ // tests are designed to succeed on a subsequent attempt.
71
+
72
+ // ── Dynamic import ─────────────────────────────────────────────────────────
73
+
74
+ const { withRetry } = await import("../core/gateway.js");
75
+ import { TalonError, classify } from "../core/errors.js";
76
+
77
+ // ── helpers ────────────────────────────────────────────────────────────────
78
+
79
+ /** Build a TalonError for the given reason. */
80
+ function talonErr(
81
+ reason:
82
+ | "network"
83
+ | "overloaded"
84
+ | "rate_limit"
85
+ | "auth"
86
+ | "bad_request"
87
+ | "context_length"
88
+ | "unknown",
89
+ retryAfterMs?: number,
90
+ ): TalonError {
91
+ const retryable = ["network", "overloaded", "rate_limit"].includes(reason);
92
+ return new TalonError(`${reason} error`, {
93
+ reason,
94
+ retryable,
95
+ retryAfterMs,
96
+ });
97
+ }
98
+
99
+ beforeEach(() => {
100
+ vi.clearAllMocks();
101
+ });
102
+
103
+ // ── Tests ─────────────────────────────────────────────────────────────────
104
+
105
+ describe("withRetry", () => {
106
+ describe("success on first attempt", () => {
107
+ it("returns the value without retrying", async () => {
108
+ const fn = vi.fn(async () => "ok");
109
+ const result = await withRetry(fn);
110
+ expect(result).toBe("ok");
111
+ expect(fn).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ it("returns non-string values correctly", async () => {
115
+ const obj = { data: 42, nested: { flag: true } };
116
+ const result = await withRetry(async () => obj);
117
+ expect(result).toBe(obj);
118
+ });
119
+
120
+ it("returns undefined correctly", async () => {
121
+ const result = await withRetry(async () => undefined);
122
+ expect(result).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ describe("non-retryable errors — abort immediately", () => {
127
+ it("throws on auth error without retrying", async () => {
128
+ const fn = vi.fn(async () => {
129
+ throw talonErr("auth");
130
+ });
131
+
132
+ await expect(withRetry(fn)).rejects.toThrow();
133
+ // Function should only be called once — no retries for non-retryable errors
134
+ expect(fn).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it("throws on bad_request error without retrying", async () => {
138
+ const fn = vi.fn(async () => {
139
+ throw talonErr("bad_request");
140
+ });
141
+
142
+ await expect(withRetry(fn)).rejects.toThrow();
143
+ expect(fn).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it("throws on context_length error without retrying", async () => {
147
+ const fn = vi.fn(async () => {
148
+ throw talonErr("context_length");
149
+ });
150
+
151
+ await expect(withRetry(fn)).rejects.toThrow();
152
+ expect(fn).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it("throws on forbidden error without retrying", async () => {
156
+ const fn = vi.fn(async () => {
157
+ throw new TalonError("403 Forbidden", {
158
+ reason: "forbidden",
159
+ retryable: false,
160
+ status: 403,
161
+ });
162
+ });
163
+
164
+ await expect(withRetry(fn)).rejects.toThrow();
165
+ expect(fn).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it("throws on unknown error without retrying", async () => {
169
+ const fn = vi.fn(async () => {
170
+ throw talonErr("unknown");
171
+ });
172
+
173
+ await expect(withRetry(fn)).rejects.toThrow();
174
+ expect(fn).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it("re-classifies raw (non-TalonError) non-retryable errors and aborts", async () => {
178
+ // classify("401 Unauthorized") → auth → non-retryable
179
+ const fn = vi.fn(async () => {
180
+ throw new Error("401 Unauthorized");
181
+ });
182
+
183
+ await expect(withRetry(fn)).rejects.toThrow();
184
+ expect(fn).toHaveBeenCalledTimes(1);
185
+ });
186
+
187
+ it("re-classifies 400 Bad Request as non-retryable", async () => {
188
+ const fn = vi.fn(async () => {
189
+ throw new Error("400 Bad Request");
190
+ });
191
+
192
+ await expect(withRetry(fn)).rejects.toThrow();
193
+ expect(fn).toHaveBeenCalledTimes(1);
194
+ });
195
+ });
196
+
197
+ describe("retryable errors — retries and eventually succeeds", () => {
198
+ it("succeeds on second attempt after a network error", async () => {
199
+ let calls = 0;
200
+ const fn = vi.fn(async () => {
201
+ calls++;
202
+ if (calls === 1) {
203
+ throw talonErr("network");
204
+ }
205
+ return "success";
206
+ });
207
+
208
+ const result = await withRetry(fn);
209
+ expect(result).toBe("success");
210
+ expect(fn).toHaveBeenCalledTimes(2);
211
+ });
212
+
213
+ it("succeeds on third attempt after two overloaded errors", async () => {
214
+ let calls = 0;
215
+ const fn = vi.fn(async () => {
216
+ calls++;
217
+ if (calls < 3) {
218
+ throw talonErr("overloaded");
219
+ }
220
+ return "eventually ok";
221
+ });
222
+
223
+ const result = await withRetry(fn);
224
+ expect(result).toBe("eventually ok");
225
+ expect(fn).toHaveBeenCalledTimes(3);
226
+ });
227
+
228
+ it("succeeds on second attempt after a rate_limit error", async () => {
229
+ let calls = 0;
230
+ const fn = vi.fn(async () => {
231
+ calls++;
232
+ if (calls === 1) {
233
+ // Small retryAfterMs so the test runs fast
234
+ throw talonErr("rate_limit", 1);
235
+ }
236
+ return "rate limit passed";
237
+ });
238
+
239
+ const result = await withRetry(fn);
240
+ expect(result).toBe("rate limit passed");
241
+ expect(fn).toHaveBeenCalledTimes(2);
242
+ });
243
+ });
244
+
245
+ describe("exhausting all retries", () => {
246
+ it("throws the last error after 3 total attempts for retryable errors", async () => {
247
+ let calls = 0;
248
+ const networkErr = talonErr("network");
249
+ const fn = vi.fn(async () => {
250
+ calls++;
251
+ throw networkErr;
252
+ });
253
+
254
+ await expect(withRetry(fn)).rejects.toThrow();
255
+ // withRetry is configured with retries: 2 (3 total attempts)
256
+ expect(fn).toHaveBeenCalledTimes(3);
257
+ });
258
+
259
+ it("throws the last error after 3 total attempts for overloaded errors", async () => {
260
+ let calls = 0;
261
+ const fn = vi.fn(async () => {
262
+ calls++;
263
+ throw talonErr("overloaded");
264
+ });
265
+
266
+ await expect(withRetry(fn)).rejects.toThrow();
267
+ expect(fn).toHaveBeenCalledTimes(3);
268
+ });
269
+ });
270
+
271
+ describe("TalonError retryAfterMs is respected by classify", () => {
272
+ it("classify preserves retryAfterMs from rate-limit message", () => {
273
+ // Verify that classify() correctly parses the retryAfterMs so
274
+ // withRetry has accurate delay information.
275
+ const err = classify(new Error("rate limit hit, retry after 45 seconds"));
276
+ expect(err.reason).toBe("rate_limit");
277
+ expect(err.retryAfterMs).toBe(45_000);
278
+ });
279
+
280
+ it("classify defaults retryAfterMs to 60000 when no retry hint is given", () => {
281
+ const err = classify(new Error("rate limit exceeded"));
282
+ expect(err.retryAfterMs).toBe(60_000);
283
+ });
284
+
285
+ it("classify caps retryAfterMs at 300000", () => {
286
+ const err = classify(new Error("rate limit, retry after 9999 seconds"));
287
+ expect(err.retryAfterMs).toBe(300_000);
288
+ });
289
+
290
+ it("network errors have retryAfterMs of 2000", () => {
291
+ const err = classify(new Error("ECONNREFUSED"));
292
+ expect(err.retryAfterMs).toBe(2_000);
293
+ });
294
+
295
+ it("overloaded errors have retryAfterMs of 5000 when matched via keyword", () => {
296
+ const err = classify(new Error("server is overloaded"));
297
+ expect(err.retryAfterMs).toBe(5_000);
298
+ });
299
+
300
+ it("5xx server errors have retryAfterMs of 2000", () => {
301
+ const err = classify(new Error("500 Internal Server Error"));
302
+ expect(err.retryAfterMs).toBe(2_000);
303
+ });
304
+ });
305
+
306
+ describe("error identity through withRetry", () => {
307
+ it("non-retryable TalonError reason is preserved in thrown error", async () => {
308
+ const original = talonErr("auth");
309
+ await expect(
310
+ withRetry(async () => {
311
+ throw original;
312
+ }),
313
+ ).rejects.toMatchObject({ reason: "auth" });
314
+ });
315
+
316
+ it("after exhausted retries the thrown error is a TalonError", async () => {
317
+ await expect(
318
+ withRetry(async () => {
319
+ throw talonErr("network");
320
+ }),
321
+ ).rejects.toBeInstanceOf(TalonError);
322
+ });
323
+
324
+ it("non-Error throws are classified and the TalonError is thrown for non-retryable", async () => {
325
+ await expect(
326
+ withRetry(async () => {
327
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
328
+ throw 42; // will be classified as "unknown" (non-retryable)
329
+ }),
330
+ ).rejects.toBeInstanceOf(TalonError);
331
+ });
332
+ });
333
+
334
+ describe("concurrency — multiple independent withRetry calls", () => {
335
+ it("two simultaneous calls both succeed independently", async () => {
336
+ const [r1, r2] = await Promise.all([
337
+ withRetry(async () => "first"),
338
+ withRetry(async () => "second"),
339
+ ]);
340
+ expect(r1).toBe("first");
341
+ expect(r2).toBe("second");
342
+ });
343
+
344
+ it("one failing call does not affect a parallel successful call", async () => {
345
+ const results = await Promise.allSettled([
346
+ withRetry(async () => {
347
+ throw talonErr("auth"); // aborts immediately, non-retryable
348
+ }),
349
+ withRetry(async () => "safe"),
350
+ ]);
351
+
352
+ expect(results[0].status).toBe("rejected");
353
+ expect(results[1].status).toBe("fulfilled");
354
+ if (results[1].status === "fulfilled") {
355
+ expect(results[1].value).toBe("safe");
356
+ }
357
+ });
358
+ });
359
+ });