talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,383 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { initDispatcher, execute, getActiveCount } from "../core/dispatcher.js";
3
+ import type { QueryBackend, ContextManager } from "../core/types.js";
4
+
5
+ function createMockDeps() {
6
+ const acquired: number[] = [];
7
+ const released: number[] = [];
8
+
9
+ const backend: QueryBackend = {
10
+ query: vi.fn(async () => ({
11
+ text: "response",
12
+ durationMs: 100,
13
+ inputTokens: 10,
14
+ outputTokens: 20,
15
+ cacheRead: 5,
16
+ cacheWrite: 3,
17
+ })),
18
+ };
19
+
20
+ const context: ContextManager = {
21
+ acquire: vi.fn((chatId: number) => { acquired.push(chatId); }),
22
+ release: vi.fn((chatId: number) => { released.push(chatId); }),
23
+ getMessageCount: vi.fn(() => 0),
24
+ };
25
+
26
+ const sendTyping = vi.fn(async () => {});
27
+ const onActivity = vi.fn();
28
+
29
+ return { backend, context, sendTyping, onActivity, acquired, released };
30
+ }
31
+
32
+ describe("dispatcher", () => {
33
+ beforeEach(() => {
34
+ const deps = createMockDeps();
35
+ initDispatcher(deps);
36
+ });
37
+
38
+ it("executes a query and returns result", async () => {
39
+ const result = await execute({
40
+ chatId: "123",
41
+ numericChatId: 123,
42
+ prompt: "hello",
43
+ senderName: "User",
44
+ isGroup: false,
45
+ source: "message",
46
+ });
47
+ expect(result.text).toBe("response");
48
+ expect(result.durationMs).toBe(100);
49
+ expect(result.bridgeMessageCount).toBe(0);
50
+ });
51
+
52
+ it("acquires and releases context", async () => {
53
+ const deps = createMockDeps();
54
+ initDispatcher(deps);
55
+
56
+ await execute({
57
+ chatId: "456",
58
+ numericChatId: 456,
59
+ prompt: "test",
60
+ senderName: "User",
61
+ isGroup: false,
62
+ source: "message",
63
+ });
64
+
65
+ expect(deps.context.acquire).toHaveBeenCalledWith(456, "456");
66
+ expect(deps.context.release).toHaveBeenCalledWith(456);
67
+ });
68
+
69
+ it("releases context even on error", async () => {
70
+ const deps = createMockDeps();
71
+ (deps.backend.query as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
72
+ new Error("boom"),
73
+ );
74
+ initDispatcher(deps);
75
+
76
+ await expect(
77
+ execute({
78
+ chatId: "789",
79
+ numericChatId: 789,
80
+ prompt: "fail",
81
+ senderName: "User",
82
+ isGroup: false,
83
+ source: "message",
84
+ }),
85
+ ).rejects.toThrow("boom");
86
+
87
+ expect(deps.context.release).toHaveBeenCalledWith(789);
88
+ });
89
+
90
+ it("sends typing on execution", async () => {
91
+ const deps = createMockDeps();
92
+ initDispatcher(deps);
93
+
94
+ await execute({
95
+ chatId: "111",
96
+ numericChatId: 111,
97
+ prompt: "hi",
98
+ senderName: "User",
99
+ isGroup: false,
100
+ source: "message",
101
+ });
102
+
103
+ expect(deps.sendTyping).toHaveBeenCalledWith(111);
104
+ });
105
+
106
+ it("calls onActivity after successful query", async () => {
107
+ const deps = createMockDeps();
108
+ initDispatcher(deps);
109
+
110
+ await execute({
111
+ chatId: "222",
112
+ numericChatId: 222,
113
+ prompt: "hi",
114
+ senderName: "User",
115
+ isGroup: false,
116
+ source: "message",
117
+ });
118
+
119
+ expect(deps.onActivity).toHaveBeenCalled();
120
+ });
121
+
122
+ it("does not call onActivity on error", async () => {
123
+ const deps = createMockDeps();
124
+ (deps.backend.query as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
125
+ new Error("fail"),
126
+ );
127
+ initDispatcher(deps);
128
+
129
+ await expect(
130
+ execute({
131
+ chatId: "333",
132
+ numericChatId: 333,
133
+ prompt: "fail",
134
+ senderName: "User",
135
+ isGroup: false,
136
+ source: "message",
137
+ }),
138
+ ).rejects.toThrow();
139
+
140
+ expect(deps.onActivity).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("passes stream callbacks to backend", async () => {
144
+ const deps = createMockDeps();
145
+ initDispatcher(deps);
146
+ const onStreamDelta = vi.fn();
147
+ const onTextBlock = vi.fn();
148
+
149
+ await execute({
150
+ chatId: "444",
151
+ numericChatId: 444,
152
+ prompt: "stream",
153
+ senderName: "User",
154
+ isGroup: false,
155
+ source: "message",
156
+ onStreamDelta,
157
+ onTextBlock,
158
+ });
159
+
160
+ expect(deps.backend.query).toHaveBeenCalledWith(
161
+ expect.objectContaining({ onStreamDelta, onTextBlock }),
162
+ );
163
+ });
164
+
165
+ it("tracks active count", async () => {
166
+ expect(getActiveCount()).toBe(0);
167
+
168
+ const deps = createMockDeps();
169
+ let resolveQuery!: () => void;
170
+ (deps.backend.query as ReturnType<typeof vi.fn>).mockImplementation(
171
+ () => new Promise<{ text: string; durationMs: number; inputTokens: number; outputTokens: number; cacheRead: number; cacheWrite: number }>((r) => {
172
+ resolveQuery = () => r({ text: "", durationMs: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 });
173
+ }),
174
+ );
175
+ initDispatcher(deps);
176
+
177
+ const p = execute({ chatId: "555", numericChatId: 555, prompt: "hi", senderName: "U", isGroup: false, source: "message" });
178
+ // Give it a tick to start
179
+ await new Promise((r) => setTimeout(r, 10));
180
+ expect(getActiveCount()).toBe(1);
181
+
182
+ resolveQuery();
183
+ await p;
184
+ expect(getActiveCount()).toBe(0);
185
+ });
186
+
187
+ it("runs different-chat queries in true parallel", async () => {
188
+ const order: string[] = [];
189
+ const backend: QueryBackend = {
190
+ query: vi.fn(async (params) => {
191
+ order.push(`start:${params.chatId}`);
192
+ await new Promise((r) => setTimeout(r, 50));
193
+ order.push(`end:${params.chatId}`);
194
+ return { text: "", durationMs: 50, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 };
195
+ }),
196
+ };
197
+
198
+ initDispatcher({
199
+ backend,
200
+ context: { acquire: () => {}, release: () => {}, getMessageCount: () => 0 },
201
+ sendTyping: async () => {},
202
+ onActivity: () => {},
203
+ });
204
+
205
+ // Fire two queries for DIFFERENT chats — they should overlap
206
+ await Promise.all([
207
+ execute({ chatId: "A", numericChatId: 1, prompt: "a", senderName: "U", isGroup: false, source: "message" }),
208
+ execute({ chatId: "B", numericChatId: 2, prompt: "b", senderName: "U", isGroup: false, source: "message" }),
209
+ ]);
210
+
211
+ // Both should START before either ENDS (true parallel)
212
+ expect(order[0]).toBe("start:A");
213
+ expect(order[1]).toBe("start:B");
214
+ });
215
+
216
+ it("second query still runs after first query errors (same chat)", async () => {
217
+ let callCount = 0;
218
+ const backend: QueryBackend = {
219
+ query: vi.fn(async () => {
220
+ callCount++;
221
+ if (callCount === 1) throw new Error("first fails");
222
+ return { text: "second ok", durationMs: 10, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 };
223
+ }),
224
+ };
225
+
226
+ initDispatcher({
227
+ backend,
228
+ context: { acquire: () => {}, release: () => {}, getMessageCount: () => 0 },
229
+ sendTyping: async () => {},
230
+ onActivity: () => {},
231
+ });
232
+
233
+ const p1 = execute({ chatId: "ERR", numericChatId: 1, prompt: "fail", senderName: "U", isGroup: false, source: "message" });
234
+ const p2 = execute({ chatId: "ERR", numericChatId: 1, prompt: "succeed", senderName: "U", isGroup: false, source: "message" });
235
+
236
+ await expect(p1).rejects.toThrow("first fails");
237
+ const result = await p2;
238
+ expect(result.text).toBe("second ok");
239
+ });
240
+
241
+ it("activeCount is accurate during errors", async () => {
242
+ const backend: QueryBackend = {
243
+ query: vi.fn(async () => { throw new Error("boom"); }),
244
+ };
245
+
246
+ initDispatcher({
247
+ backend,
248
+ context: { acquire: () => {}, release: () => {}, getMessageCount: () => 0 },
249
+ sendTyping: async () => {},
250
+ onActivity: () => {},
251
+ });
252
+
253
+ await expect(
254
+ execute({ chatId: "X", numericChatId: 1, prompt: "x", senderName: "U", isGroup: false, source: "message" }),
255
+ ).rejects.toThrow("boom");
256
+
257
+ expect(getActiveCount()).toBe(0); // cleaned up even on error
258
+ });
259
+
260
+ it("cleans up chatChains after queries complete (no map leak)", async () => {
261
+ const backend: QueryBackend = {
262
+ query: vi.fn(async () => ({
263
+ text: "ok",
264
+ durationMs: 10,
265
+ inputTokens: 0,
266
+ outputTokens: 0,
267
+ cacheRead: 0,
268
+ cacheWrite: 0,
269
+ })),
270
+ };
271
+
272
+ initDispatcher({
273
+ backend,
274
+ context: { acquire: () => {}, release: () => {}, getMessageCount: () => 0 },
275
+ sendTyping: async () => {},
276
+ onActivity: () => {},
277
+ });
278
+
279
+ // Execute queries for two different chats
280
+ await execute({
281
+ chatId: "cleanup-A",
282
+ numericChatId: 100,
283
+ prompt: "a",
284
+ senderName: "U",
285
+ isGroup: false,
286
+ source: "message",
287
+ });
288
+ await execute({
289
+ chatId: "cleanup-B",
290
+ numericChatId: 200,
291
+ prompt: "b",
292
+ senderName: "U",
293
+ isGroup: false,
294
+ source: "message",
295
+ });
296
+
297
+ // After both complete, activeCount should be 0 (chains cleaned up)
298
+ expect(getActiveCount()).toBe(0);
299
+
300
+ // Execute another query for the same chatId — should work fine (no stale chain)
301
+ const result = await execute({
302
+ chatId: "cleanup-A",
303
+ numericChatId: 100,
304
+ prompt: "c",
305
+ senderName: "U",
306
+ isGroup: false,
307
+ source: "message",
308
+ });
309
+ expect(result.text).toBe("ok");
310
+ });
311
+
312
+ it("calls sendTyping at least once during execution", async () => {
313
+ const deps = createMockDeps();
314
+ let resolveQuery!: () => void;
315
+ (deps.backend.query as ReturnType<typeof vi.fn>).mockImplementation(
316
+ () =>
317
+ new Promise<{
318
+ text: string;
319
+ durationMs: number;
320
+ inputTokens: number;
321
+ outputTokens: number;
322
+ cacheRead: number;
323
+ cacheWrite: number;
324
+ }>((r) => {
325
+ resolveQuery = () =>
326
+ r({
327
+ text: "done",
328
+ durationMs: 0,
329
+ inputTokens: 0,
330
+ outputTokens: 0,
331
+ cacheRead: 0,
332
+ cacheWrite: 0,
333
+ });
334
+ }),
335
+ );
336
+ initDispatcher(deps);
337
+
338
+ const p = execute({
339
+ chatId: "typing-test",
340
+ numericChatId: 777,
341
+ prompt: "hi",
342
+ senderName: "U",
343
+ isGroup: false,
344
+ source: "message",
345
+ });
346
+
347
+ // Wait for the initial sendTyping call
348
+ await new Promise((r) => setTimeout(r, 50));
349
+ expect(deps.sendTyping).toHaveBeenCalledWith(777);
350
+ expect(deps.sendTyping.mock.calls.length).toBeGreaterThanOrEqual(1);
351
+
352
+ resolveQuery();
353
+ await p;
354
+ });
355
+
356
+ it("serializes same-chat queries (FIFO)", async () => {
357
+ const order: string[] = [];
358
+ const backend: QueryBackend = {
359
+ query: vi.fn(async (params) => {
360
+ order.push(`start:${params.text}`);
361
+ await new Promise((r) => setTimeout(r, 30));
362
+ order.push(`end:${params.text}`);
363
+ return { text: "", durationMs: 30, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 };
364
+ }),
365
+ };
366
+
367
+ initDispatcher({
368
+ backend,
369
+ context: { acquire: () => {}, release: () => {}, getMessageCount: () => 0 },
370
+ sendTyping: async () => {},
371
+ onActivity: () => {},
372
+ });
373
+
374
+ // Fire two queries for the SAME chat — second must wait
375
+ await Promise.all([
376
+ execute({ chatId: "X", numericChatId: 1, prompt: "first", senderName: "U", isGroup: false, source: "message" }),
377
+ execute({ chatId: "X", numericChatId: 1, prompt: "second", senderName: "U", isGroup: false, source: "message" }),
378
+ ]);
379
+
380
+ // Same chat: first completes before second starts
381
+ expect(order).toEqual(["start:first", "end:first", "start:second", "end:second"]);
382
+ });
383
+ });
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { TalonError, classify, friendlyMessage } from "../core/errors.js";
3
+
4
+ describe("classify", () => {
5
+ it("classifies rate limit errors", () => {
6
+ const err = classify(new Error("429 Too Many Requests"));
7
+ expect(err.reason).toBe("rate_limit");
8
+ expect(err.retryable).toBe(true);
9
+ expect(err.status).toBe(429);
10
+ });
11
+
12
+ it("extracts retry-after from rate limit", () => {
13
+ const err = classify(new Error("rate limit, retry after 30 seconds"));
14
+ expect(err.reason).toBe("rate_limit");
15
+ expect(err.retryAfterMs).toBe(30000);
16
+ });
17
+
18
+ it("classifies overloaded errors", () => {
19
+ const err = classify(new Error("503 Service Unavailable"));
20
+ expect(err.reason).toBe("overloaded");
21
+ expect(err.retryable).toBe(true);
22
+ });
23
+
24
+ it("classifies generic 5xx as overloaded", () => {
25
+ const err = classify(new Error("502 Bad Gateway"));
26
+ expect(err.reason).toBe("overloaded");
27
+ expect(err.retryable).toBe(true);
28
+ });
29
+
30
+ it("classifies network errors", () => {
31
+ expect(classify(new Error("ECONNREFUSED")).reason).toBe("network");
32
+ expect(classify(new Error("fetch failed")).reason).toBe("network");
33
+ expect(classify(new Error("ETIMEDOUT")).reason).toBe("network");
34
+ expect(classify(new Error("ENOTFOUND")).reason).toBe("network");
35
+ });
36
+
37
+ it("classifies session expired errors", () => {
38
+ const err = classify(new Error("session expired"));
39
+ expect(err.reason).toBe("session_expired");
40
+ expect(err.retryable).toBe(false);
41
+ });
42
+
43
+ it("classifies context length errors", () => {
44
+ const err = classify(new Error("context length exceeded"));
45
+ expect(err.reason).toBe("context_length");
46
+ expect(err.retryable).toBe(false);
47
+ });
48
+
49
+ it("classifies auth errors", () => {
50
+ const err = classify(new Error("401 Unauthorized"));
51
+ expect(err.reason).toBe("auth");
52
+ expect(err.retryable).toBe(false);
53
+ });
54
+
55
+ it("classifies 400 as bad_request", () => {
56
+ const err = classify(new Error("400 Bad Request"));
57
+ expect(err.reason).toBe("bad_request");
58
+ expect(err.retryable).toBe(false);
59
+ });
60
+
61
+ it("classifies 403 as forbidden", () => {
62
+ const err = classify(new Error("403 Forbidden"));
63
+ expect(err.reason).toBe("forbidden");
64
+ expect(err.retryable).toBe(false);
65
+ });
66
+
67
+ it("returns unknown for unrecognized errors", () => {
68
+ const err = classify(new Error("something weird"));
69
+ expect(err.reason).toBe("unknown");
70
+ expect(err.retryable).toBe(false);
71
+ });
72
+
73
+ it("passes through TalonError unchanged", () => {
74
+ const original = new TalonError("test", { reason: "rate_limit", retryable: true });
75
+ expect(classify(original)).toBe(original);
76
+ });
77
+
78
+ it("handles string errors", () => {
79
+ const err = classify("rate limit hit");
80
+ expect(err.reason).toBe("rate_limit");
81
+ });
82
+
83
+ it("handles non-Error objects", () => {
84
+ const err = classify(42);
85
+ expect(err.reason).toBe("unknown");
86
+ });
87
+
88
+ it("preserves original error as cause", () => {
89
+ const original = new Error("503 overloaded");
90
+ const err = classify(original);
91
+ expect(err.cause).toBe(original);
92
+ });
93
+
94
+ it("classifies 500 as retryable server error", () => {
95
+ const err = classify(new Error("500 Internal Server Error"));
96
+ expect(err.retryable).toBe(true);
97
+ expect(err.status).toBe(500);
98
+ });
99
+
100
+ it("classifies 504 as retryable", () => {
101
+ const err = classify(new Error("504 Gateway Timeout"));
102
+ expect(err.retryable).toBe(true);
103
+ });
104
+
105
+ it("classifies ENOTFOUND as network error", () => {
106
+ const err = classify(new Error("ENOTFOUND"));
107
+ expect(err.reason).toBe("network");
108
+ expect(err.retryable).toBe(true);
109
+ });
110
+
111
+ // Mutant killers: rate.?limit regex — .? allows optional char between rate and limit
112
+ it("matches 'rate limit' with space (rate.?limit regex)", () => {
113
+ const err = classify(new Error("rate limit exceeded"));
114
+ expect(err.reason).toBe("rate_limit");
115
+ });
116
+
117
+ it("matches 'ratelimit' without space (rate.?limit regex)", () => {
118
+ const err = classify(new Error("ratelimit exceeded"));
119
+ expect(err.reason).toBe("rate_limit");
120
+ });
121
+
122
+ // Mutant killers: retry.?after regex — .? allows optional char between retry and after
123
+ it("extracts retry-after with no separator (retryafter60)", () => {
124
+ const err = classify(new Error("ratelimit retryafter60"));
125
+ expect(err.reason).toBe("rate_limit");
126
+ expect(err.retryAfterMs).toBe(60_000);
127
+ });
128
+
129
+ it("extracts retry-after with hyphen separator (retry-after: 60)", () => {
130
+ const err = classify(new Error("rate limit hit, retry-after: 60"));
131
+ expect(err.reason).toBe("rate_limit");
132
+ expect(err.retryAfterMs).toBe(60_000);
133
+ });
134
+
135
+ // Mutant killers: status ?? 429 — default status when no status code in message
136
+ it("defaults to status 429 when rate limit message has no status code", () => {
137
+ const err = classify(new Error("rate limit exceeded, please slow down"));
138
+ expect(err.reason).toBe("rate_limit");
139
+ expect(err.status).toBe(429);
140
+ });
141
+
142
+ it("uses extracted status instead of 429 default when status present", () => {
143
+ // "503" would match overloaded first, so use a message that hits rate_limit with a non-429 status
144
+ const err = classify(new Error("rate limit hit, error 200 ok"));
145
+ expect(err.reason).toBe("rate_limit");
146
+ expect(err.status).toBe(200);
147
+ });
148
+
149
+ // Mutant killers: overloaded/503/capacity branch
150
+ it("classifies 'overloaded' keyword as overloaded", () => {
151
+ const err = classify(new Error("API is overloaded"));
152
+ expect(err.reason).toBe("overloaded");
153
+ expect(err.retryable).toBe(true);
154
+ });
155
+
156
+ it("classifies 'capacity' keyword as overloaded", () => {
157
+ const err = classify(new Error("at capacity, try again later"));
158
+ expect(err.reason).toBe("overloaded");
159
+ expect(err.retryable).toBe(true);
160
+ });
161
+
162
+ // Mutant killers: status ?? 503 for overloaded branch
163
+ it("defaults to status 503 when overloaded message has no status code", () => {
164
+ const err = classify(new Error("server is overloaded please wait"));
165
+ expect(err.reason).toBe("overloaded");
166
+ expect(err.status).toBe(503);
167
+ });
168
+
169
+ it("uses extracted status instead of 503 default for overloaded", () => {
170
+ // "503" is in the message so status would be 503 anyway — use a message with overloaded keyword + different status
171
+ const err = classify(new Error("overloaded, returned 502"));
172
+ expect(err.reason).toBe("overloaded");
173
+ expect(err.status).toBe(502);
174
+ });
175
+
176
+ // Mutant killers: context.*length regex — .* allows any chars between context and length
177
+ it("matches 'context length exceeded' (space between context and length)", () => {
178
+ const err = classify(new Error("context length exceeded"));
179
+ expect(err.reason).toBe("context_length");
180
+ });
181
+
182
+ it("matches 'context_length_exceeded' (underscore between context and length)", () => {
183
+ const err = classify(new Error("context_length_exceeded"));
184
+ expect(err.reason).toBe("context_length");
185
+ });
186
+ });
187
+
188
+ describe("friendlyMessage", () => {
189
+ it("returns rate limit message with retry time", () => {
190
+ const err = new TalonError("x", { reason: "rate_limit", retryAfterMs: 30000 });
191
+ expect(friendlyMessage(err)).toContain("30 seconds");
192
+ });
193
+
194
+ it("returns overloaded message", () => {
195
+ expect(friendlyMessage(new Error("503"))).toContain("busy");
196
+ });
197
+
198
+ it("returns network error message", () => {
199
+ expect(friendlyMessage(new Error("ECONNREFUSED"))).toContain("Connection");
200
+ });
201
+
202
+ it("returns context length message with /reset", () => {
203
+ const msg = friendlyMessage(new Error("context length exceeded"));
204
+ expect(msg).toContain("/reset");
205
+ });
206
+
207
+ it("passes through session expired messages as-is", () => {
208
+ const msg = "Session expired. Send your message again.";
209
+ const err = new TalonError(msg, { reason: "session_expired" });
210
+ expect(friendlyMessage(err)).toBe(msg);
211
+ });
212
+
213
+ it("classifies raw errors before generating message", () => {
214
+ expect(friendlyMessage(new Error("some random failure"))).toContain("Something went wrong");
215
+ });
216
+ });
217
+
218
+ describe("TalonError", () => {
219
+ it("is an instanceof Error", () => {
220
+ const err = new TalonError("test", { reason: "unknown" });
221
+ expect(err).toBeInstanceOf(Error);
222
+ expect(err).toBeInstanceOf(TalonError);
223
+ });
224
+
225
+ it("has correct name", () => {
226
+ const err = new TalonError("test", { reason: "rate_limit" });
227
+ expect(err.name).toBe("TalonError");
228
+ });
229
+
230
+ it("defaults retryable to false", () => {
231
+ const err = new TalonError("test", { reason: "unknown" });
232
+ expect(err.retryable).toBe(false);
233
+ });
234
+
235
+ it("preserves cause", () => {
236
+ const cause = new Error("original");
237
+ const err = new TalonError("wrapped", { reason: "unknown", cause });
238
+ expect(err.cause).toBe(cause);
239
+ });
240
+ });