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.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- package/src/util/workspace.ts +51 -10
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
import {
|
|
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(),
|
|
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,
|
|
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(() => ({
|
|
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")
|
|
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(
|
|
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 {
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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 () => {
|
|
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({
|
|
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({
|
|
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({
|
|
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",
|
|
239
|
-
|
|
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
|
+
});
|