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
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Extended edge-case tests for src/core/errors.ts
3
+ *
4
+ * Complements the baseline coverage in errors.test.ts with additional
5
+ * edge cases: more network codes, non-Error inputs, nested causes,
6
+ * all friendlyMessage paths, gateway status codes, and TalonError
7
+ * cause-chain preservation.
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { TalonError, classify, friendlyMessage } from "../core/errors.js";
12
+
13
+ // ── classify — additional network codes ────────────────────────────────────
14
+
15
+ describe("classify — additional network codes", () => {
16
+ it("classifies ECONNRESET as network/retryable", () => {
17
+ const err = classify(new Error("ECONNRESET"));
18
+ expect(err.reason).toBe("network");
19
+ expect(err.retryable).toBe(true);
20
+ });
21
+
22
+ it("classifies ECONNABORTED as network/retryable", () => {
23
+ const err = classify(new Error("ECONNABORTED"));
24
+ expect(err.reason).toBe("network");
25
+ expect(err.retryable).toBe(true);
26
+ });
27
+
28
+ it("classifies 'connection reset' string as network/retryable", () => {
29
+ const err = classify(new Error("connection reset by peer"));
30
+ expect(err.reason).toBe("network");
31
+ expect(err.retryable).toBe(true);
32
+ });
33
+
34
+ it("classifies 'fetch failed' as network/retryable", () => {
35
+ const err = classify(new Error("fetch failed: SSL handshake timeout"));
36
+ expect(err.reason).toBe("network");
37
+ expect(err.retryable).toBe(true);
38
+ });
39
+
40
+ it("classifies ETIMEDOUT as network/retryable", () => {
41
+ const err = classify(new Error("connect ETIMEDOUT 1.2.3.4:443"));
42
+ expect(err.reason).toBe("network");
43
+ expect(err.retryable).toBe(true);
44
+ expect(err.retryAfterMs).toBe(2000);
45
+ });
46
+ });
47
+
48
+ // ── classify — non-Error inputs ───────────────────────────────────────────
49
+
50
+ describe("classify — non-Error inputs", () => {
51
+ it("handles plain object input (converts to string)", () => {
52
+ const err = classify({ message: "rate limit" });
53
+ // Plain objects stringify to "[object Object]" — falls through to unknown
54
+ expect(err.reason).toBe("unknown");
55
+ expect(err.retryable).toBe(false);
56
+ });
57
+
58
+ it("handles numeric input", () => {
59
+ const err = classify(42);
60
+ expect(err.reason).toBe("unknown");
61
+ expect(err.retryable).toBe(false);
62
+ });
63
+
64
+ it("handles null input", () => {
65
+ const err = classify(null);
66
+ expect(err.reason).toBe("unknown");
67
+ expect(err.retryable).toBe(false);
68
+ });
69
+
70
+ it("handles undefined input", () => {
71
+ const err = classify(undefined);
72
+ expect(err.reason).toBe("unknown");
73
+ expect(err.retryable).toBe(false);
74
+ });
75
+
76
+ it("handles boolean true", () => {
77
+ const err = classify(true);
78
+ expect(err.reason).toBe("unknown");
79
+ });
80
+
81
+ it("handles string that matches 'rate limit'", () => {
82
+ const err = classify("rate limit exceeded");
83
+ expect(err.reason).toBe("rate_limit");
84
+ expect(err.retryable).toBe(true);
85
+ });
86
+
87
+ it("handles string that matches 'overloaded'", () => {
88
+ const err = classify("server overloaded please retry");
89
+ expect(err.reason).toBe("overloaded");
90
+ expect(err.retryable).toBe(true);
91
+ });
92
+ });
93
+
94
+ // ── classify — nested cause errors ───────────────────────────────────────
95
+
96
+ describe("classify — nested cause errors", () => {
97
+ it("classifies based on the top-level error message, not the cause message", () => {
98
+ const cause = new Error("rate limit exceeded");
99
+ const wrapper = new Error("Something failed", { cause });
100
+ // The wrapper message "Something failed" does not match any known pattern
101
+ const err = classify(wrapper);
102
+ expect(err.reason).toBe("unknown");
103
+ });
104
+
105
+ it("preserves the original Error as the cause property", () => {
106
+ const original = new Error("503 Service Unavailable");
107
+ const classified = classify(original);
108
+ expect(classified.cause).toBe(original);
109
+ });
110
+
111
+ it("cause is undefined for non-Error inputs (string, number, etc.)", () => {
112
+ const err = classify("overloaded");
113
+ // cause is only set when err instanceof Error
114
+ expect(err.cause).toBeUndefined();
115
+ });
116
+
117
+ it("cause is undefined for null/undefined inputs", () => {
118
+ expect(classify(null).cause).toBeUndefined();
119
+ expect(classify(undefined).cause).toBeUndefined();
120
+ });
121
+
122
+ it("TalonError passed through classify preserves its existing cause", () => {
123
+ const inner = new Error("original cause");
124
+ const talon = new TalonError("wrapped", {
125
+ reason: "network",
126
+ retryable: true,
127
+ cause: inner,
128
+ });
129
+ const result = classify(talon);
130
+ expect(result).toBe(talon); // exact same reference
131
+ expect(result.cause).toBe(inner);
132
+ });
133
+ });
134
+
135
+ // ── classify — HTTP gateway status codes ────────────────────────────────
136
+
137
+ describe("classify — gateway status codes", () => {
138
+ it("classifies 502 Bad Gateway as overloaded/retryable", () => {
139
+ const err = classify(new Error("502 Bad Gateway"));
140
+ expect(err.reason).toBe("overloaded");
141
+ expect(err.retryable).toBe(true);
142
+ expect(err.status).toBe(502);
143
+ });
144
+
145
+ it("classifies 504 Gateway Timeout as overloaded/retryable", () => {
146
+ const err = classify(new Error("504 Gateway Timeout"));
147
+ expect(err.reason).toBe("overloaded");
148
+ expect(err.retryable).toBe(true);
149
+ expect(err.status).toBe(504);
150
+ });
151
+
152
+ it("classifies 408 Request Timeout as network/retryable via 5xx path — actually falls to unknown", () => {
153
+ // 408 is a 4xx status. It does not match rate_limit, overloaded, network,
154
+ // session, context_length, auth, 400, 403, or 5xx branches.
155
+ // Documents the actual behavior: falls through to unknown.
156
+ const err = classify(new Error("408 Request Timeout"));
157
+ // 408 doesn't match the overloaded/5xx branch (which requires status >= 500)
158
+ // It also doesn't match any other pattern unless the message contains a keyword.
159
+ expect(err.retryable).toBe(false);
160
+ expect(err.status).toBe(408);
161
+ });
162
+
163
+ it("classifies 500 Internal Server Error as overloaded/retryable", () => {
164
+ const err = classify(new Error("500 Internal Server Error"));
165
+ expect(err.reason).toBe("overloaded");
166
+ expect(err.retryable).toBe(true);
167
+ expect(err.status).toBe(500);
168
+ expect(err.retryAfterMs).toBe(2000);
169
+ });
170
+
171
+ it("classifies 503 Service Unavailable as overloaded/retryable", () => {
172
+ // The "503" token in the message triggers the overloaded regex directly
173
+ const err = classify(new Error("503 Service Unavailable"));
174
+ expect(err.reason).toBe("overloaded");
175
+ expect(err.retryable).toBe(true);
176
+ });
177
+
178
+ it("classifies 401 Unauthorized as auth/non-retryable", () => {
179
+ const err = classify(new Error("401 Unauthorized"));
180
+ expect(err.reason).toBe("auth");
181
+ expect(err.retryable).toBe(false);
182
+ expect(err.status).toBe(401);
183
+ });
184
+
185
+ it("classifies 403 Forbidden as forbidden/non-retryable", () => {
186
+ const err = classify(new Error("403 Forbidden"));
187
+ expect(err.reason).toBe("forbidden");
188
+ expect(err.retryable).toBe(false);
189
+ expect(err.status).toBe(403);
190
+ });
191
+ });
192
+
193
+ // ── friendlyMessage — all error reasons ────────────────────────────────
194
+
195
+ describe("friendlyMessage — all error reasons", () => {
196
+ it("rate_limit without retryAfterMs falls back to default template", () => {
197
+ // When retryAfterMs is provided by classify it defaults to 60000
198
+ const err = classify(new Error("rate limit exceeded"));
199
+ const msg = friendlyMessage(err);
200
+ // Should contain "60 seconds" since default retryAfterMs is 60_000
201
+ expect(msg).toContain("60 seconds");
202
+ });
203
+
204
+ it("rate_limit with custom retryAfterMs shows correct seconds", () => {
205
+ const err = new TalonError("rate limited", {
206
+ reason: "rate_limit",
207
+ retryable: true,
208
+ retryAfterMs: 45_000,
209
+ });
210
+ const msg = friendlyMessage(err);
211
+ expect(msg).toContain("45 seconds");
212
+ });
213
+
214
+ it("rate_limit with retryAfterMs of 30000 shows 30 seconds", () => {
215
+ const err = classify(new Error("rate limit, retry after 30 seconds"));
216
+ expect(friendlyMessage(err)).toContain("30 seconds");
217
+ });
218
+
219
+ it("overloaded reason contains 'busy'", () => {
220
+ const err = new TalonError("overloaded", {
221
+ reason: "overloaded",
222
+ retryable: true,
223
+ });
224
+ expect(friendlyMessage(err)).toContain("busy");
225
+ });
226
+
227
+ it("network reason contains 'Connection'", () => {
228
+ const err = new TalonError("network error", {
229
+ reason: "network",
230
+ retryable: true,
231
+ });
232
+ expect(friendlyMessage(err)).toContain("Connection");
233
+ });
234
+
235
+ it("auth reason contains 'API key'", () => {
236
+ const err = new TalonError("auth error", {
237
+ reason: "auth",
238
+ retryable: false,
239
+ });
240
+ expect(friendlyMessage(err)).toContain("API key");
241
+ });
242
+
243
+ it("context_length reason contains '/reset'", () => {
244
+ const err = new TalonError("too long", {
245
+ reason: "context_length",
246
+ retryable: false,
247
+ });
248
+ expect(friendlyMessage(err)).toContain("/reset");
249
+ });
250
+
251
+ it("session_expired reason returns the TalonError message verbatim", () => {
252
+ const customMsg = "Your session timed out. Please send a new message.";
253
+ const err = new TalonError(customMsg, {
254
+ reason: "session_expired",
255
+ retryable: false,
256
+ });
257
+ expect(friendlyMessage(err)).toBe(customMsg);
258
+ });
259
+
260
+ it("bad_request reason contains 'went wrong'", () => {
261
+ const err = new TalonError("bad request", {
262
+ reason: "bad_request",
263
+ retryable: false,
264
+ });
265
+ expect(friendlyMessage(err)).toContain("went wrong");
266
+ });
267
+
268
+ it("forbidden reason contains 'Permission denied'", () => {
269
+ const err = new TalonError("forbidden", {
270
+ reason: "forbidden",
271
+ retryable: false,
272
+ });
273
+ expect(friendlyMessage(err)).toContain("Permission denied");
274
+ });
275
+
276
+ it("telegram_api reason contains 'Telegram'", () => {
277
+ const err = new TalonError("telegram api failure", {
278
+ reason: "telegram_api",
279
+ retryable: false,
280
+ });
281
+ expect(friendlyMessage(err)).toContain("Telegram");
282
+ });
283
+
284
+ it("unknown reason contains 'Something went wrong'", () => {
285
+ const err = new TalonError("mystery error", {
286
+ reason: "unknown",
287
+ retryable: false,
288
+ });
289
+ expect(friendlyMessage(err)).toContain("Something went wrong");
290
+ });
291
+
292
+ it("classifies raw Error before generating message", () => {
293
+ // Passing a raw Error (not TalonError) should trigger auto-classification
294
+ const rawErr = new Error("ECONNREFUSED connection refused");
295
+ const msg = friendlyMessage(rawErr);
296
+ expect(msg).toContain("Connection");
297
+ });
298
+
299
+ it("handles string input by classifying it first", () => {
300
+ const msg = friendlyMessage("503 overloaded service");
301
+ expect(msg).toContain("busy");
302
+ });
303
+
304
+ it("handles null input without throwing", () => {
305
+ expect(() => friendlyMessage(null)).not.toThrow();
306
+ });
307
+ });
308
+
309
+ // ── TalonError — construction and properties ────────────────────────────
310
+
311
+ describe("TalonError — construction and properties", () => {
312
+ it("is instanceof Error and instanceof TalonError", () => {
313
+ const err = new TalonError("test", { reason: "network", retryable: true });
314
+ expect(err).toBeInstanceOf(Error);
315
+ expect(err).toBeInstanceOf(TalonError);
316
+ });
317
+
318
+ it("name property is 'TalonError'", () => {
319
+ expect(new TalonError("x", { reason: "unknown" }).name).toBe("TalonError");
320
+ });
321
+
322
+ it("reason is stored correctly for every known reason", () => {
323
+ const reasons = [
324
+ "rate_limit",
325
+ "overloaded",
326
+ "network",
327
+ "auth",
328
+ "context_length",
329
+ "session_expired",
330
+ "bad_request",
331
+ "forbidden",
332
+ "telegram_api",
333
+ "unknown",
334
+ ] as const;
335
+ for (const reason of reasons) {
336
+ const err = new TalonError("msg", { reason });
337
+ expect(err.reason).toBe(reason);
338
+ }
339
+ });
340
+
341
+ it("retryable defaults to false when not provided", () => {
342
+ const err = new TalonError("x", { reason: "unknown" });
343
+ expect(err.retryable).toBe(false);
344
+ });
345
+
346
+ it("retryable is true when explicitly set", () => {
347
+ const err = new TalonError("x", { reason: "network", retryable: true });
348
+ expect(err.retryable).toBe(true);
349
+ });
350
+
351
+ it("status is preserved", () => {
352
+ const err = new TalonError("x", { reason: "auth", status: 401 });
353
+ expect(err.status).toBe(401);
354
+ });
355
+
356
+ it("status is undefined when not provided", () => {
357
+ const err = new TalonError("x", { reason: "unknown" });
358
+ expect(err.status).toBeUndefined();
359
+ });
360
+
361
+ it("retryAfterMs is preserved", () => {
362
+ const err = new TalonError("x", {
363
+ reason: "rate_limit",
364
+ retryAfterMs: 30_000,
365
+ });
366
+ expect(err.retryAfterMs).toBe(30_000);
367
+ });
368
+
369
+ it("retryAfterMs is undefined when not provided", () => {
370
+ const err = new TalonError("x", { reason: "network" });
371
+ expect(err.retryAfterMs).toBeUndefined();
372
+ });
373
+
374
+ it("preserves a cause Error", () => {
375
+ const cause = new Error("root cause");
376
+ const err = new TalonError("wrapped", { reason: "unknown", cause });
377
+ expect(err.cause).toBe(cause);
378
+ });
379
+
380
+ it("preserves a cause TalonError (nested chain)", () => {
381
+ const inner = new TalonError("inner", {
382
+ reason: "network",
383
+ retryable: true,
384
+ });
385
+ const outer = new TalonError("outer", {
386
+ reason: "overloaded",
387
+ retryable: true,
388
+ cause: inner,
389
+ });
390
+ expect(outer.cause).toBe(inner);
391
+ expect((outer.cause as TalonError).reason).toBe("network");
392
+ });
393
+
394
+ it("cause is undefined when not provided", () => {
395
+ const err = new TalonError("x", { reason: "unknown" });
396
+ expect(err.cause).toBeUndefined();
397
+ });
398
+
399
+ it("message is stored correctly", () => {
400
+ const err = new TalonError("very specific message", { reason: "auth" });
401
+ expect(err.message).toBe("very specific message");
402
+ });
403
+
404
+ it("stack trace is present", () => {
405
+ const err = new TalonError("x", { reason: "unknown" });
406
+ expect(err.stack).toBeDefined();
407
+ expect(err.stack).toContain("TalonError");
408
+ });
409
+ });
410
+
411
+ // ── Line 128: auth status fallback ──────────────────────────────────────────
412
+
413
+ describe("classify — auth without HTTP status in message", () => {
414
+ it("uses 401 as default status when 'authentication' in message has no numeric status", () => {
415
+ const err = classify(new Error("authentication failed"));
416
+ expect(err.reason).toBe("auth");
417
+ expect(err.status).toBe(401);
418
+ expect(err.retryable).toBe(false);
419
+ });
420
+
421
+ it("auth pattern takes priority over status code for 'unauthorized' message", () => {
422
+ // 'unauthorized' matches auth regex before 403 status check is reached
423
+ const err = classify(new Error("unauthorized: 403 Forbidden"));
424
+ expect(err.reason).toBe("auth");
425
+ expect(err.status).toBe(403); // extracted numeric status overrides default 401
426
+ expect(err.retryable).toBe(false);
427
+ });
428
+ });
@@ -52,6 +52,13 @@ describe("classify", () => {
52
52
  expect(err.retryable).toBe(false);
53
53
  });
