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
@@ -3,7 +3,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
3
  // ── Module mocks (must come before import) ──────────────────────────────────
4
4
 
5
5
  vi.mock("../util/log.js", () => ({
6
- log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
6
+ log: vi.fn(),
7
+ logError: vi.fn(),
8
+ logWarn: vi.fn(),
9
+ logDebug: vi.fn(),
7
10
  }));
8
11
 
9
12
  vi.mock("write-file-atomic", () => ({
@@ -31,7 +34,12 @@ const mockGetCronJob = vi.fn();
31
34
  const mockGetCronJobsForChat = vi.fn((): any[] => []);
32
35
  const mockUpdateCronJob = vi.fn();
33
36
  const mockDeleteCronJob = vi.fn();
34
- const mockValidateCronExpression = vi.fn((): { valid: boolean; next?: string; error?: string } => ({ valid: true, next: "2026-04-01T09:00:00.000Z" }));
37
+ const mockValidateCronExpression = vi.fn(
38
+ (): { valid: boolean; next?: string; error?: string } => ({
39
+ valid: true,
40
+ next: "2026-04-01T09:00:00.000Z",
41
+ }),
42
+ );
35
43
  const mockGenerateCronId = vi.fn(() => "test-id-123");
36
44
 
37
45
  vi.mock("../storage/cron-store.js", () => ({
@@ -82,14 +90,27 @@ function mockResponse(opts: {
82
90
  }
83
91
 
84
92
  /** Create an ArrayBuffer with valid image magic bytes. */
85
- function imageBuffer(type: "png" | "jpg" | "gif" | "webp", size = 1024): ArrayBuffer {
93
+ function imageBuffer(
94
+ type: "png" | "jpg" | "gif" | "webp",
95
+ size = 1024,
96
+ ): ArrayBuffer {
86
97
  const buf = new ArrayBuffer(Math.max(size, 16));
87
98
  const view = new Uint8Array(buf);
88
99
  switch (type) {
89
- case "png": view.set([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); break;
90
- case "jpg": view.set([0xFF, 0xD8, 0xFF, 0xE0]); break;
91
- case "gif": view.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); break;
92
- case "webp": view.set([0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50]); break;
100
+ case "png":
101
+ view.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
102
+ break;
103
+ case "jpg":
104
+ view.set([0xff, 0xd8, 0xff, 0xe0]);
105
+ break;
106
+ case "gif":
107
+ view.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]);
108
+ break;
109
+ case "webp":
110
+ view.set([
111
+ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
112
+ ]);
113
+ break;
93
114
  }
94
115
  return buf;
95
116
  }
@@ -120,7 +141,9 @@ describe("gateway shared actions", () => {
120
141
 
121
142
  describe("unknown actions", () => {
122
143
  it("returns null for unknown actions", async () => {
123
- expect(await handleSharedAction({ action: "unknown_thing" }, 123)).toBeNull();
144
+ expect(
145
+ await handleSharedAction({ action: "unknown_thing" }, 123),
146
+ ).toBeNull();
124
147
  });
125
148
 
126
149
  it("returns null for empty action", async () => {
@@ -162,18 +185,27 @@ describe("gateway shared actions", () => {
162
185
 
163
186
  describe("search_history", () => {
164
187
  it("returns search results with default limit", async () => {
165
- const result = await handleSharedAction({ action: "search_history", query: "hello" }, 42);
188
+ const result = await handleSharedAction(
189
+ { action: "search_history", query: "hello" },
190
+ 42,
191
+ );
166
192
  expect(result).toEqual({ ok: true, text: "search results here" });
167
193
  expect(mockSearchHistory).toHaveBeenCalledWith("42", "hello", 20);
168
194
  });
169
195
 
170
196
  it("passes custom limit", async () => {
171
- await handleSharedAction({ action: "search_history", query: "test", limit: 5 }, 42);
197
+ await handleSharedAction(
198
+ { action: "search_history", query: "test", limit: 5 },
199
+ 42,
200
+ );
172
201
  expect(mockSearchHistory).toHaveBeenCalledWith("42", "test", 5);
173
202
  });
174
203
 
175
204
  it("clamps limit to 100", async () => {
176
- await handleSharedAction({ action: "search_history", query: "test", limit: 999 }, 42);
205
+ await handleSharedAction(
206
+ { action: "search_history", query: "test", limit: 999 },
207
+ 42,
208
+ );
177
209
  expect(mockSearchHistory).toHaveBeenCalledWith("42", "test", 100);
178
210
  });
179
211
 
@@ -185,18 +217,27 @@ describe("gateway shared actions", () => {
185
217
 
186
218
  describe("get_user_messages", () => {
187
219
  it("returns user messages with default limit", async () => {
188
- const result = await handleSharedAction({ action: "get_user_messages", user_name: "alice" }, 42);
220
+ const result = await handleSharedAction(
221
+ { action: "get_user_messages", user_name: "alice" },
222
+ 42,
223
+ );
189
224
  expect(result).toEqual({ ok: true, text: "user messages here" });
190
225
  expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "alice", 20);
191
226
  });
192
227
 
193
228
  it("passes custom limit", async () => {
194
- await handleSharedAction({ action: "get_user_messages", user_name: "bob", limit: 10 }, 42);
229
+ await handleSharedAction(
230
+ { action: "get_user_messages", user_name: "bob", limit: 10 },
231
+ 42,
232
+ );
195
233
  expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "bob", 10);
196
234
  });
197
235
 
198
236
  it("clamps limit to 50", async () => {
199
- await handleSharedAction({ action: "get_user_messages", user_name: "bob", limit: 200 }, 42);
237
+ await handleSharedAction(
238
+ { action: "get_user_messages", user_name: "bob", limit: 200 },
239
+ 42,
240
+ );
200
241
  expect(mockGetMessagesByUser).toHaveBeenCalledWith("42", "bob", 50);
201
242
  });
202
243
 
@@ -208,7 +249,10 @@ describe("gateway shared actions", () => {
208
249
 
209
250
  describe("list_known_users", () => {
210
251
  it("returns known users", async () => {
211
- const result = await handleSharedAction({ action: "list_known_users" }, 42);
252
+ const result = await handleSharedAction(
253
+ { action: "list_known_users" },
254
+ 42,
255
+ );
212
256
  expect(result).toEqual({ ok: true, text: "alice, bob" });
213
257
  expect(mockGetKnownUsers).toHaveBeenCalledWith("42");
214
258
  });
@@ -243,27 +287,43 @@ describe("gateway shared actions", () => {
243
287
  });
244
288
 
245
289
  it("returns error for empty query string", async () => {
246
- const result = await handleSharedAction({ action: "web_search", query: "" }, 123);
290
+ const result = await handleSharedAction(
291
+ { action: "web_search", query: "" },
292
+ 123,
293
+ );
247
294
  expect(result).toEqual({ ok: false, error: "Missing query" });
248
295
  });
249
296
 
250
297
  it("uses Brave API when key is configured", async () => {
251
298
  process.env.TALON_BRAVE_API_KEY = "test-brave-key";
252
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
253
- ok: true,
254
- contentType: "application/json",
255
- json: {
256
- web: {
257
- results: [
258
- { title: "Result 1", url: "https://example.com/1", description: "Description 1" },
259
- { title: "Result 2", url: "https://example.com/2", description: "Description 2" },
260
- ],
299
+ const mockFetch = vi.fn().mockResolvedValueOnce(
300
+ mockResponse({
301
+ ok: true,
302
+ contentType: "application/json",
303
+ json: {
304
+ web: {
305
+ results: [
306
+ {
307
+ title: "Result 1",
308
+ url: "https://example.com/1",
309
+ description: "Description 1",
310
+ },
311
+ {
312
+ title: "Result 2",
313
+ url: "https://example.com/2",
314
+ description: "Description 2",
315
+ },
316
+ ],
317
+ },
261
318
  },
262
- },
263
- }));
319
+ }),
320
+ );
264
321
  vi.stubGlobal("fetch", mockFetch);
265
322
 
266
- const result = await handleSharedAction({ action: "web_search", query: "test query" }, 123);
323
+ const result = await handleSharedAction(
324
+ { action: "web_search", query: "test query" },
325
+ 123,
326
+ );
267
327
 
268
328
  expect(result?.ok).toBe(true);
269
329
  expect(result?.text).toContain("via Brave");
@@ -283,45 +343,73 @@ describe("gateway shared actions", () => {
283
343
 
284
344
  it("respects custom limit for Brave API", async () => {
285
345
  process.env.TALON_BRAVE_API_KEY = "test-brave-key";
286
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
287
- ok: true,
288
- json: { web: { results: [{ title: "R", url: "https://r.com", description: "d" }] } },
289
- }));
346
+ const mockFetch = vi.fn().mockResolvedValueOnce(
347
+ mockResponse({
348
+ ok: true,
349
+ json: {
350
+ web: {
351
+ results: [{ title: "R", url: "https://r.com", description: "d" }],
352
+ },
353
+ },
354
+ }),
355
+ );
290
356
  vi.stubGlobal("fetch", mockFetch);
291
357
 
292
- await handleSharedAction({ action: "web_search", query: "test", limit: 8 }, 123);
358
+ await handleSharedAction(
359
+ { action: "web_search", query: "test", limit: 8 },
360
+ 123,
361
+ );
293
362
  const [url] = mockFetch.mock.calls[0];
294
363
  expect(url).toContain("count=8");
295
364
  });
296
365
 
297
366
  it("clamps search limit to 10", async () => {
298
367
  process.env.TALON_BRAVE_API_KEY = "test-brave-key";
299
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
300
- ok: true,
301
- json: { web: { results: [{ title: "R", url: "https://r.com", description: "d" }] } },
302
- }));
368
+ const mockFetch = vi.fn().mockResolvedValueOnce(
369
+ mockResponse({
370
+ ok: true,
371
+ json: {
372
+ web: {
373
+ results: [{ title: "R", url: "https://r.com", description: "d" }],
374
+ },
375
+ },
376
+ }),
377
+ );
303
378
  vi.stubGlobal("fetch", mockFetch);
304
379
 
305
- await handleSharedAction({ action: "web_search", query: "test", limit: 50 }, 123);
380
+ await handleSharedAction(
381
+ { action: "web_search", query: "test", limit: 50 },
382
+ 123,
383
+ );
306
384
  const [url] = mockFetch.mock.calls[0];
307
385
  expect(url).toContain("count=10");
308
386
  });
309
387
 
310
388
  it("falls back to SearXNG when Brave returns non-ok", async () => {
311
389
  process.env.TALON_BRAVE_API_KEY = "test-brave-key";
312
- const mockFetch = vi.fn()
313
- .mockResolvedValueOnce(mockResponse({ ok: false, status: 429 })) // Brave fails
314
- .mockResolvedValueOnce(mockResponse({
315
- ok: true,
316
- json: {
317
- results: [
318
- { title: "SearX Result", url: "https://searx.example.com", content: "SearX snippet" },
319
- ],
320
- },
321
- }));
390
+ const mockFetch = vi
391
+ .fn()
392
+ .mockResolvedValueOnce(mockResponse({ ok: false, status: 429 })) // Brave fails
393
+ .mockResolvedValueOnce(
394
+ mockResponse({
395
+ ok: true,
396
+ json: {
397
+ results: [
398
+ {
399
+ title: "SearX Result",
400
+ url: "https://searx.example.com",
401
+ content: "SearX snippet",
402
+ },
403
+ ],
404
+ },
405
+ }),
406
+ );
322
407
  vi.stubGlobal("fetch", mockFetch);
323
408
 
324
- const result = await handleSharedAction({ action: "web_search", query: "fallback test" }, 123);
409
+ const result = await handleSharedAction(
410
+ { action: "web_search", query: "fallback test" },
411
+ 123,
412
+ );
325
413
 
326
414
  expect(mockFetch).toHaveBeenCalledTimes(2);
327
415
  expect(result?.ok).toBe(true);
@@ -331,19 +419,29 @@ describe("gateway shared actions", () => {
331
419
 
332
420
  it("falls back to SearXNG when Brave throws an error", async () => {
333
421
  process.env.TALON_BRAVE_API_KEY = "test-brave-key";
334
- const mockFetch = vi.fn()
335
- .mockRejectedValueOnce(new Error("network error")) // Brave throws
336
- .mockResolvedValueOnce(mockResponse({
337
- ok: true,
338
- json: {
339
- results: [
340
- { title: "Fallback", url: "https://fb.com", content: "snippet" },
341
- ],
342
- },
343
- }));
422
+ const mockFetch = vi
423
+ .fn()
424
+ .mockRejectedValueOnce(new Error("network error")) // Brave throws
425
+ .mockResolvedValueOnce(
426
+ mockResponse({
427
+ ok: true,
428
+ json: {
429
+ results: [
430
+ {
431
+ title: "Fallback",
432
+ url: "https://fb.com",
433
+ content: "snippet",
434
+ },
435
+ ],
436
+ },
437
+ }),
438
+ );
344
439
  vi.stubGlobal("fetch", mockFetch);
345
440
 
346
- const result = await handleSharedAction({ action: "web_search", query: "test" }, 123);
441
+ const result = await handleSharedAction(
442
+ { action: "web_search", query: "test" },
443
+ 123,
444
+ );
347
445
 
348
446
  expect(result?.ok).toBe(true);
349
447
  expect(result?.text).toContain("via SearXNG");
@@ -351,17 +449,26 @@ describe("gateway shared actions", () => {
351
449
 
352
450
  it("uses SearXNG directly when no Brave key", async () => {
353
451
  // No TALON_BRAVE_API_KEY set
354
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
355
- ok: true,
356
- json: {
357
- results: [
358
- { title: "Direct SearX", url: "https://searx.com/r", content: "content here" },
359
- ],
360
- },
361
- }));
452
+ const mockFetch = vi.fn().mockResolvedValueOnce(
453
+ mockResponse({
454
+ ok: true,
455
+ json: {
456
+ results: [
457
+ {
458
+ title: "Direct SearX",
459
+ url: "https://searx.com/r",
460
+ content: "content here",
461
+ },
462
+ ],
463
+ },
464
+ }),
465
+ );
362
466
  vi.stubGlobal("fetch", mockFetch);
363
467
 
364
- const result = await handleSharedAction({ action: "web_search", query: "direct" }, 123);
468
+ const result = await handleSharedAction(
469
+ { action: "web_search", query: "direct" },
470
+ 123,
471
+ );
365
472
 
366
473
  expect(mockFetch).toHaveBeenCalledTimes(1);
367
474
  const [url] = mockFetch.mock.calls[0];
@@ -373,10 +480,14 @@ describe("gateway shared actions", () => {
373
480
 
374
481
  it("uses custom SearXNG URL from env", async () => {
375
482
  process.env.TALON_SEARXNG_URL = "http://my-searx:9090";
376
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
377
- ok: true,
378
- json: { results: [{ title: "T", url: "https://t.com", content: "c" }] },
379
- }));
483
+ const mockFetch = vi.fn().mockResolvedValueOnce(
484
+ mockResponse({
485
+ ok: true,
486
+ json: {
487
+ results: [{ title: "T", url: "https://t.com", content: "c" }],
488
+ },
489
+ }),
490
+ );
380
491
  vi.stubGlobal("fetch", mockFetch);
381
492
 
382
493
  await handleSharedAction({ action: "web_search", query: "custom" }, 123);
@@ -387,12 +498,16 @@ describe("gateway shared actions", () => {
387
498
 
388
499
  it("returns 'no results' when both providers fail", async () => {
389
500
  process.env.TALON_BRAVE_API_KEY = "test-key";
390
- const mockFetch = vi.fn()
501
+ const mockFetch = vi
502
+ .fn()
391
503
  .mockRejectedValueOnce(new Error("brave fail"))
392
504
  .mockRejectedValueOnce(new Error("searx fail"));
393
505
  vi.stubGlobal("fetch", mockFetch);
394
506
 
395
- const result = await handleSharedAction({ action: "web_search", query: "nothing" }, 123);
507
+ const result = await handleSharedAction(
508
+ { action: "web_search", query: "nothing" },
509
+ 123,
510
+ );
396
511
 
397
512
  expect(result?.ok).toBe(true);
398
513
  expect(result?.text).toBe('No results for "nothing".');
@@ -400,12 +515,16 @@ describe("gateway shared actions", () => {
400
515
 
401
516
  it("returns 'no results' when both return non-ok", async () => {
402
517
  process.env.TALON_BRAVE_API_KEY = "test-key";
403
- const mockFetch = vi.fn()
518
+ const mockFetch = vi
519
+ .fn()
404
520
  .mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
405
521
  .mockResolvedValueOnce(mockResponse({ ok: false, status: 503 }));
406
522
  vi.stubGlobal("fetch", mockFetch);
407
523
 
408
- const result = await handleSharedAction({ action: "web_search", query: "failing" }, 123);
524
+ const result = await handleSharedAction(
525
+ { action: "web_search", query: "failing" },
526
+ 123,
527
+ );
409
528
 
410
529
  expect(result?.ok).toBe(true);
411
530
  expect(result?.text).toBe('No results for "failing".');
@@ -413,12 +532,20 @@ describe("gateway shared actions", () => {
413
532
 
414
533
  it("returns 'no results' when Brave returns empty results array", async () => {
415
534
  process.env.TALON_BRAVE_API_KEY = "test-key";
416
- const mockFetch = vi.fn()
417
- .mockResolvedValueOnce(mockResponse({ ok: true, json: { web: { results: [] } } }))
418
- .mockResolvedValueOnce(mockResponse({ ok: true, json: { results: [] } }));
535
+ const mockFetch = vi
536
+ .fn()
537
+ .mockResolvedValueOnce(
538
+ mockResponse({ ok: true, json: { web: { results: [] } } }),
539
+ )
540
+ .mockResolvedValueOnce(
541
+ mockResponse({ ok: true, json: { results: [] } }),
542
+ );
419
543
  vi.stubGlobal("fetch", mockFetch);
420
544
 
421
- const result = await handleSharedAction({ action: "web_search", query: "empty" }, 123);
545
+ const result = await handleSharedAction(
546
+ { action: "web_search", query: "empty" },
547
+ 123,
548
+ );
422
549
 
423
550
  expect(result?.ok).toBe(true);
424
551
  expect(result?.text).toBe('No results for "empty".');
@@ -426,28 +553,49 @@ describe("gateway shared actions", () => {
426
553
 
427
554
  it("handles Brave response with missing web field", async () => {
428
555
  process.env.TALON_BRAVE_API_KEY = "test-key";
429
- const mockFetch = vi.fn()
430
- .mockResolvedValueOnce(mockResponse({ ok: true, json: {} })) // no web field
431
- .mockResolvedValueOnce(mockResponse({
432
- ok: true,
433
- json: { results: [{ title: "FallbackR", url: "https://f.com", content: "fb" }] },
434
- }));
556
+ const mockFetch = vi
557
+ .fn()
558
+ .mockResolvedValueOnce(mockResponse({ ok: true, json: {} })) // no web field
559
+ .mockResolvedValueOnce(
560
+ mockResponse({
561
+ ok: true,
562
+ json: {
563
+ results: [
564
+ { title: "FallbackR", url: "https://f.com", content: "fb" },
565
+ ],
566
+ },
567
+ }),
568
+ );
435
569
  vi.stubGlobal("fetch", mockFetch);
436
570
 
437
- const result = await handleSharedAction({ action: "web_search", query: "test" }, 123);
571
+ const result = await handleSharedAction(
572
+ { action: "web_search", query: "test" },
573
+ 123,
574
+ );
438
575
  expect(result?.text).toContain("via SearXNG");
439
576
  });
440
577
 
441
578
  it("truncates long snippets to 200 chars", async () => {
442
579
  process.env.TALON_BRAVE_API_KEY = "test-key";
443
580
  const longDesc = "A".repeat(500);
444
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
445
- ok: true,
446
- json: { web: { results: [{ title: "Long", url: "https://l.com", description: longDesc }] } },
447
- }));
581
+ const mockFetch = vi.fn().mockResolvedValueOnce(
582
+ mockResponse({
583
+ ok: true,
584
+ json: {
585
+ web: {
586
+ results: [
587
+ { title: "Long", url: "https://l.com", description: longDesc },
588
+ ],
589
+ },
590
+ },
591
+ }),
592
+ );
448
593
  vi.stubGlobal("fetch", mockFetch);
449
594
 
450
- const result = await handleSharedAction({ action: "web_search", query: "long" }, 123);
595
+ const result = await handleSharedAction(
596
+ { action: "web_search", query: "long" },
597
+ 123,
598
+ );
451
599
  // The snippet should be sliced to 200 chars
452
600
  expect(result?.text).not.toContain("A".repeat(201));
453
601
  expect(result?.text).toContain("A".repeat(200));
@@ -455,29 +603,43 @@ describe("gateway shared actions", () => {
455
603
 
456
604
  it("handles missing description in Brave results", async () => {
457
605
  process.env.TALON_BRAVE_API_KEY = "test-key";
458
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
459
- ok: true,
460
- json: { web: { results: [{ title: "NoDesc", url: "https://nd.com" }] } },
461
- }));
606
+ const mockFetch = vi.fn().mockResolvedValueOnce(
607
+ mockResponse({
608
+ ok: true,
609
+ json: {
610
+ web: { results: [{ title: "NoDesc", url: "https://nd.com" }] },
611
+ },
612
+ }),
613
+ );
462
614
  vi.stubGlobal("fetch", mockFetch);
463
615
 
464
- const result = await handleSharedAction({ action: "web_search", query: "nodesc" }, 123);
616
+ const result = await handleSharedAction(
617
+ { action: "web_search", query: "nodesc" },
618
+ 123,
619
+ );
465
620
  expect(result?.ok).toBe(true);
466
621
  expect(result?.text).toContain("NoDesc");
467
622
  });
468
623
 
469
624
  it("slices SearXNG results to limit", async () => {
470
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
471
- ok: true,
472
- json: {
473
- results: Array.from({ length: 20 }, (_, i) => ({
474
- title: `R${i}`, url: `https://r${i}.com`, content: `c${i}`,
475
- })),
476
- },
477
- }));
625
+ const mockFetch = vi.fn().mockResolvedValueOnce(
626
+ mockResponse({
627
+ ok: true,
628
+ json: {
629
+ results: Array.from({ length: 20 }, (_, i) => ({
630
+ title: `R${i}`,
631
+ url: `https://r${i}.com`,
632
+ content: `c${i}`,
633
+ })),
634
+ },
635
+ }),
636
+ );
478
637
  vi.stubGlobal("fetch", mockFetch);
479
638
 
480
- const result = await handleSharedAction({ action: "web_search", query: "many", limit: 3 }, 123);
639
+ const result = await handleSharedAction(
640
+ { action: "web_search", query: "many", limit: 3 },
641
+ 123,
642
+ );
481
643
  // Should only contain 3 results (numbered 1-3)
482
644
  expect(result?.text).toContain("1. R0");
483
645
  expect(result?.text).toContain("3. R2");
@@ -496,23 +658,35 @@ describe("gateway shared actions", () => {
496
658
  });
497
659
 
498
660
  it("rejects non-http protocols", async () => {
499
- const result = await handleSharedAction({ action: "fetch_url", url: "ftp://example.com" }, 123);
661
+ const result = await handleSharedAction(
662
+ { action: "fetch_url", url: "ftp://example.com" },
663
+ 123,
664
+ );
500
665
  expect(result?.ok).toBe(false);
501
666
  expect(result?.error).toContain("http or https");
502
667
  });
503
668
 
504
669
  it("rejects malformed URLs", async () => {
505
- const result = await handleSharedAction({ action: "fetch_url", url: "not a url at all" }, 123);
670
+ const result = await handleSharedAction(
671
+ { action: "fetch_url", url: "not a url at all" },
672
+ 123,
673
+ );
506
674
  expect(result).toEqual({ ok: false, error: "Invalid URL" });
507
675
  });
508
676
 
509
677
  it("rejects javascript: protocol", async () => {
510
- const result = await handleSharedAction({ action: "fetch_url", url: "javascript:alert(1)" }, 123);
678
+ const result = await handleSharedAction(
679
+ { action: "fetch_url", url: "javascript:alert(1)" },
680
+ 123,
681
+ );
511
682
  expect(result?.ok).toBe(false);
512
683
  });
513
684
 
514
685
  it("rejects data: protocol", async () => {
515
- const result = await handleSharedAction({ action: "fetch_url", url: "data:text/html,<h1>hi</h1>" }, 123);
686
+ const result = await handleSharedAction(
687
+ { action: "fetch_url", url: "data:text/html,<h1>hi</h1>" },
688
+ 123,
689
+ );
516
690
  expect(result?.ok).toBe(false);
517
691
  });
518
692
 
@@ -523,14 +697,19 @@ describe("gateway shared actions", () => {
523
697
  <h1>Hello World</h1>
524
698
  <p>This is a &amp; test with &lt;tags&gt; and &nbsp; entities.</p>
525
699
  </body></html>`;
526
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
527
- ok: true,
528
- contentType: "text/html; charset=utf-8",
529
- body: htmlBody,
530
- }));
700
+ const mockFetch = vi.fn().mockResolvedValueOnce(
701
+ mockResponse({
702
+ ok: true,
703
+ contentType: "text/html; charset=utf-8",
704
+ body: htmlBody,
705
+ }),
706
+ );
531
707
  vi.stubGlobal("fetch", mockFetch);
532
708
 
533
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com" }, 123);
709
+ const result = await handleSharedAction(
710
+ { action: "fetch_url", url: "https://example.com" },
711
+ 123,
712
+ );
534
713
 
535
714
  expect(result?.ok).toBe(true);
536
715
  // Script and style content should be stripped
@@ -543,14 +722,19 @@ describe("gateway shared actions", () => {
543
722
 
544
723
  it("returns JSON content as text", async () => {
545
724
  const jsonBody = '{"key": "value", "count": 42}';
546
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
547
- ok: true,
548
- contentType: "application/json",
549
- body: jsonBody,
550
- }));
725
+ const mockFetch = vi.fn().mockResolvedValueOnce(
726
+ mockResponse({
727
+ ok: true,
728
+ contentType: "application/json",
729
+ body: jsonBody,
730
+ }),
731
+ );
551
732
  vi.stubGlobal("fetch", mockFetch);
552
733
 
553
- const result = await handleSharedAction({ action: "fetch_url", url: "https://api.example.com/data" }, 123);
734
+ const result = await handleSharedAction(
735
+ { action: "fetch_url", url: "https://api.example.com/data" },
736
+ 123,
737
+ );
554
738
 
555
739
  expect(result?.ok).toBe(true);
556
740
  expect(result?.text).toContain('"key": "value"');
@@ -558,72 +742,104 @@ describe("gateway shared actions", () => {
558
742
 
559
743
  it("truncates large text content to 8000 chars", async () => {
560
744
  const longText = "A".repeat(10000);
561
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
562
- ok: true,
563
- contentType: "text/plain",
564
- body: longText,
565
- }));
745
+ const mockFetch = vi.fn().mockResolvedValueOnce(
746
+ mockResponse({
747
+ ok: true,
748
+ contentType: "text/plain",
749
+ body: longText,
750
+ }),
751
+ );
566
752
  vi.stubGlobal("fetch", mockFetch);
567
753
 
568
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/big" }, 123);
754
+ const result = await handleSharedAction(
755
+ { action: "fetch_url", url: "https://example.com/big" },
756
+ 123,
757
+ );
569
758
 
570
759
  expect(result?.ok).toBe(true);
571
760
  expect(result?.text!.length).toBe(8000);
572
761
  });
573
762
 
574
763
  it("returns message for pages with no readable content", async () => {
575
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
576
- ok: true,
577
- contentType: "text/html",
578
- body: "<html><body> </body></html>",
579
- }));
764
+ const mockFetch = vi.fn().mockResolvedValueOnce(
765
+ mockResponse({
766
+ ok: true,
767
+ contentType: "text/html",
768
+ body: "<html><body> </body></html>",
769
+ }),
770
+ );
580
771
  vi.stubGlobal("fetch", mockFetch);
581
772
 
582
- const result = await handleSharedAction({ action: "fetch_url", url: "https://empty.com" }, 123);
773
+ const result = await handleSharedAction(
774
+ { action: "fetch_url", url: "https://empty.com" },
775
+ 123,
776
+ );
583
777
 
584
778
  expect(result?.ok).toBe(true);
585
779
  expect(result?.text).toBe("(Page has no readable content)");
586
780
  });
587
781
 
588
782
  it("returns HTTP error for non-ok response", async () => {
589
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
590
- ok: false,
591
- status: 404,
592
- }));
783
+ const mockFetch = vi.fn().mockResolvedValueOnce(
784
+ mockResponse({
785
+ ok: false,
786
+ status: 404,
787
+ }),
788
+ );
593
789
  vi.stubGlobal("fetch", mockFetch);
594
790
 
595
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/missing" }, 123);
791
+ const result = await handleSharedAction(
792
+ { action: "fetch_url", url: "https://example.com/missing" },
793
+ 123,
794
+ );
596
795
 
597
796
  expect(result).toEqual({ ok: false, error: "HTTP 404" });
598
797
  });
599
798
 
600
799
  it("returns HTTP 500 error", async () => {
601
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
602
- ok: false,
603
- status: 500,
604
- }));
800
+ const mockFetch = vi.fn().mockResolvedValueOnce(
801
+ mockResponse({
802
+ ok: false,
803
+ status: 500,
804
+ }),
805
+ );
605
806
  vi.stubGlobal("fetch", mockFetch);
606
807
 
607
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/error" }, 123);
808
+ const result = await handleSharedAction(
809
+ { action: "fetch_url", url: "https://example.com/error" },
810
+ 123,
811
+ );
608
812
 
609
813
  expect(result).toEqual({ ok: false, error: "HTTP 500" });
610
814
  });
611
815
 
612
816
  it("handles network errors", async () => {
613
- const mockFetch = vi.fn().mockRejectedValueOnce(new Error("ECONNREFUSED"));
817
+ const mockFetch = vi
818
+ .fn()
819
+ .mockRejectedValueOnce(new Error("ECONNREFUSED"));
614
820
  vi.stubGlobal("fetch", mockFetch);
615
821
 
616
- const result = await handleSharedAction({ action: "fetch_url", url: "https://down.example.com" }, 123);
822
+ const result = await handleSharedAction(
823
+ { action: "fetch_url", url: "https://down.example.com" },
824
+ 123,
825
+ );
617
826
 
618
827
  expect(result?.ok).toBe(false);
619
828
  expect(result?.error).toContain("Fetch failed: ECONNREFUSED");
620
829
  });
621
830
 
622
831
  it("handles timeout errors", async () => {
623
- const mockFetch = vi.fn().mockRejectedValueOnce(new Error("The operation was aborted due to timeout"));
832
+ const mockFetch = vi
833
+ .fn()
834
+ .mockRejectedValueOnce(
835
+ new Error("The operation was aborted due to timeout"),
836
+ );
624
837
  vi.stubGlobal("fetch", mockFetch);
625
838
 
626
- const result = await handleSharedAction({ action: "fetch_url", url: "https://slow.example.com" }, 123);
839
+ const result = await handleSharedAction(
840
+ { action: "fetch_url", url: "https://slow.example.com" },
841
+ 123,
842
+ );
627
843
 
628
844
  expect(result?.ok).toBe(false);
629
845
  expect(result?.error).toContain("Fetch failed");
@@ -634,7 +850,10 @@ describe("gateway shared actions", () => {
634
850
  const mockFetch = vi.fn().mockRejectedValueOnce("string error");
635
851
  vi.stubGlobal("fetch", mockFetch);
636
852
 
637
- const result = await handleSharedAction({ action: "fetch_url", url: "https://weird.example.com" }, 123);
853
+ const result = await handleSharedAction(
854
+ { action: "fetch_url", url: "https://weird.example.com" },
855
+ 123,
856
+ );
638
857
 
639
858
  expect(result?.ok).toBe(false);
640
859
  expect(result?.error).toBe("Fetch failed: string error");
@@ -644,15 +863,20 @@ describe("gateway shared actions", () => {
644
863
 
645
864
  it("downloads binary PNG file", async () => {
646
865
  const buffer = imageBuffer("png");
647
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
648
- ok: true,
649
- contentType: "image/png",
650
- arrayBuffer: buffer,
651
- }));
866
+ const mockFetch = vi.fn().mockResolvedValueOnce(
867
+ mockResponse({
868
+ ok: true,
869
+ contentType: "image/png",
870
+ arrayBuffer: buffer,
871
+ }),
872
+ );
652
873
  vi.stubGlobal("fetch", mockFetch);
653
874
  mockExistsSync.mockReturnValue(true);
654
875
 
655
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
876
+ const result = await handleSharedAction(
877
+ { action: "fetch_url", url: "https://example.com/img.png" },
878
+ 123,
879
+ );
656
880
 
657
881
  expect(result?.ok).toBe(true);
658
882
  expect(result?.text).toContain("Downloaded image");
@@ -663,15 +887,20 @@ describe("gateway shared actions", () => {
663
887
 
664
888
  it("downloads JPEG file", async () => {
665
889
  const buffer = imageBuffer("jpg", 2048);
666
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
667
- ok: true,
668
- contentType: "image/jpeg",
669
- arrayBuffer: buffer,
670
- }));
890
+ const mockFetch = vi.fn().mockResolvedValueOnce(
891
+ mockResponse({
892
+ ok: true,
893
+ contentType: "image/jpeg",
894
+ arrayBuffer: buffer,
895
+ }),
896
+ );
671
897
  vi.stubGlobal("fetch", mockFetch);
672
898
  mockExistsSync.mockReturnValue(true);
673
899
 
674
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/photo.jpg" }, 123);
900
+ const result = await handleSharedAction(
901
+ { action: "fetch_url", url: "https://example.com/photo.jpg" },
902
+ 123,
903
+ );
675
904
 
676
905
  expect(result?.ok).toBe(true);
677
906
  expect(result?.text).toContain(".jpg");
@@ -680,14 +909,19 @@ describe("gateway shared actions", () => {
680
909
 
681
910
  it("downloads GIF file", async () => {
682
911
  const buffer = imageBuffer("gif", 512);
683
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
684
- ok: true,
685
- contentType: "image/gif",
686
- arrayBuffer: buffer,
687
- }));
912
+ const mockFetch = vi.fn().mockResolvedValueOnce(
913
+ mockResponse({
914
+ ok: true,
915
+ contentType: "image/gif",
916
+ arrayBuffer: buffer,
917
+ }),
918
+ );
688
919
  vi.stubGlobal("fetch", mockFetch);
689
920
 
690
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/anim.gif" }, 123);
921
+ const result = await handleSharedAction(
922
+ { action: "fetch_url", url: "https://example.com/anim.gif" },
923
+ 123,
924
+ );
691
925
 
692
926
  expect(result?.ok).toBe(true);
693
927
  expect(result?.text).toContain(".gif");
@@ -695,14 +929,19 @@ describe("gateway shared actions", () => {
695
929
 
696
930
  it("downloads WebP file", async () => {
697
931
  const buffer = imageBuffer("webp", 512);
698
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
699
- ok: true,
700
- contentType: "image/webp",
701
- arrayBuffer: buffer,
702
- }));
932
+ const mockFetch = vi.fn().mockResolvedValueOnce(
933
+ mockResponse({
934
+ ok: true,
935
+ contentType: "image/webp",
936
+ arrayBuffer: buffer,
937
+ }),
938
+ );
703
939
  vi.stubGlobal("fetch", mockFetch);
704
940
 
705
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/pic.webp" }, 123);
941
+ const result = await handleSharedAction(
942
+ { action: "fetch_url", url: "https://example.com/pic.webp" },
943
+ 123,
944
+ );
706
945
 
707
946
  expect(result?.ok).toBe(true);
708
947
  expect(result?.text).toContain(".webp");
@@ -710,14 +949,19 @@ describe("gateway shared actions", () => {
710
949
 
711
950
  it("downloads PDF file", async () => {
712
951
  const buffer = new ArrayBuffer(4096);
713
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
714
- ok: true,
715
- contentType: "application/pdf",
716
- arrayBuffer: buffer,
717
- }));
952
+ const mockFetch = vi.fn().mockResolvedValueOnce(
953
+ mockResponse({
954
+ ok: true,
955
+ contentType: "application/pdf",
956
+ arrayBuffer: buffer,
957
+ }),
958
+ );
718
959
  vi.stubGlobal("fetch", mockFetch);
719
960
 
720
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/doc.pdf" }, 123);
961
+ const result = await handleSharedAction(
962
+ { action: "fetch_url", url: "https://example.com/doc.pdf" },
963
+ 123,
964
+ );
721
965
 
722
966
  expect(result?.ok).toBe(true);
723
967
  expect(result?.text).toContain(".pdf");
@@ -726,14 +970,19 @@ describe("gateway shared actions", () => {
726
970
 
727
971
  it("downloads ZIP file", async () => {
728
972
  const buffer = new ArrayBuffer(8192);
729
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
730
- ok: true,
731
- contentType: "application/zip",
732
- arrayBuffer: buffer,
733
- }));
973
+ const mockFetch = vi.fn().mockResolvedValueOnce(
974
+ mockResponse({
975
+ ok: true,
976
+ contentType: "application/zip",
977
+ arrayBuffer: buffer,
978
+ }),
979
+ );
734
980
  vi.stubGlobal("fetch", mockFetch);
735
981
 
736
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/archive.zip" }, 123);
982
+ const result = await handleSharedAction(
983
+ { action: "fetch_url", url: "https://example.com/archive.zip" },
984
+ 123,
985
+ );
737
986
 
738
987
  expect(result?.ok).toBe(true);
739
988
  expect(result?.text).toContain(".zip");
@@ -741,14 +990,19 @@ describe("gateway shared actions", () => {
741
990
 
742
991
  it("uses .bin extension for unknown binary types", async () => {
743
992
  const buffer = new ArrayBuffer(256);
744
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
745
- ok: true,
746
- contentType: "application/octet-stream",
747
- arrayBuffer: buffer,
748
- }));
993
+ const mockFetch = vi.fn().mockResolvedValueOnce(
994
+ mockResponse({
995
+ ok: true,
996
+ contentType: "application/octet-stream",
997
+ arrayBuffer: buffer,
998
+ }),
999
+ );
749
1000
  vi.stubGlobal("fetch", mockFetch);
750
1001
 
751
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/data" }, 123);
1002
+ const result = await handleSharedAction(
1003
+ { action: "fetch_url", url: "https://example.com/data" },
1004
+ 123,
1005
+ );
752
1006
 
753
1007
  expect(result?.ok).toBe(true);
754
1008
  expect(result?.text).toContain(".bin");
@@ -756,44 +1010,62 @@ describe("gateway shared actions", () => {
756
1010
 
757
1011
  it("creates uploads directory when it does not exist", async () => {
758
1012
  const buffer = imageBuffer("png", 256);
759
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
760
- ok: true,
761
- contentType: "image/png",
762
- arrayBuffer: buffer,
763
- }));
1013
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1014
+ mockResponse({
1015
+ ok: true,
1016
+ contentType: "image/png",
1017
+ arrayBuffer: buffer,
1018
+ }),
1019
+ );
764
1020
  vi.stubGlobal("fetch", mockFetch);
765
1021
  mockExistsSync.mockReturnValue(false);
766
1022
 
767
- await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
1023
+ await handleSharedAction(
1024
+ { action: "fetch_url", url: "https://example.com/img.png" },
1025
+ 123,
1026
+ );
768
1027
 
769
- expect(mockMkdirSync).toHaveBeenCalledWith(expect.stringContaining("uploads"), { recursive: true });
1028
+ expect(mockMkdirSync).toHaveBeenCalledWith(
1029
+ expect.stringContaining("uploads"),
1030
+ { recursive: true },
1031
+ );
770
1032
  });
771
1033
 
772
1034
  it("does not create uploads directory when it exists", async () => {
773
1035
  const buffer = new ArrayBuffer(256);
774
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
775
- ok: true,
776
- contentType: "image/png",
777
- arrayBuffer: buffer,
778
- }));
1036
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1037
+ mockResponse({
1038
+ ok: true,
1039
+ contentType: "image/png",
1040
+ arrayBuffer: buffer,
1041
+ }),
1042
+ );
779
1043
  vi.stubGlobal("fetch", mockFetch);
780
1044
  mockExistsSync.mockReturnValue(true);
781
1045
 
782
- await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
1046
+ await handleSharedAction(
1047
+ { action: "fetch_url", url: "https://example.com/img.png" },
1048
+ 123,
1049
+ );
783
1050
 
784
1051
  expect(mockMkdirSync).not.toHaveBeenCalled();
785
1052
  });
786
1053
 
787
1054
  it("rejects files larger than 20MB", async () => {
788
1055
  const buffer = new ArrayBuffer(21 * 1024 * 1024); // 21MB
789
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
790
- ok: true,
791
- contentType: "image/png",
792
- arrayBuffer: buffer,
793
- }));
1056
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1057
+ mockResponse({
1058
+ ok: true,
1059
+ contentType: "image/png",
1060
+ arrayBuffer: buffer,
1061
+ }),
1062
+ );
794
1063
  vi.stubGlobal("fetch", mockFetch);
795
1064
 
796
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/huge.png" }, 123);
1065
+ const result = await handleSharedAction(
1066
+ { action: "fetch_url", url: "https://example.com/huge.png" },
1067
+ 123,
1068
+ );
797
1069
 
798
1070
  expect(result?.ok).toBe(false);
799
1071
  expect(result?.error).toContain("File too large");
@@ -803,29 +1075,41 @@ describe("gateway shared actions", () => {
803
1075
 
804
1076
  it("includes send instructions in download response", async () => {
805
1077
  const buffer = imageBuffer("png");
806
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
807
- ok: true,
808
- contentType: "image/png",
809
- arrayBuffer: buffer,
810
- }));
1078
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1079
+ mockResponse({
1080
+ ok: true,
1081
+ contentType: "image/png",
1082
+ arrayBuffer: buffer,
1083
+ }),
1084
+ );
811
1085
  vi.stubGlobal("fetch", mockFetch);
812
1086
 
813
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/img.png" }, 123);
1087
+ const result = await handleSharedAction(
1088
+ { action: "fetch_url", url: "https://example.com/img.png" },
1089
+ 123,
1090
+ );
814
1091
 
815
1092
  expect(result?.text).toContain("Read it with the Read tool");
816
1093
  expect(result?.text).toContain('send(type="file"');
817
1094
  });
818
1095
 
819
1096
  it("rejects HTML error page disguised as image (magic byte validation)", async () => {
820
- const htmlError = new TextEncoder().encode("<!DOCTYPE html><html><body>Wikimedia Error</body></html>");
821
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
822
- ok: true,
823
- contentType: "image/jpeg",
824
- arrayBuffer: htmlError.buffer,
825
- }));
1097
+ const htmlError = new TextEncoder().encode(
1098
+ "<!DOCTYPE html><html><body>Wikimedia Error</body></html>",
1099
+ );
1100
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1101
+ mockResponse({
1102
+ ok: true,
1103
+ contentType: "image/jpeg",
1104
+ arrayBuffer: htmlError.buffer,
1105
+ }),
1106
+ );
826
1107
  vi.stubGlobal("fetch", mockFetch);
827
1108
 
828
- const result = await handleSharedAction({ action: "fetch_url", url: "https://upload.wikimedia.org/img.jpg" }, 123);
1109
+ const result = await handleSharedAction(
1110
+ { action: "fetch_url", url: "https://upload.wikimedia.org/img.jpg" },
1111
+ 123,
1112
+ );
829
1113
 
830
1114
  expect(result?.ok).toBe(false);
831
1115
  expect(result?.error).toContain("error page instead of an image");
@@ -833,45 +1117,63 @@ describe("gateway shared actions", () => {
833
1117
  });
834
1118
 
835
1119
  it("rejects empty response", async () => {
836
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
837
- ok: true,
838
- contentType: "image/png",
839
- arrayBuffer: new ArrayBuffer(0),
840
- }));
1120
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1121
+ mockResponse({
1122
+ ok: true,
1123
+ contentType: "image/png",
1124
+ arrayBuffer: new ArrayBuffer(0),
1125
+ }),
1126
+ );
841
1127
  vi.stubGlobal("fetch", mockFetch);
842
1128
 
843
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/empty.png" }, 123);
1129
+ const result = await handleSharedAction(
1130
+ { action: "fetch_url", url: "https://example.com/empty.png" },
1131
+ 123,
1132
+ );
844
1133
 
845
1134
  expect(result?.ok).toBe(false);
846
1135
  expect(result?.error).toContain("Empty response");
847
1136
  });
848
1137
 
849
1138
  it("passes User-Agent header in fetch", async () => {
850
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
851
- ok: true,
852
- contentType: "text/html",
853
- body: "<p>Content here is enough to pass the 20 char minimum check test</p>",
854
- }));
1139
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1140
+ mockResponse({
1141
+ ok: true,
1142
+ contentType: "text/html",
1143
+ body: "<p>Content here is enough to pass the 20 char minimum check test</p>",
1144
+ }),
1145
+ );
855
1146
  vi.stubGlobal("fetch", mockFetch);
856
1147
 
857
- await handleSharedAction({ action: "fetch_url", url: "https://example.com" }, 123);
1148
+ await handleSharedAction(
1149
+ { action: "fetch_url", url: "https://example.com" },
1150
+ 123,
1151
+ );
858
1152
 
859
- expect(mockFetch).toHaveBeenCalledWith("https://example.com", expect.objectContaining({
860
- headers: { "User-Agent": "Talon/1.0" },
861
- redirect: "follow",
862
- }));
1153
+ expect(mockFetch).toHaveBeenCalledWith(
1154
+ "https://example.com",
1155
+ expect.objectContaining({
1156
+ headers: { "User-Agent": "Talon/1.0" },
1157
+ redirect: "follow",
1158
+ }),
1159
+ );
863
1160
  });
864
1161
 
865
1162
  it("labels non-image binary as content subtype", async () => {
866
1163
  const buffer = new ArrayBuffer(512);
867
- const mockFetch = vi.fn().mockResolvedValueOnce(mockResponse({
868
- ok: true,
869
- contentType: "application/pdf",
870
- arrayBuffer: buffer,
871
- }));
1164
+ const mockFetch = vi.fn().mockResolvedValueOnce(
1165
+ mockResponse({
1166
+ ok: true,
1167
+ contentType: "application/pdf",
1168
+ arrayBuffer: buffer,
1169
+ }),
1170
+ );
872
1171
  vi.stubGlobal("fetch", mockFetch);
873
1172
 
874
- const result = await handleSharedAction({ action: "fetch_url", url: "https://example.com/doc.pdf" }, 123);
1173
+ const result = await handleSharedAction(
1174
+ { action: "fetch_url", url: "https://example.com/doc.pdf" },
1175
+ 123,
1176
+ );
875
1177
 
876
1178
  // For non-image types, it uses ct.split("/")[1]?.split(";")[0] => "pdf"
877
1179
  expect(result?.text).toContain("Downloaded pdf");
@@ -887,95 +1189,128 @@ describe("gateway shared actions", () => {
887
1189
 
888
1190
  describe("create_cron_job", () => {
889
1191
  it("creates a cron job with all fields", async () => {
890
- const result = await handleSharedAction({
891
- action: "create_cron_job",
892
- name: "Morning Greeting",
893
- schedule: "0 9 * * *",
894
- type: "message",
895
- content: "Good morning!",
896
- timezone: "America/New_York",
897
- }, 42);
1192
+ const result = await handleSharedAction(
1193
+ {
1194
+ action: "create_cron_job",
1195
+ name: "Morning Greeting",
1196
+ schedule: "0 9 * * *",
1197
+ type: "message",
1198
+ content: "Good morning!",
1199
+ timezone: "America/New_York",
1200
+ },
1201
+ 42,
1202
+ );
898
1203
 
899
1204
  expect(result?.ok).toBe(true);
900
1205
  expect(result?.text).toContain('Created cron job "Morning Greeting"');
901
1206
  expect(result?.text).toContain("test-id-123");
902
1207
  expect(result?.text).toContain("0 9 * * *");
903
1208
  expect(result?.text).toContain("Type: message");
904
- expect(mockAddCronJob).toHaveBeenCalledWith(expect.objectContaining({
905
- id: "test-id-123",
906
- chatId: "42",
907
- schedule: "0 9 * * *",
908
- type: "message",
909
- content: "Good morning!",
910
- name: "Morning Greeting",
911
- enabled: true,
912
- timezone: "America/New_York",
913
- }));
1209
+ expect(mockAddCronJob).toHaveBeenCalledWith(
1210
+ expect.objectContaining({
1211
+ id: "test-id-123",
1212
+ chatId: "42",
1213
+ schedule: "0 9 * * *",
1214
+ type: "message",
1215
+ content: "Good morning!",
1216
+ name: "Morning Greeting",
1217
+ enabled: true,
1218
+ timezone: "America/New_York",
1219
+ }),
1220
+ );
914
1221
  });
915
1222
 
916
1223
  it("uses default name when not provided", async () => {
917
- await handleSharedAction({
918
- action: "create_cron_job",
919
- schedule: "0 9 * * *",
920
- content: "hi",
921
- }, 42);
922
-
923
- expect(mockAddCronJob).toHaveBeenCalledWith(expect.objectContaining({
924
- name: "Unnamed job",
925
- }));
1224
+ await handleSharedAction(
1225
+ {
1226
+ action: "create_cron_job",
1227
+ schedule: "0 9 * * *",
1228
+ content: "hi",
1229
+ },
1230
+ 42,
1231
+ );
1232
+
1233
+ expect(mockAddCronJob).toHaveBeenCalledWith(
1234
+ expect.objectContaining({
1235
+ name: "Unnamed job",
1236
+ }),
1237
+ );
926
1238
  });
927
1239
 
928
1240
  it("uses default type 'message' when not specified", async () => {
929
- await handleSharedAction({
930
- action: "create_cron_job",
931
- schedule: "*/5 * * * *",
932
- content: "ping",
933
- }, 42);
1241
+ await handleSharedAction(
1242
+ {
1243
+ action: "create_cron_job",
1244
+ schedule: "*/5 * * * *",
1245
+ content: "ping",
1246
+ },
1247
+ 42,
1248
+ );
934
1249
 
935
- expect(mockAddCronJob).toHaveBeenCalledWith(expect.objectContaining({
936
- type: "message",
937
- }));
1250
+ expect(mockAddCronJob).toHaveBeenCalledWith(
1251
+ expect.objectContaining({
1252
+ type: "message",
1253
+ }),
1254
+ );
938
1255
  });
939
1256
 
940
1257
  it("rejects missing schedule", async () => {
941
- const result = await handleSharedAction({
942
- action: "create_cron_job",
943
- name: "test",
944
- content: "hi",
945
- }, 123);
946
- expect(result).toEqual({ ok: false, error: "Missing schedule expression" });
1258
+ const result = await handleSharedAction(
1259
+ {
1260
+ action: "create_cron_job",
1261
+ name: "test",
1262
+ content: "hi",
1263
+ },
1264
+ 123,
1265
+ );
1266
+ expect(result).toEqual({
1267
+ ok: false,
1268
+ error: "Missing schedule expression",
1269
+ });
947
1270
  });
948
1271
 
949
1272
  it("rejects missing content", async () => {
950
- const result = await handleSharedAction({
951
- action: "create_cron_job",
952
- name: "test",
953
- schedule: "0 9 * * *",
954
- }, 123);
1273
+ const result = await handleSharedAction(
1274
+ {
1275
+ action: "create_cron_job",
1276
+ name: "test",
1277
+ schedule: "0 9 * * *",
1278
+ },
1279
+ 123,
1280
+ );
955
1281
  expect(result).toEqual({ ok: false, error: "Missing content" });
956
1282
  });
957
1283
 
958
1284
  it("rejects content over 10,000 chars", async () => {
959
- const result = await handleSharedAction({
960
- action: "create_cron_job",
961
- name: "test",
962
- schedule: "0 9 * * *",
963
- content: "x".repeat(10001),
964
- }, 123);
1285
+ const result = await handleSharedAction(
1286
+ {
1287
+ action: "create_cron_job",
1288
+ name: "test",
1289
+ schedule: "0 9 * * *",
1290
+ content: "x".repeat(10001),
1291
+ },
1292
+ 123,
1293
+ );
965
1294
  expect(result?.ok).toBe(false);
966
1295
  expect(result?.error).toContain("too long");
967
1296
  expect(result?.error).toContain("10,000");
968
1297
  });
969
1298
 
970
1299
  it("rejects invalid cron expression", async () => {
971
- mockValidateCronExpression.mockReturnValueOnce({ valid: false, error: "bad syntax" });
1300
+ mockValidateCronExpression.mockReturnValueOnce({
1301
+ valid: false,
1302
+ error: "bad syntax",
1303
+ });
972
1304
 
973
- const result = await handleSharedAction({
974
- action: "create_cron_job",
975
- name: "test",
976
- schedule: "not valid",
977
- content: "hi",
978
- }, 123);
1305
+ const result = await handleSharedAction(
1306
+ {
1307
+ action: "create_cron_job",
1308
+ name: "test",
1309
+ schedule: "not valid",
1310
+ content: "hi",
1311
+ },
1312
+ 123,
1313
+ );
979
1314
 
980
1315
  expect(result?.ok).toBe(false);
981
1316
  expect(result?.error).toContain("Invalid cron expression");
@@ -983,13 +1318,19 @@ describe("gateway shared actions", () => {
983
1318
  });
984
1319
 
985
1320
  it("includes next run time in response", async () => {
986
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: "2026-04-01T09:00:00.000Z" });
1321
+ mockValidateCronExpression.mockReturnValueOnce({
1322
+ valid: true,
1323
+ next: "2026-04-01T09:00:00.000Z",
1324
+ });
987
1325
 
988
- const result = await handleSharedAction({
989
- action: "create_cron_job",
990
- schedule: "0 9 * * *",
991
- content: "morning",
992
- }, 123);
1326
+ const result = await handleSharedAction(
1327
+ {
1328
+ action: "create_cron_job",
1329
+ schedule: "0 9 * * *",
1330
+ content: "morning",
1331
+ },
1332
+ 123,
1333
+ );
993
1334
 
994
1335
  expect(result?.text).toContain("2026-04-01T09:00:00.000Z");
995
1336
  });
@@ -997,34 +1338,49 @@ describe("gateway shared actions", () => {
997
1338
  it("shows 'unknown' when next run time is not available", async () => {
998
1339
  mockValidateCronExpression.mockReturnValueOnce({ valid: true });
999
1340
 
1000
- const result = await handleSharedAction({
1001
- action: "create_cron_job",
1002
- schedule: "0 9 * * *",
1003
- content: "morning",
1004
- }, 123);
1341
+ const result = await handleSharedAction(
1342
+ {
1343
+ action: "create_cron_job",
1344
+ schedule: "0 9 * * *",
1345
+ content: "morning",
1346
+ },
1347
+ 123,
1348
+ );
1005
1349
 
1006
1350
  expect(result?.text).toContain("Next run: unknown");
1007
1351
  });
1008
1352
 
1009
1353
  it("passes timezone to validation", async () => {
1010
- await handleSharedAction({
1011
- action: "create_cron_job",
1012
- schedule: "0 9 * * *",
1013
- content: "hi",
1014
- timezone: "Europe/London",
1015
- }, 123);
1354
+ await handleSharedAction(
1355
+ {
1356
+ action: "create_cron_job",
1357
+ schedule: "0 9 * * *",
1358
+ content: "hi",
1359
+ timezone: "Europe/London",
1360
+ },
1361
+ 123,
1362
+ );
1016
1363
 
1017
- expect(mockValidateCronExpression).toHaveBeenCalledWith("0 9 * * *", "Europe/London");
1364
+ expect(mockValidateCronExpression).toHaveBeenCalledWith(
1365
+ "0 9 * * *",
1366
+ "Europe/London",
1367
+ );
1018
1368
  });
1019
1369
 
1020
1370
  it("passes undefined timezone when not specified", async () => {
1021
- await handleSharedAction({
1022
- action: "create_cron_job",
1023
- schedule: "0 9 * * *",
1024
- content: "hi",
1025
- }, 123);
1371
+ await handleSharedAction(
1372
+ {
1373
+ action: "create_cron_job",
1374
+ schedule: "0 9 * * *",
1375
+ content: "hi",
1376
+ },
1377
+ 123,
1378
+ );
1026
1379
 
1027
- expect(mockValidateCronExpression).toHaveBeenCalledWith("0 9 * * *", undefined);
1380
+ expect(mockValidateCronExpression).toHaveBeenCalledWith(
1381
+ "0 9 * * *",
1382
+ undefined,
1383
+ );
1028
1384
  });
1029
1385
  });
1030
1386
 
@@ -1034,9 +1390,15 @@ describe("gateway shared actions", () => {
1034
1390
  it("returns 'no cron jobs' when empty", async () => {
1035
1391
  mockGetCronJobsForChat.mockReturnValue([]);
1036
1392
 
1037
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1393
+ const result = await handleSharedAction(
1394
+ { action: "list_cron_jobs" },
1395
+ 42,
1396
+ );
1038
1397
 
1039
- expect(result).toEqual({ ok: true, text: "No cron jobs in this chat." });
1398
+ expect(result).toEqual({
1399
+ ok: true,
1400
+ text: "No cron jobs in this chat.",
1401
+ });
1040
1402
  });
1041
1403
 
1042
1404
  it("lists existing jobs with all details", async () => {
@@ -1055,9 +1417,15 @@ describe("gateway shared actions", () => {
1055
1417
  timezone: "America/New_York",
1056
1418
  },
1057
1419
  ]);
1058
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date("2026-04-02T09:00:00Z").toISOString() });
1420
+ mockValidateCronExpression.mockReturnValueOnce({
1421
+ valid: true,
1422
+ next: new Date("2026-04-02T09:00:00Z").toISOString(),
1423
+ });
1059
1424
 
1060
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1425
+ const result = await handleSharedAction(
1426
+ { action: "list_cron_jobs" },
1427
+ 42,
1428
+ );
1061
1429
 
1062
1430
  expect(result?.ok).toBe(true);
1063
1431
  expect(result?.text).toContain("Cron jobs (1)");
@@ -1084,9 +1452,15 @@ describe("gateway shared actions", () => {
1084
1452
  runCount: 0,
1085
1453
  },
1086
1454
  ]);
1087
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date(Date.now() + 60000).toISOString() });
1455
+ mockValidateCronExpression.mockReturnValueOnce({
1456
+ valid: true,
1457
+ next: new Date(Date.now() + 60000).toISOString(),
1458
+ });
1088
1459
 
1089
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1460
+ const result = await handleSharedAction(
1461
+ { action: "list_cron_jobs" },
1462
+ 42,
1463
+ );
1090
1464
 
1091
1465
  expect(result?.text).toContain("Last: never");
1092
1466
  });
@@ -1108,7 +1482,10 @@ describe("gateway shared actions", () => {
1108
1482
  ]);
1109
1483
  mockValidateCronExpression.mockReturnValueOnce({ valid: true });
1110
1484
 
1111
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1485
+ const result = await handleSharedAction(
1486
+ { action: "list_cron_jobs" },
1487
+ 42,
1488
+ );
1112
1489
 
1113
1490
  expect(result?.text).toContain("Lunch Alert (disabled)");
1114
1491
  });
@@ -1129,7 +1506,10 @@ describe("gateway shared actions", () => {
1129
1506
  ]);
1130
1507
  mockValidateCronExpression.mockReturnValueOnce({ valid: false });
1131
1508
 
1132
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1509
+ const result = await handleSharedAction(
1510
+ { action: "list_cron_jobs" },
1511
+ 42,
1512
+ );
1133
1513
 
1134
1514
  expect(result?.text).toContain("Next: unknown");
1135
1515
  });
@@ -1149,9 +1529,15 @@ describe("gateway shared actions", () => {
1149
1529
  runCount: 0,
1150
1530
  },
1151
1531
  ]);
1152
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
1532
+ mockValidateCronExpression.mockReturnValueOnce({
1533
+ valid: true,
1534
+ next: new Date().toISOString(),
1535
+ });
1153
1536
 
1154
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1537
+ const result = await handleSharedAction(
1538
+ { action: "list_cron_jobs" },
1539
+ 42,
1540
+ );
1155
1541
 
1156
1542
  expect(result?.text).toContain("B".repeat(100) + "...");
1157
1543
  expect(result?.text).not.toContain("B".repeat(101));
@@ -1160,18 +1546,41 @@ describe("gateway shared actions", () => {
1160
1546
  it("lists multiple jobs", async () => {
1161
1547
  mockGetCronJobsForChat.mockReturnValue([
1162
1548
  {
1163
- id: "j1", chatId: "42", schedule: "0 9 * * *", type: "message",
1164
- content: "morning", name: "Job A", enabled: true, createdAt: 1700000000000, runCount: 1,
1549
+ id: "j1",
1550
+ chatId: "42",
1551
+ schedule: "0 9 * * *",
1552
+ type: "message",
1553
+ content: "morning",
1554
+ name: "Job A",
1555
+ enabled: true,
1556
+ createdAt: 1700000000000,
1557
+ runCount: 1,
1165
1558
  },
1166
1559
  {
1167
- id: "j2", chatId: "42", schedule: "0 17 * * *", type: "query",
1168
- content: "evening", name: "Job B", enabled: false, createdAt: 1700000000000, runCount: 2,
1560
+ id: "j2",
1561
+ chatId: "42",
1562
+ schedule: "0 17 * * *",
1563
+ type: "query",
1564
+ content: "evening",
1565
+ name: "Job B",
1566
+ enabled: false,
1567
+ createdAt: 1700000000000,
1568
+ runCount: 2,
1169
1569
  },
1170
1570
  ]);
1171
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
1172
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
1571
+ mockValidateCronExpression.mockReturnValueOnce({
1572
+ valid: true,
1573
+ next: new Date().toISOString(),
1574
+ });
1575
+ mockValidateCronExpression.mockReturnValueOnce({
1576
+ valid: true,
1577
+ next: new Date().toISOString(),
1578
+ });
1173
1579
 
1174
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1580
+ const result = await handleSharedAction(
1581
+ { action: "list_cron_jobs" },
1582
+ 42,
1583
+ );
1175
1584
 
1176
1585
  expect(result?.text).toContain("Cron jobs (2)");
1177
1586
  expect(result?.text).toContain("Job A");
@@ -1181,13 +1590,26 @@ describe("gateway shared actions", () => {
1181
1590
  it("shows no timezone when not set", async () => {
1182
1591
  mockGetCronJobsForChat.mockReturnValue([
1183
1592
  {
1184
- id: "j-tz", chatId: "42", schedule: "0 9 * * *", type: "message",
1185
- content: "hi", name: "No TZ", enabled: true, createdAt: 1700000000000, runCount: 0,
1593
+ id: "j-tz",
1594
+ chatId: "42",
1595
+ schedule: "0 9 * * *",
1596
+ type: "message",
1597
+ content: "hi",
1598
+ name: "No TZ",
1599
+ enabled: true,
1600
+ createdAt: 1700000000000,
1601
+ runCount: 0,
1186
1602
  },
1187
1603
  ]);
1188
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: new Date().toISOString() });
1604
+ mockValidateCronExpression.mockReturnValueOnce({
1605
+ valid: true,
1606
+ next: new Date().toISOString(),
1607
+ });
1189
1608
 
1190
- const result = await handleSharedAction({ action: "list_cron_jobs" }, 42);
1609
+ const result = await handleSharedAction(
1610
+ { action: "list_cron_jobs" },
1611
+ 42,
1612
+ );
1191
1613
 
1192
1614
  // Should show schedule without timezone in parens
1193
1615
  expect(result?.text).toContain("Schedule: 0 9 * * *");
@@ -1199,14 +1621,20 @@ describe("gateway shared actions", () => {
1199
1621
 
1200
1622
  describe("edit_cron_job", () => {
1201
1623
  it("rejects missing job_id", async () => {
1202
- const result = await handleSharedAction({ action: "edit_cron_job" }, 123);
1624
+ const result = await handleSharedAction(
1625
+ { action: "edit_cron_job" },
1626
+ 123,
1627
+ );
1203
1628
  expect(result).toEqual({ ok: false, error: "Missing job_id" });
1204
1629
  });
1205
1630
 
1206
1631
  it("rejects non-existent job", async () => {
1207
1632
  mockGetCronJob.mockReturnValue(undefined);
1208
1633
 
1209
- const result = await handleSharedAction({ action: "edit_cron_job", job_id: "nonexistent" }, 123);
1634
+ const result = await handleSharedAction(
1635
+ { action: "edit_cron_job", job_id: "nonexistent" },
1636
+ 123,
1637
+ );
1210
1638
 
1211
1639
  expect(result?.ok).toBe(false);
1212
1640
  expect(result?.error).toContain("not found");
@@ -1214,11 +1642,21 @@ describe("gateway shared actions", () => {
1214
1642
 
1215
1643
  it("rejects job from different chat", async () => {
1216
1644
  mockGetCronJob.mockReturnValue({
1217
- id: "job-x", chatId: "999", schedule: "0 9 * * *", type: "message",
1218
- content: "hi", name: "Other Chat", enabled: true, createdAt: 1700000000000, runCount: 0,
1645
+ id: "job-x",
1646
+ chatId: "999",
1647
+ schedule: "0 9 * * *",
1648
+ type: "message",
1649
+ content: "hi",
1650
+ name: "Other Chat",
1651
+ enabled: true,
1652
+ createdAt: 1700000000000,
1653
+ runCount: 0,
1219
1654
  });
1220
1655
 
1221
- const result = await handleSharedAction({ action: "edit_cron_job", job_id: "job-x" }, 123);
1656
+ const result = await handleSharedAction(
1657
+ { action: "edit_cron_job", job_id: "job-x" },
1658
+ 123,
1659
+ );
1222
1660
 
1223
1661
  expect(result?.ok).toBe(false);
1224
1662
  expect(result?.error).toBe("Job belongs to a different chat");
@@ -1226,79 +1664,156 @@ describe("gateway shared actions", () => {
1226
1664
 
1227
1665
  it("updates job name", async () => {
1228
1666
  mockGetCronJob.mockReturnValue({
1229
- id: "job-e1", chatId: "123", schedule: "0 9 * * *", type: "message",
1230
- content: "hi", name: "Old Name", enabled: true, createdAt: 1700000000000, runCount: 0,
1667
+ id: "job-e1",
1668
+ chatId: "123",
1669
+ schedule: "0 9 * * *",
1670
+ type: "message",
1671
+ content: "hi",
1672
+ name: "Old Name",
1673
+ enabled: true,
1674
+ createdAt: 1700000000000,
1675
+ runCount: 0,
1231
1676
  });
1232
1677
  mockUpdateCronJob.mockReturnValue({ name: "New Name" });
1233
1678
 
1234
- const result = await handleSharedAction({
1235
- action: "edit_cron_job", job_id: "job-e1", name: "New Name",
1236
- }, 123);
1679
+ const result = await handleSharedAction(
1680
+ {
1681
+ action: "edit_cron_job",
1682
+ job_id: "job-e1",
1683
+ name: "New Name",
1684
+ },
1685
+ 123,
1686
+ );
1237
1687
 
1238
1688
  expect(result?.ok).toBe(true);
1239
1689
  expect(result?.text).toContain("New Name");
1240
1690
  expect(result?.text).toContain("Fields changed: name");
1241
- expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e1", { name: "New Name" });
1691
+ expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e1", {
1692
+ name: "New Name",
1693
+ });
1242
1694
  });
1243
1695
 
1244
1696
  it("updates content", async () => {
1245
1697
  mockGetCronJob.mockReturnValue({
1246
- id: "job-e2", chatId: "123", schedule: "0 9 * * *", type: "message",
1247
- content: "old", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1698
+ id: "job-e2",
1699
+ chatId: "123",
1700
+ schedule: "0 9 * * *",
1701
+ type: "message",
1702
+ content: "old",
1703
+ name: "Job",
1704
+ enabled: true,
1705
+ createdAt: 1700000000000,
1706
+ runCount: 0,
1248
1707
  });
1249
1708
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1250
1709
 
1251
- const result = await handleSharedAction({
1252
- action: "edit_cron_job", job_id: "job-e2", content: "new content",
1253
- }, 123);
1710
+ const result = await handleSharedAction(
1711
+ {
1712
+ action: "edit_cron_job",
1713
+ job_id: "job-e2",
1714
+ content: "new content",
1715
+ },
1716
+ 123,
1717
+ );
1254
1718
 
1255
1719
  expect(result?.ok).toBe(true);
1256
- expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e2", { content: "new content" });
1720
+ expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e2", {
1721
+ content: "new content",
1722
+ });
1257
1723
  });
1258
1724
 
1259
1725
  it("updates enabled flag", async () => {
1260
1726
  mockGetCronJob.mockReturnValue({
1261
- id: "job-e3", chatId: "123", schedule: "0 9 * * *", type: "message",
1262
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1727
+ id: "job-e3",
1728
+ chatId: "123",
1729
+ schedule: "0 9 * * *",
1730
+ type: "message",
1731
+ content: "hi",
1732
+ name: "Job",
1733
+ enabled: true,
1734
+ createdAt: 1700000000000,
1735
+ runCount: 0,
1263
1736
  });
1264
1737
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1265
1738
 
1266
- const result = await handleSharedAction({
1267
- action: "edit_cron_job", job_id: "job-e3", enabled: false,
1268
- }, 123);
1739
+ const result = await handleSharedAction(
1740
+ {
1741
+ action: "edit_cron_job",
1742
+ job_id: "job-e3",
1743
+ enabled: false,
1744
+ },
1745
+ 123,
1746
+ );
1269
1747
 
1270
1748
  expect(result?.ok).toBe(true);
1271
- expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e3", { enabled: false });
1749
+ expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e3", {
1750
+ enabled: false,
1751
+ });
1272
1752
  });
1273
1753
 
1274
1754
  it("updates schedule with validation", async () => {
1275
1755
  mockGetCronJob.mockReturnValue({
1276
- id: "job-e4", chatId: "123", schedule: "0 9 * * *", type: "message",
1277
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1756
+ id: "job-e4",
1757
+ chatId: "123",
1758
+ schedule: "0 9 * * *",
1759
+ type: "message",
1760
+ content: "hi",
1761
+ name: "Job",
1762
+ enabled: true,
1763
+ createdAt: 1700000000000,
1764
+ runCount: 0,
1278
1765
  timezone: "America/New_York",
1279
1766
  });
1280
- mockValidateCronExpression.mockReturnValueOnce({ valid: true, next: "2026-05-01T12:00:00Z" });
1767
+ mockValidateCronExpression.mockReturnValueOnce({
1768
+ valid: true,
1769
+ next: "2026-05-01T12:00:00Z",
1770
+ });
1281
1771
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1282
1772
 
1283
- const result = await handleSharedAction({
1284
- action: "edit_cron_job", job_id: "job-e4", schedule: "0 12 * * *",
1285
- }, 123);
1773
+ const result = await handleSharedAction(
1774
+ {
1775
+ action: "edit_cron_job",
1776
+ job_id: "job-e4",
1777
+ schedule: "0 12 * * *",
1778
+ },
1779
+ 123,
1780
+ );
1286
1781
 
1287
1782
  expect(result?.ok).toBe(true);
1288
- expect(mockValidateCronExpression).toHaveBeenCalledWith("0 12 * * *", "America/New_York");
1289
- expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e4", { schedule: "0 12 * * *" });
1783
+ expect(mockValidateCronExpression).toHaveBeenCalledWith(
1784
+ "0 12 * * *",
1785
+ "America/New_York",
1786
+ );
1787
+ expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e4", {
1788
+ schedule: "0 12 * * *",
1789
+ });
1290
1790
  });
1291
1791
 
1292
1792
  it("rejects invalid schedule update", async () => {
1293
1793
  mockGetCronJob.mockReturnValue({
1294
- id: "job-e5", chatId: "123", schedule: "0 9 * * *", type: "message",
1295
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1794
+ id: "job-e5",
1795
+ chatId: "123",
1796
+ schedule: "0 9 * * *",
1797
+ type: "message",
1798
+ content: "hi",
1799
+ name: "Job",
1800
+ enabled: true,
1801
+ createdAt: 1700000000000,
1802
+ runCount: 0,
1803
+ });
1804
+ mockValidateCronExpression.mockReturnValueOnce({
1805
+ valid: false,
1806
+ error: "too many fields",
1296
1807
  });
1297
- mockValidateCronExpression.mockReturnValueOnce({ valid: false, error: "too many fields" });
1298
1808
 
1299
- const result = await handleSharedAction({
1300
- action: "edit_cron_job", job_id: "job-e5", schedule: "bad schedule",
1301
- }, 123);
1809
+ const result = await handleSharedAction(
1810
+ {
1811
+ action: "edit_cron_job",
1812
+ job_id: "job-e5",
1813
+ schedule: "bad schedule",
1814
+ },
1815
+ 123,
1816
+ );
1302
1817
 
1303
1818
  expect(result?.ok).toBe(false);
1304
1819
  expect(result?.error).toContain("Invalid cron expression");
@@ -1308,98 +1823,187 @@ describe("gateway shared actions", () => {
1308
1823
 
1309
1824
  it("updates multiple fields at once", async () => {
1310
1825
  mockGetCronJob.mockReturnValue({
1311
- id: "job-e6", chatId: "123", schedule: "0 9 * * *", type: "message",
1312
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1826
+ id: "job-e6",
1827
+ chatId: "123",
1828
+ schedule: "0 9 * * *",
1829
+ type: "message",
1830
+ content: "hi",
1831
+ name: "Job",
1832
+ enabled: true,
1833
+ createdAt: 1700000000000,
1834
+ runCount: 0,
1313
1835
  });
1314
1836
  mockUpdateCronJob.mockReturnValue({ name: "Updated" });
1315
1837
 
1316
- const result = await handleSharedAction({
1317
- action: "edit_cron_job", job_id: "job-e6",
1318
- name: "Updated", content: "new", enabled: false, type: "query",
1319
- }, 123);
1838
+ const result = await handleSharedAction(
1839
+ {
1840
+ action: "edit_cron_job",
1841
+ job_id: "job-e6",
1842
+ name: "Updated",
1843
+ content: "new",
1844
+ enabled: false,
1845
+ type: "query",
1846
+ },
1847
+ 123,
1848
+ );
1320
1849
 
1321
1850
  expect(result?.ok).toBe(true);
1322
- expect(result?.text).toContain("Fields changed: name, content, enabled, type");
1851
+ expect(result?.text).toContain(
1852
+ "Fields changed: name, content, enabled, type",
1853
+ );
1323
1854
  expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e6", {
1324
- name: "Updated", content: "new", enabled: false, type: "query",
1855
+ name: "Updated",
1856
+ content: "new",
1857
+ enabled: false,
1858
+ type: "query",
1325
1859
  });
1326
1860
  });
1327
1861
 
1328
1862
  it("updates timezone", async () => {
1329
1863
  mockGetCronJob.mockReturnValue({
1330
- id: "job-e7", chatId: "123", schedule: "0 9 * * *", type: "message",
1331
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1864
+ id: "job-e7",
1865
+ chatId: "123",
1866
+ schedule: "0 9 * * *",
1867
+ type: "message",
1868
+ content: "hi",
1869
+ name: "Job",
1870
+ enabled: true,
1871
+ createdAt: 1700000000000,
1872
+ runCount: 0,
1332
1873
  });
1333
1874
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1334
1875
 
1335
- const result = await handleSharedAction({
1336
- action: "edit_cron_job", job_id: "job-e7", timezone: "Europe/Berlin",
1337
- }, 123);
1876
+ const result = await handleSharedAction(
1877
+ {
1878
+ action: "edit_cron_job",
1879
+ job_id: "job-e7",
1880
+ timezone: "Europe/Berlin",
1881
+ },
1882
+ 123,
1883
+ );
1338
1884
 
1339
1885
  expect(result?.ok).toBe(true);
1340
- expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e7", { timezone: "Europe/Berlin" });
1886
+ expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e7", {
1887
+ timezone: "Europe/Berlin",
1888
+ });
1341
1889
  });
1342
1890
 
1343
1891
  it("clears timezone when set to empty", async () => {
1344
1892
  mockGetCronJob.mockReturnValue({
1345
- id: "job-e8", chatId: "123", schedule: "0 9 * * *", type: "message",
1346
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1893
+ id: "job-e8",
1894
+ chatId: "123",
1895
+ schedule: "0 9 * * *",
1896
+ type: "message",
1897
+ content: "hi",
1898
+ name: "Job",
1899
+ enabled: true,
1900
+ createdAt: 1700000000000,
1901
+ runCount: 0,
1347
1902
  timezone: "America/New_York",
1348
1903
  });
1349
1904
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1350
1905
 
1351
- const result = await handleSharedAction({
1352
- action: "edit_cron_job", job_id: "job-e8", timezone: "",
1353
- }, 123);
1906
+ const result = await handleSharedAction(
1907
+ {
1908
+ action: "edit_cron_job",
1909
+ job_id: "job-e8",
1910
+ timezone: "",
1911
+ },
1912
+ 123,
1913
+ );
1354
1914
 
1355
1915
  expect(result?.ok).toBe(true);
1356
- expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e8", { timezone: undefined });
1916
+ expect(mockUpdateCronJob).toHaveBeenCalledWith("job-e8", {
1917
+ timezone: undefined,
1918
+ });
1357
1919
  });
1358
1920
 
1359
1921
  it("uses new timezone for schedule validation when both change", async () => {
1360
1922
  mockGetCronJob.mockReturnValue({
1361
- id: "job-e9", chatId: "123", schedule: "0 9 * * *", type: "message",
1362
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1923
+ id: "job-e9",
1924
+ chatId: "123",
1925
+ schedule: "0 9 * * *",
1926
+ type: "message",
1927
+ content: "hi",
1928
+ name: "Job",
1929
+ enabled: true,
1930
+ createdAt: 1700000000000,
1931
+ runCount: 0,
1363
1932
  timezone: "America/New_York",
1364
1933
  });
1365
1934
  mockValidateCronExpression.mockReturnValueOnce({ valid: true });
1366
1935
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1367
1936
 
1368
- await handleSharedAction({
1369
- action: "edit_cron_job", job_id: "job-e9",
1370
- schedule: "0 12 * * *", timezone: "Asia/Tokyo",
1371
- }, 123);
1937
+ await handleSharedAction(
1938
+ {
1939
+ action: "edit_cron_job",
1940
+ job_id: "job-e9",
1941
+ schedule: "0 12 * * *",
1942
+ timezone: "Asia/Tokyo",
1943
+ },
1944
+ 123,
1945
+ );
1372
1946
 
1373
1947
  // Should validate with the NEW timezone, not the old one
1374
- expect(mockValidateCronExpression).toHaveBeenCalledWith("0 12 * * *", "Asia/Tokyo");
1948
+ expect(mockValidateCronExpression).toHaveBeenCalledWith(
1949
+ "0 12 * * *",
1950
+ "Asia/Tokyo",
1951
+ );
1375
1952
  });
1376
1953
 
1377
1954
  it("falls back to job timezone when schedule changes but timezone does not", async () => {
1378
1955
  mockGetCronJob.mockReturnValue({
1379
- id: "job-e10", chatId: "123", schedule: "0 9 * * *", type: "message",
1380
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1956
+ id: "job-e10",
1957
+ chatId: "123",
1958
+ schedule: "0 9 * * *",
1959
+ type: "message",
1960
+ content: "hi",
1961
+ name: "Job",
1962
+ enabled: true,
1963
+ createdAt: 1700000000000,
1964
+ runCount: 0,
1381
1965
  timezone: "US/Pacific",
1382
1966
  });
1383
1967
  mockValidateCronExpression.mockReturnValueOnce({ valid: true });
1384
1968
  mockUpdateCronJob.mockReturnValue({ name: "Job" });
1385
1969
 
1386
- await handleSharedAction({
1387
- action: "edit_cron_job", job_id: "job-e10", schedule: "30 8 * * 1-5",
1388
- }, 123);
1970
+ await handleSharedAction(
1971
+ {
1972
+ action: "edit_cron_job",
1973
+ job_id: "job-e10",
1974
+ schedule: "30 8 * * 1-5",
1975
+ },
1976
+ 123,
1977
+ );
1389
1978
 
1390
- expect(mockValidateCronExpression).toHaveBeenCalledWith("30 8 * * 1-5", "US/Pacific");
1979
+ expect(mockValidateCronExpression).toHaveBeenCalledWith(
1980
+ "30 8 * * 1-5",
1981
+ "US/Pacific",
1982
+ );
1391
1983
  });
1392
1984
 
1393
1985
  it("uses job_id as fallback name when updateCronJob returns no name", async () => {
1394
1986
  mockGetCronJob.mockReturnValue({
1395
- id: "job-e11", chatId: "123", schedule: "0 9 * * *", type: "message",
1396
- content: "hi", name: "Job", enabled: true, createdAt: 1700000000000, runCount: 0,
1987
+ id: "job-e11",
1988
+ chatId: "123",
1989
+ schedule: "0 9 * * *",
1990
+ type: "message",
1991
+ content: "hi",
1992
+ name: "Job",
1993
+ enabled: true,
1994
+ createdAt: 1700000000000,
1995
+ runCount: 0,
1397
1996
  });
1398
1997
  mockUpdateCronJob.mockReturnValue(undefined);
1399
1998
 
1400
- const result = await handleSharedAction({
1401
- action: "edit_cron_job", job_id: "job-e11", content: "new",
1402
- }, 123);
1999
+ const result = await handleSharedAction(
2000
+ {
2001
+ action: "edit_cron_job",
2002
+ job_id: "job-e11",
2003
+ content: "new",
2004
+ },
2005
+ 123,
2006
+ );
1403
2007
 
1404
2008
  expect(result?.ok).toBe(true);
1405
2009
  expect(result?.text).toContain("job-e11");
@@ -1410,14 +2014,20 @@ describe("gateway shared actions", () => {
1410
2014
 
1411
2015
  describe("delete_cron_job", () => {
1412
2016
  it("rejects missing job_id", async () => {
1413
- const result = await handleSharedAction({ action: "delete_cron_job" }, 123);
2017
+ const result = await handleSharedAction(
2018
+ { action: "delete_cron_job" },
2019
+ 123,
2020
+ );
1414
2021
  expect(result).toEqual({ ok: false, error: "Missing job_id" });
1415
2022
  });
1416
2023
 
1417
2024
  it("rejects non-existent job", async () => {
1418
2025
  mockGetCronJob.mockReturnValue(undefined);
1419
2026
 
1420
- const result = await handleSharedAction({ action: "delete_cron_job", job_id: "ghost" }, 123);
2027
+ const result = await handleSharedAction(
2028
+ { action: "delete_cron_job", job_id: "ghost" },
2029
+ 123,
2030
+ );
1421
2031
 
1422
2032
  expect(result?.ok).toBe(false);
1423
2033
  expect(result?.error).toContain("not found");
@@ -1425,11 +2035,21 @@ describe("gateway shared actions", () => {
1425
2035
 
1426
2036
  it("rejects job from different chat", async () => {
1427
2037
  mockGetCronJob.mockReturnValue({
1428
- id: "job-d1", chatId: "999", schedule: "0 9 * * *", type: "message",
1429
- content: "hi", name: "Other", enabled: true, createdAt: 1700000000000, runCount: 0,
2038
+ id: "job-d1",
2039
+ chatId: "999",
2040
+ schedule: "0 9 * * *",
2041
+ type: "message",
2042
+ content: "hi",
2043
+ name: "Other",
2044
+ enabled: true,
2045
+ createdAt: 1700000000000,
2046
+ runCount: 0,
1430
2047
  });
1431
2048
 
1432
- const result = await handleSharedAction({ action: "delete_cron_job", job_id: "job-d1" }, 123);
2049
+ const result = await handleSharedAction(
2050
+ { action: "delete_cron_job", job_id: "job-d1" },
2051
+ 123,
2052
+ );
1433
2053
 
1434
2054
  expect(result?.ok).toBe(false);
1435
2055
  expect(result?.error).toBe("Job belongs to a different chat");
@@ -1437,11 +2057,21 @@ describe("gateway shared actions", () => {
1437
2057
 
1438
2058
  it("deletes a job successfully", async () => {
1439
2059
  mockGetCronJob.mockReturnValue({
1440
- id: "job-d2", chatId: "123", schedule: "0 9 * * *", type: "message",
1441
- content: "hi", name: "To Delete", enabled: true, createdAt: 1700000000000, runCount: 5,
2060
+ id: "job-d2",
2061
+ chatId: "123",
2062
+ schedule: "0 9 * * *",
2063
+ type: "message",
2064
+ content: "hi",
2065
+ name: "To Delete",
2066
+ enabled: true,
2067
+ createdAt: 1700000000000,
2068
+ runCount: 5,
1442
2069
  });
1443
2070
 
1444
- const result = await handleSharedAction({ action: "delete_cron_job", job_id: "job-d2" }, 123);
2071
+ const result = await handleSharedAction(
2072
+ { action: "delete_cron_job", job_id: "job-d2" },
2073
+ 123,
2074
+ );
1445
2075
 
1446
2076
  expect(result?.ok).toBe(true);
1447
2077
  expect(result?.text).toContain('Deleted cron job "To Delete"');
@@ -1451,3 +2081,114 @@ describe("gateway shared actions", () => {
1451
2081
  });
1452
2082
  });
1453
2083
  });
2084
+
2085
+ // ── Additional branch coverage for fetch_url and web_search ──────────────
2086
+
2087
+ describe("gateway-actions — additional branch coverage", () => {
2088
+ let originalFetch: typeof globalThis.fetch;
2089
+
2090
+ beforeEach(() => {
2091
+ originalFetch = globalThis.fetch;
2092
+ delete process.env.TALON_BRAVE_API_KEY;
2093
+ });
2094
+
2095
+ afterEach(() => {
2096
+ globalThis.fetch = originalFetch;
2097
+ });
2098
+
2099
+ it("rejects fetch_url when Content-Length header exceeds 20MB (line 152 TRUE branch)", async () => {
2100
+ // Build a response that has a Content-Length header > 20MB
2101
+ const headers = new Headers();
2102
+ headers.set("content-type", "text/html");
2103
+ headers.set("content-length", String(25 * 1024 * 1024)); // 25MB
2104
+ const bigResponse = {
2105
+ ok: true,
2106
+ status: 200,
2107
+ headers,
2108
+ text: async () => "some text",
2109
+ json: async () => ({}),
2110
+ arrayBuffer: async () => new ArrayBuffer(0),
2111
+ } as unknown as Response;
2112
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(bigResponse));
2113
+
2114
+ const result = await handleSharedAction(
2115
+ { action: "fetch_url", url: "https://example.com/huge.html" },
2116
+ 123,
2117
+ );
2118
+
2119
+ expect(result?.ok).toBe(false);
2120
+ expect(result?.error).toContain("too large");
2121
+ });
2122
+
2123
+ it("uses empty string when content-type header is absent in fetch_url (line 146 FALSE branch)", async () => {
2124
+ // Response with no content-type header and binary content → falls through to binary path
2125
+ // ct="" → mimeType="" → isText=false → binary download
2126
+ const noCtHeaders = new Headers(); // no content-type
2127
+ const buf = new ArrayBuffer(16);
2128
+ const view = new Uint8Array(buf);
2129
+ view[0] = 0x25; // not a known image magic byte → ext = "bin"
2130
+ const noCtResponse = {
2131
+ ok: true,
2132
+ status: 200,
2133
+ headers: noCtHeaders,
2134
+ text: async () => "",
2135
+ json: async () => ({}),
2136
+ arrayBuffer: async () => buf,
2137
+ } as unknown as Response;
2138
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(noCtResponse));
2139
+
2140
+ const result = await handleSharedAction(
2141
+ { action: "fetch_url", url: "https://example.com/unknown" },
2142
+ 123,
2143
+ );
2144
+ // Should succeed (downloaded as bin), covering ct ?? "" right side and ct.split("/")[1] ?? "file" right side
2145
+ expect(result?.ok).toBe(true);
2146
+ });
2147
+
2148
+ it("handles search result with missing content field (line 113 r.content ?? '' branch)", async () => {
2149
+ const mockFetch = vi.fn().mockResolvedValueOnce({
2150
+ ok: true,
2151
+ status: 200,
2152
+ headers: new Headers(),
2153
+ json: async () => ({
2154
+ results: [
2155
+ { title: "Result", url: "https://example.com", content: undefined },
2156
+ ],
2157
+ }),
2158
+ text: async () => "",
2159
+ arrayBuffer: async () => new ArrayBuffer(0),
2160
+ } as unknown as Response);
2161
+ vi.stubGlobal("fetch", mockFetch);
2162
+ process.env.TALON_SEARXNG_URL = "http://localhost:8080";
2163
+
2164
+ const result = await handleSharedAction(
2165
+ { action: "web_search", query: "test" },
2166
+ 123,
2167
+ );
2168
+ expect(result?.ok).toBe(true);
2169
+ // snippet should be "" (from ?? "")
2170
+ expect(result?.text).toBeDefined();
2171
+ });
2172
+
2173
+ it("handles search response with no results array (line 113 data.results ?? [] branch)", async () => {
2174
+ const mockFetch = vi.fn().mockResolvedValueOnce({
2175
+ ok: true,
2176
+ status: 200,
2177
+ headers: new Headers(),
2178
+ json: async () => ({
2179
+ /* no results property */
2180
+ }),
2181
+ text: async () => "",
2182
+ arrayBuffer: async () => new ArrayBuffer(0),
2183
+ } as unknown as Response);
2184
+ vi.stubGlobal("fetch", mockFetch);
2185
+ process.env.TALON_SEARXNG_URL = "http://localhost:8080";
2186
+
2187
+ const result = await handleSharedAction(
2188
+ { action: "web_search", query: "empty" },
2189
+ 123,
2190
+ );
2191
+ expect(result?.ok).toBe(true);
2192
+ expect(result?.text).toContain("No results for");
2193
+ });
2194
+ });