talon-agent 1.3.0 → 1.5.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/package.json +4 -2
- package/prompts/heartbeat.md +18 -6
- package/src/__tests__/compose-tools.test.ts +216 -0
- package/src/__tests__/fuzz.test.ts +0 -2
- package/src/__tests__/gateway-actions.test.ts +1 -423
- package/src/__tests__/heartbeat.test.ts +21 -0
- package/src/__tests__/reload-plugins.test.ts +199 -0
- package/src/__tests__/sessions.test.ts +155 -121
- package/src/backend/claude-sdk/index.ts +230 -109
- package/src/backend/opencode/index.ts +5 -20
- package/src/bootstrap.ts +8 -44
- package/src/core/gateway-actions.ts +42 -88
- package/src/core/heartbeat.ts +8 -5
- package/src/core/plugin.ts +147 -0
- package/src/core/tools/admin.ts +22 -0
- package/src/core/tools/bridge.ts +40 -0
- package/src/core/tools/chat.ts +52 -0
- package/src/core/tools/history.ts +80 -0
- package/src/core/tools/index.ts +84 -0
- package/src/core/tools/mcp-server.ts +64 -0
- package/src/core/tools/media.ts +23 -0
- package/src/core/tools/members.ts +46 -0
- package/src/core/tools/messaging.ts +300 -0
- package/src/core/tools/scheduling.ts +89 -0
- package/src/core/tools/stickers.ts +143 -0
- package/src/core/tools/types.ts +61 -0
- package/src/core/tools/web.ts +26 -0
- package/src/frontend/teams/index.ts +9 -10
- package/src/frontend/telegram/actions.ts +10 -1
- package/src/frontend/telegram/commands.ts +11 -10
- package/src/plugins/github/index.ts +106 -0
- package/src/plugins/playwright/index.ts +82 -0
- package/src/storage/sessions.ts +34 -50
- package/src/util/config.ts +20 -1
- package/src/util/log.ts +3 -1
- package/src/backend/claude-sdk/tools.ts +0 -651
- package/src/frontend/teams/tools.ts +0 -175
|
@@ -125,9 +125,6 @@ describe("gateway shared actions", () => {
|
|
|
125
125
|
originalFetch = globalThis.fetch;
|
|
126
126
|
originalEnv = { ...process.env };
|
|
127
127
|
vi.clearAllMocks();
|
|
128
|
-
// Reset env vars used by web_search
|
|
129
|
-
delete process.env.TALON_BRAVE_API_KEY;
|
|
130
|
-
delete process.env.TALON_SEARXNG_URL;
|
|
131
128
|
});
|
|
132
129
|
|
|
133
130
|
afterEach(() => {
|
|
@@ -276,377 +273,6 @@ describe("gateway shared actions", () => {
|
|
|
276
273
|
});
|
|
277
274
|
});
|
|
278
275
|
|
|
279
|
-
// ════════════════════════════════════════════════════════════════════════
|
|
280
|
-
// web_search
|
|
281
|
-
// ════════════════════════════════════════════════════════════════════════
|
|
282
|
-
|
|
283
|
-
describe("web_search", () => {
|
|
284
|
-
it("returns error for missing query", async () => {
|
|
285
|
-
const result = await handleSharedAction({ action: "web_search" }, 123);
|
|
286
|
-
expect(result).toEqual({ ok: false, error: "Missing query" });
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("returns error for empty query string", async () => {
|
|
290
|
-
const result = await handleSharedAction(
|
|
291
|
-
{ action: "web_search", query: "" },
|
|
292
|
-
123,
|
|
293
|
-
);
|
|
294
|
-
expect(result).toEqual({ ok: false, error: "Missing query" });
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it("uses Brave API when key is configured", async () => {
|
|
298
|
-
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
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
|
-
},
|
|
318
|
-
},
|
|
319
|
-
}),
|
|
320
|
-
);
|
|
321
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
322
|
-
|
|
323
|
-
const result = await handleSharedAction(
|
|
324
|
-
{ action: "web_search", query: "test query" },
|
|
325
|
-
123,
|
|
326
|
-
);
|
|
327
|
-
|
|
328
|
-
expect(result?.ok).toBe(true);
|
|
329
|
-
expect(result?.text).toContain("via Brave");
|
|
330
|
-
expect(result?.text).toContain("Result 1");
|
|
331
|
-
expect(result?.text).toContain("https://example.com/1");
|
|
332
|
-
expect(result?.text).toContain("Description 1");
|
|
333
|
-
expect(result?.text).toContain("Result 2");
|
|
334
|
-
|
|
335
|
-
// Verify Brave API was called correctly
|
|
336
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
337
|
-
const [url, opts] = mockFetch.mock.calls[0];
|
|
338
|
-
expect(url).toContain("api.search.brave.com");
|
|
339
|
-
expect(url).toContain("q=test%20query");
|
|
340
|
-
expect(url).toContain("count=5");
|
|
341
|
-
expect(opts.headers["X-Subscription-Token"]).toBe("test-brave-key");
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("respects custom limit for Brave API", async () => {
|
|
345
|
-
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
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
|
-
);
|
|
356
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
357
|
-
|
|
358
|
-
await handleSharedAction(
|
|
359
|
-
{ action: "web_search", query: "test", limit: 8 },
|
|
360
|
-
123,
|
|
361
|
-
);
|
|
362
|
-
const [url] = mockFetch.mock.calls[0];
|
|
363
|
-
expect(url).toContain("count=8");
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it("clamps search limit to 10", async () => {
|
|
367
|
-
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
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
|
-
);
|
|
378
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
379
|
-
|
|
380
|
-
await handleSharedAction(
|
|
381
|
-
{ action: "web_search", query: "test", limit: 50 },
|
|
382
|
-
123,
|
|
383
|
-
);
|
|
384
|
-
const [url] = mockFetch.mock.calls[0];
|
|
385
|
-
expect(url).toContain("count=10");
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it("falls back to SearXNG when Brave returns non-ok", async () => {
|
|
389
|
-
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
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
|
-
);
|
|
407
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
408
|
-
|
|
409
|
-
const result = await handleSharedAction(
|
|
410
|
-
{ action: "web_search", query: "fallback test" },
|
|
411
|
-
123,
|
|
412
|
-
);
|
|
413
|
-
|
|
414
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
415
|
-
expect(result?.ok).toBe(true);
|
|
416
|
-
expect(result?.text).toContain("via SearXNG");
|
|
417
|
-
expect(result?.text).toContain("SearX Result");
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it("falls back to SearXNG when Brave throws an error", async () => {
|
|
421
|
-
process.env.TALON_BRAVE_API_KEY = "test-brave-key";
|
|
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
|
-
);
|
|
439
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
440
|
-
|
|
441
|
-
const result = await handleSharedAction(
|
|
442
|
-
{ action: "web_search", query: "test" },
|
|
443
|
-
123,
|
|
444
|
-
);
|
|
445
|
-
|
|
446
|
-
expect(result?.ok).toBe(true);
|
|
447
|
-
expect(result?.text).toContain("via SearXNG");
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
it("uses SearXNG directly when no Brave key", async () => {
|
|
451
|
-
// No TALON_BRAVE_API_KEY set
|
|
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
|
-
);
|
|
466
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
467
|
-
|
|
468
|
-
const result = await handleSharedAction(
|
|
469
|
-
{ action: "web_search", query: "direct" },
|
|
470
|
-
123,
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
474
|
-
const [url] = mockFetch.mock.calls[0];
|
|
475
|
-
expect(url).toContain("localhost:8080");
|
|
476
|
-
expect(url).toContain("format=json");
|
|
477
|
-
expect(result?.ok).toBe(true);
|
|
478
|
-
expect(result?.text).toContain("via SearXNG");
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
it("uses custom SearXNG URL from env", async () => {
|
|
482
|
-
process.env.TALON_SEARXNG_URL = "http://my-searx:9090";
|
|
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
|
-
);
|
|
491
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
492
|
-
|
|
493
|
-
await handleSharedAction({ action: "web_search", query: "custom" }, 123);
|
|
494
|
-
|
|
495
|
-
const [url] = mockFetch.mock.calls[0];
|
|
496
|
-
expect(url).toContain("my-searx:9090");
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("returns 'no results' when both providers fail", async () => {
|
|
500
|
-
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
501
|
-
const mockFetch = vi
|
|
502
|
-
.fn()
|
|
503
|
-
.mockRejectedValueOnce(new Error("brave fail"))
|
|
504
|
-
.mockRejectedValueOnce(new Error("searx fail"));
|
|
505
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
506
|
-
|
|
507
|
-
const result = await handleSharedAction(
|
|
508
|
-
{ action: "web_search", query: "nothing" },
|
|
509
|
-
123,
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
expect(result?.ok).toBe(true);
|
|
513
|
-
expect(result?.text).toBe('No results for "nothing".');
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
it("returns 'no results' when both return non-ok", async () => {
|
|
517
|
-
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
518
|
-
const mockFetch = vi
|
|
519
|
-
.fn()
|
|
520
|
-
.mockResolvedValueOnce(mockResponse({ ok: false, status: 500 }))
|
|
521
|
-
.mockResolvedValueOnce(mockResponse({ ok: false, status: 503 }));
|
|
522
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
523
|
-
|
|
524
|
-
const result = await handleSharedAction(
|
|
525
|
-
{ action: "web_search", query: "failing" },
|
|
526
|
-
123,
|
|
527
|
-
);
|
|
528
|
-
|
|
529
|
-
expect(result?.ok).toBe(true);
|
|
530
|
-
expect(result?.text).toBe('No results for "failing".');
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it("returns 'no results' when Brave returns empty results array", async () => {
|
|
534
|
-
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
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
|
-
);
|
|
543
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
544
|
-
|
|
545
|
-
const result = await handleSharedAction(
|
|
546
|
-
{ action: "web_search", query: "empty" },
|
|
547
|
-
123,
|
|
548
|
-
);
|
|
549
|
-
|
|
550
|
-
expect(result?.ok).toBe(true);
|
|
551
|
-
expect(result?.text).toBe('No results for "empty".');
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it("handles Brave response with missing web field", async () => {
|
|
555
|
-
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
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
|
-
);
|
|
569
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
570
|
-
|
|
571
|
-
const result = await handleSharedAction(
|
|
572
|
-
{ action: "web_search", query: "test" },
|
|
573
|
-
123,
|
|
574
|
-
);
|
|
575
|
-
expect(result?.text).toContain("via SearXNG");
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it("truncates long snippets to 200 chars", async () => {
|
|
579
|
-
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
580
|
-
const longDesc = "A".repeat(500);
|
|
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
|
-
);
|
|
593
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
594
|
-
|
|
595
|
-
const result = await handleSharedAction(
|
|
596
|
-
{ action: "web_search", query: "long" },
|
|
597
|
-
123,
|
|
598
|
-
);
|
|
599
|
-
// The snippet should be sliced to 200 chars
|
|
600
|
-
expect(result?.text).not.toContain("A".repeat(201));
|
|
601
|
-
expect(result?.text).toContain("A".repeat(200));
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
it("handles missing description in Brave results", async () => {
|
|
605
|
-
process.env.TALON_BRAVE_API_KEY = "test-key";
|
|
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
|
-
);
|
|
614
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
615
|
-
|
|
616
|
-
const result = await handleSharedAction(
|
|
617
|
-
{ action: "web_search", query: "nodesc" },
|
|
618
|
-
123,
|
|
619
|
-
);
|
|
620
|
-
expect(result?.ok).toBe(true);
|
|
621
|
-
expect(result?.text).toContain("NoDesc");
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it("slices SearXNG results to limit", async () => {
|
|
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
|
-
);
|
|
637
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
638
|
-
|
|
639
|
-
const result = await handleSharedAction(
|
|
640
|
-
{ action: "web_search", query: "many", limit: 3 },
|
|
641
|
-
123,
|
|
642
|
-
);
|
|
643
|
-
// Should only contain 3 results (numbered 1-3)
|
|
644
|
-
expect(result?.text).toContain("1. R0");
|
|
645
|
-
expect(result?.text).toContain("3. R2");
|
|
646
|
-
expect(result?.text).not.toContain("4. R3");
|
|
647
|
-
});
|
|
648
|
-
});
|
|
649
|
-
|
|
650
276
|
// ════════════════════════════════════════════════════════════════════════
|
|
651
277
|
// fetch_url
|
|
652
278
|
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -2082,14 +1708,13 @@ describe("gateway shared actions", () => {
|
|
|
2082
1708
|
});
|
|
2083
1709
|
});
|
|
2084
1710
|
|
|
2085
|
-
// ── Additional branch coverage for fetch_url
|
|
1711
|
+
// ── Additional branch coverage for fetch_url ──────────────────────────────
|
|
2086
1712
|
|
|
2087
1713
|
describe("gateway-actions — additional branch coverage", () => {
|
|
2088
1714
|
let originalFetch: typeof globalThis.fetch;
|
|
2089
1715
|
|
|
2090
1716
|
beforeEach(() => {
|
|
2091
1717
|
originalFetch = globalThis.fetch;
|
|
2092
|
-
delete process.env.TALON_BRAVE_API_KEY;
|
|
2093
1718
|
});
|
|
2094
1719
|
|
|
2095
1720
|
afterEach(() => {
|
|
@@ -2144,51 +1769,4 @@ describe("gateway-actions — additional branch coverage", () => {
|
|
|
2144
1769
|
// Should succeed (downloaded as bin), covering ct ?? "" right side and ct.split("/")[1] ?? "file" right side
|
|
2145
1770
|
expect(result?.ok).toBe(true);
|
|
2146
1771
|
});
|
|
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
1772
|
});
|
|
@@ -48,6 +48,10 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
|
48
48
|
query: queryMock,
|
|
49
49
|
}));
|
|
50
50
|
|
|
51
|
+
vi.mock("../core/plugin.js", () => ({
|
|
52
|
+
getPluginMcpServers: vi.fn(() => ({})),
|
|
53
|
+
}));
|
|
54
|
+
|
|
51
55
|
vi.mock("../util/paths.js", () => ({
|
|
52
56
|
files: {
|
|
53
57
|
heartbeatState: "/fake/.talon/workspace/memory/heartbeat_state.json",
|
|
@@ -184,6 +188,23 @@ describe("forceHeartbeat", () => {
|
|
|
184
188
|
expect(finalState.status).toBe("idle");
|
|
185
189
|
});
|
|
186
190
|
|
|
191
|
+
it("passes plugin MCP servers to the agent via getPluginMcpServers", async () => {
|
|
192
|
+
const { getPluginMcpServers } = await import("../core/plugin.js");
|
|
193
|
+
const mockServers = {
|
|
194
|
+
"email-tools": { command: "node", args: ["email.js"], env: {} },
|
|
195
|
+
};
|
|
196
|
+
vi.mocked(getPluginMcpServers).mockReturnValue(mockServers);
|
|
197
|
+
|
|
198
|
+
await forceHeartbeat();
|
|
199
|
+
|
|
200
|
+
expect(getPluginMcpServers).toHaveBeenCalledWith("", "heartbeat");
|
|
201
|
+
// Verify mcpServers was passed through to query()
|
|
202
|
+
const queryCall = queryMock.mock.calls[0] as unknown as [
|
|
203
|
+
{ options: { mcpServers: Record<string, unknown> } },
|
|
204
|
+
];
|
|
205
|
+
expect(queryCall[0].options.mcpServers).toEqual(mockServers);
|
|
206
|
+
});
|
|
207
|
+
|
|
187
208
|
it("preserves previous last_run on failure", async () => {
|
|
188
209
|
const previousLastRun = Date.now() - 3600_000;
|
|
189
210
|
existsSyncMock.mockReturnValue(true);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Module mocks ──────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
vi.mock("../util/log.js", () => ({
|
|
6
|
+
log: vi.fn(),
|
|
7
|
+
logError: vi.fn(),
|
|
8
|
+
logWarn: vi.fn(),
|
|
9
|
+
logDebug: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("write-file-atomic", () => ({
|
|
13
|
+
default: { sync: vi.fn() },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock cheerio (required by gateway-actions via extractText)
|
|
17
|
+
vi.mock("cheerio", () => ({
|
|
18
|
+
load: vi.fn(() => {
|
|
19
|
+
const $ = (sel: string) => ({
|
|
20
|
+
remove: vi.fn(),
|
|
21
|
+
text: () => "",
|
|
22
|
+
});
|
|
23
|
+
($ as any).root = vi.fn();
|
|
24
|
+
return $;
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock storage modules required by gateway-actions
|
|
29
|
+
vi.mock("../storage/history.js", () => ({
|
|
30
|
+
getRecentFormatted: vi.fn(() => ""),
|
|
31
|
+
searchHistory: vi.fn(() => ""),
|
|
32
|
+
getMessagesByUser: vi.fn(() => ""),
|
|
33
|
+
getKnownUsers: vi.fn(() => ""),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock("../storage/media-index.js", () => ({
|
|
36
|
+
formatMediaIndex: vi.fn(() => ""),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock("../storage/cron-store.js", () => ({
|
|
39
|
+
addCronJob: vi.fn(),
|
|
40
|
+
getCronJob: vi.fn(),
|
|
41
|
+
getCronJobsForChat: vi.fn(() => []),
|
|
42
|
+
updateCronJob: vi.fn(),
|
|
43
|
+
deleteCronJob: vi.fn(),
|
|
44
|
+
validateCronExpression: vi.fn(() => ({ valid: true })),
|
|
45
|
+
generateCronId: vi.fn(() => "test-id"),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// ── Plugin mocking ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const DEFAULT_CONFIG = {
|
|
51
|
+
model: "claude-opus-4-6",
|
|
52
|
+
frontend: "telegram",
|
|
53
|
+
plugins: [],
|
|
54
|
+
systemPrompt: "test prompt",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const mockReloadPlugins = vi.fn(async () => ({
|
|
58
|
+
names: ["extras", "brave-search"],
|
|
59
|
+
config: { ...DEFAULT_CONFIG },
|
|
60
|
+
}));
|
|
61
|
+
const mockGetPluginPromptAdditions = vi.fn(() => "prompt additions");
|
|
62
|
+
const mockRebuildSystemPrompt = vi.fn();
|
|
63
|
+
const mockUpdateSystemPrompt = vi.fn();
|
|
64
|
+
|
|
65
|
+
vi.mock("../core/plugin.js", () => ({
|
|
66
|
+
reloadPlugins: (...args: unknown[]) =>
|
|
67
|
+
mockReloadPlugins(...(args as Parameters<typeof mockReloadPlugins>)),
|
|
68
|
+
getPluginPromptAdditions: () => mockGetPluginPromptAdditions(),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
vi.mock("../util/config.js", () => ({
|
|
72
|
+
rebuildSystemPrompt: (...args: unknown[]) =>
|
|
73
|
+
mockRebuildSystemPrompt(
|
|
74
|
+
...(args as Parameters<typeof mockRebuildSystemPrompt>),
|
|
75
|
+
),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
vi.mock("../backend/claude-sdk/index.js", () => ({
|
|
79
|
+
updateSystemPrompt: (...args: unknown[]) =>
|
|
80
|
+
mockUpdateSystemPrompt(
|
|
81
|
+
...(args as Parameters<typeof mockUpdateSystemPrompt>),
|
|
82
|
+
),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// ── Import after mocks ────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
import { handleSharedAction } from "../core/gateway-actions.js";
|
|
88
|
+
|
|
89
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
describe("reload_plugins gateway action", () => {
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
vi.resetAllMocks();
|
|
94
|
+
// Re-establish default implementations after reset
|
|
95
|
+
mockReloadPlugins.mockImplementation(async () => ({
|
|
96
|
+
names: ["extras", "brave-search"],
|
|
97
|
+
config: { ...DEFAULT_CONFIG },
|
|
98
|
+
}));
|
|
99
|
+
mockGetPluginPromptAdditions.mockReturnValue("prompt additions");
|
|
100
|
+
mockRebuildSystemPrompt.mockImplementation(() => {});
|
|
101
|
+
mockUpdateSystemPrompt.mockImplementation(() => {});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("returns loaded plugin names on success", async () => {
|
|
105
|
+
const result = await handleSharedAction(
|
|
106
|
+
{ action: "reload_plugins" },
|
|
107
|
+
12345,
|
|
108
|
+
);
|
|
109
|
+
expect(result).not.toBeNull();
|
|
110
|
+
expect(result!.ok).toBe(true);
|
|
111
|
+
expect(result!.text).toContain("Plugins reloaded successfully");
|
|
112
|
+
expect(result!.text).toContain("extras");
|
|
113
|
+
expect(result!.text).toContain("brave-search");
|
|
114
|
+
expect(result!.text).toContain("(2)");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("calls reloadPlugins without explicit frontends (derived from config)", async () => {
|
|
118
|
+
await handleSharedAction({ action: "reload_plugins" }, 12345);
|
|
119
|
+
// Gateway no longer passes frontends — reloadPlugins derives them from config
|
|
120
|
+
expect(mockReloadPlugins).toHaveBeenCalledWith();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rebuilds system prompt after reloading", async () => {
|
|
124
|
+
await handleSharedAction({ action: "reload_plugins" }, 12345);
|
|
125
|
+
expect(mockRebuildSystemPrompt).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(mockGetPluginPromptAdditions).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("updates backend system prompt after rebuild", async () => {
|
|
130
|
+
await handleSharedAction({ action: "reload_plugins" }, 12345);
|
|
131
|
+
expect(mockUpdateSystemPrompt).toHaveBeenCalledTimes(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns error when reloadPlugins throws", async () => {
|
|
135
|
+
mockReloadPlugins.mockRejectedValueOnce(
|
|
136
|
+
new Error("Config validation failed"),
|
|
137
|
+
);
|
|
138
|
+
const result = await handleSharedAction(
|
|
139
|
+
{ action: "reload_plugins" },
|
|
140
|
+
12345,
|
|
141
|
+
);
|
|
142
|
+
expect(result).not.toBeNull();
|
|
143
|
+
expect(result!.ok).toBe(false);
|
|
144
|
+
expect(result!.error).toContain("Config validation failed");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns error when config is malformed", async () => {
|
|
148
|
+
mockReloadPlugins.mockRejectedValueOnce(
|
|
149
|
+
new Error("Invalid JSON in config"),
|
|
150
|
+
);
|
|
151
|
+
const result = await handleSharedAction(
|
|
152
|
+
{ action: "reload_plugins" },
|
|
153
|
+
12345,
|
|
154
|
+
);
|
|
155
|
+
expect(result!.ok).toBe(false);
|
|
156
|
+
expect(result!.error).toContain("Invalid JSON in config");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("reports zero plugins when none configured", async () => {
|
|
160
|
+
mockReloadPlugins.mockImplementation(async () => ({
|
|
161
|
+
names: [],
|
|
162
|
+
config: { ...DEFAULT_CONFIG },
|
|
163
|
+
}));
|
|
164
|
+
const result = await handleSharedAction(
|
|
165
|
+
{ action: "reload_plugins" },
|
|
166
|
+
12345,
|
|
167
|
+
);
|
|
168
|
+
expect(result!.ok).toBe(true);
|
|
169
|
+
expect(result!.text).toContain("(0)");
|
|
170
|
+
expect(result!.text).toContain("(none)");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Admin tool description tests ──────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe("admin tool description", () => {
|
|
177
|
+
it("does not mention session reset or MCP subprocesses", async () => {
|
|
178
|
+
const { adminTools } = await import("../core/tools/admin.js");
|
|
179
|
+
const reloadTool = adminTools.find((t) => t.name === "reload_plugins");
|
|
180
|
+
expect(reloadTool).toBeDefined();
|
|
181
|
+
expect(reloadTool!.description).not.toContain("resets sessions");
|
|
182
|
+
expect(reloadTool!.description).not.toContain("sessions reset");
|
|
183
|
+
expect(reloadTool!.description).not.toContain("MCP subprocesses");
|
|
184
|
+
expect(reloadTool!.description).toContain("without restarting");
|
|
185
|
+
expect(reloadTool!.description).toContain("without downtime");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("mentions env var cleanup", async () => {
|
|
189
|
+
const { adminTools } = await import("../core/tools/admin.js");
|
|
190
|
+
const reloadTool = adminTools.find((t) => t.name === "reload_plugins");
|
|
191
|
+
expect(reloadTool!.description).toContain("env vars");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("has admin tag", async () => {
|
|
195
|
+
const { adminTools } = await import("../core/tools/admin.js");
|
|
196
|
+
const reloadTool = adminTools.find((t) => t.name === "reload_plugins");
|
|
197
|
+
expect(reloadTool!.tag).toBe("admin");
|
|
198
|
+
});
|
|
199
|
+
});
|