54
54
 
55
+ it("classifies auth errors without status code — covers L128 ?? 401 fallback", () => {
56
+ // "authentication failed" has no numeric status → status=undefined → status ?? 401 = 401
57
+ const err = classify(new Error("authentication failed"));
58
+ expect(err.reason).toBe("auth");
59
+ expect(err.status).toBe(401);
60
+ });
61
+
55
62
  it("classifies 400 as bad_request", () => {
56
63
  const err = classify(new Error("400 Bad Request"));
57
64
  expect(err.reason).toBe("bad_request");
@@ -71,7 +78,10 @@ describe("classify", () => {
71
78
  });
72
79
 
73
80
  it("passes through TalonError unchanged", () => {
74
- const original = new TalonError("test", { reason: "rate_limit", retryable: true });
81
+ const original = new TalonError("test", {
82
+ reason: "rate_limit",
83
+ retryable: true,
84
+ });
75
85
  expect(classify(original)).toBe(original);
76
86
  });
77
87
 
@@ -85,6 +95,57 @@ describe("classify", () => {
85
95
  expect(err.reason).toBe("unknown");
86
96
  });
87
97
 
98
+ it("covers inner catch when String(err) throws — non-stringifiable object (L62)", () => {
99
+ const bad = {
100
+ toString() {
101
+ throw new Error("no string");
102
+ },
103
+ [Symbol.toPrimitive]() {
104
+ throw new Error("no primitive");
105
+ },
106
+ };
107
+ const err = classify(bad);
108
+ expect(err.reason).toBe("unknown");
109
+ expect(err.message).toBe("[non-stringifiable error]");
110
+ });
111
+
112
+ // Additional classify coverage for network variants
113
+ it("classifies ECONNRESET as network", () => {
114
+ expect(classify(new Error("ECONNRESET")).reason).toBe("network");
115
+ });
116
+
117
+ it("classifies ECONNABORTED as network", () => {
118
+ expect(classify(new Error("ECONNABORTED")).reason).toBe("network");
119
+ });
120
+
121
+ it("classifies 'connection reset' as network", () => {
122
+ expect(classify(new Error("connection reset by peer")).reason).toBe(
123
+ "network",
124
+ );
125
+ });
126
+
127
+ it("classifies 'invalid.*resume' as session_expired", () => {
128
+ expect(
129
+ classify(new Error("invalid session, resume not possible")).reason,
130
+ ).toBe("session_expired");
131
+ });
132
+
133
+ it("classifies 'too long' as context_length", () => {
134
+ expect(classify(new Error("message is too long")).reason).toBe(
135
+ "context_length",
136
+ );
137
+ });
138
+
139
+ it("classifies 'token limit' as context_length", () => {
140
+ expect(classify(new Error("token limit exceeded")).reason).toBe(
141
+ "context_length",
142
+ );
143
+ });
144
+
145
+ it("classifies 'api_key' as auth", () => {
146
+ expect(classify(new Error("invalid api_key provided")).reason).toBe("auth");
147
+ });
148
+
88
149
  it("preserves original error as cause", () => {
89
150
  const original = new Error("503 overloaded");
90
151
  const err = classify(original);
@@ -187,7 +248,10 @@ describe("classify", () => {
187
248
 
188
249
  describe("friendlyMessage", () => {
189
250
  it("returns rate limit message with retry time", () => {
190
- const err = new TalonError("x", { reason: "rate_limit", retryAfterMs: 30000 });
251
+ const err = new TalonError("x", {
252
+ reason: "rate_limit",
253
+ retryAfterMs: 30000,
254
+ });
191
255
  expect(friendlyMessage(err)).toContain("30 seconds");
192
256
  });
193
257
 
@@ -211,7 +275,35 @@ describe("friendlyMessage", () => {
211
275
  });
212
276
 
213
277
  it("classifies raw errors before generating message", () => {
214
- expect(friendlyMessage(new Error("some random failure"))).toContain("Something went wrong");
278
+ expect(friendlyMessage(new Error("some random failure"))).toContain(
279
+ "Something went wrong",
280
+ );
281
+ });
282
+
283
+ it("returns generic rate limit message when retryAfterMs is absent", () => {
284
+ const err = new TalonError("x", { reason: "rate_limit", retryable: true });
285
+ // No retryAfterMs → falls through to FRIENDLY_MESSAGES["rate_limit"]
286
+ expect(friendlyMessage(err)).toContain("Rate limited");
287
+ expect(friendlyMessage(err)).not.toContain("seconds");
288
+ });
289
+
290
+ it("returns auth message", () => {
291
+ expect(friendlyMessage(new Error("401 Unauthorized"))).toContain("API key");
292
+ });
293
+
294
+ it("returns bad_request message", () => {
295
+ expect(friendlyMessage(new Error("400 Bad Request"))).toContain(
296
+ "Something went wrong",
297
+ );
298
+ });
299
+
300
+ it("returns forbidden message", () => {
301
+ expect(friendlyMessage(new Error("403 Forbidden"))).toContain("Permission");
302
+ });
303
+
304
+ it("returns telegram_api message", () => {
305
+ const err = new TalonError("Telegram failed", { reason: "telegram_api" });
306
+ expect(friendlyMessage(err)).toContain("Telegram API");
215
307
  });
216
308
  });
217
309