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.
Files changed (37) hide show
  1. package/package.json +4 -2
  2. package/prompts/heartbeat.md +18 -6
  3. package/src/__tests__/compose-tools.test.ts +216 -0
  4. package/src/__tests__/fuzz.test.ts +0 -2
  5. package/src/__tests__/gateway-actions.test.ts +1 -423
  6. package/src/__tests__/heartbeat.test.ts +21 -0
  7. package/src/__tests__/reload-plugins.test.ts +199 -0
  8. package/src/__tests__/sessions.test.ts +155 -121
  9. package/src/backend/claude-sdk/index.ts +230 -109
  10. package/src/backend/opencode/index.ts +5 -20
  11. package/src/bootstrap.ts +8 -44
  12. package/src/core/gateway-actions.ts +42 -88
  13. package/src/core/heartbeat.ts +8 -5
  14. package/src/core/plugin.ts +147 -0
  15. package/src/core/tools/admin.ts +22 -0
  16. package/src/core/tools/bridge.ts +40 -0
  17. package/src/core/tools/chat.ts +52 -0
  18. package/src/core/tools/history.ts +80 -0
  19. package/src/core/tools/index.ts +84 -0
  20. package/src/core/tools/mcp-server.ts +64 -0
  21. package/src/core/tools/media.ts +23 -0
  22. package/src/core/tools/members.ts +46 -0
  23. package/src/core/tools/messaging.ts +300 -0
  24. package/src/core/tools/scheduling.ts +89 -0
  25. package/src/core/tools/stickers.ts +143 -0
  26. package/src/core/tools/types.ts +61 -0
  27. package/src/core/tools/web.ts +26 -0
  28. package/src/frontend/teams/index.ts +9 -10
  29. package/src/frontend/telegram/actions.ts +10 -1
  30. package/src/frontend/telegram/commands.ts +11 -10
  31. package/src/plugins/github/index.ts +106 -0
  32. package/src/plugins/playwright/index.ts +82 -0
  33. package/src/storage/sessions.ts +34 -50
  34. package/src/util/config.ts +20 -1
  35. package/src/util/log.ts +3 -1
  36. package/src/backend/claude-sdk/tools.ts +0 -651
  37. 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 and web_search ──────────────
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
+ });