talon-agent 1.6.1 → 1.7.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.
@@ -1,3 +1,4 @@
1
+ import type { TalonConfig } from "../util/config.js";
1
2
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
3
 
3
4
  vi.mock("../util/log.js", () => ({
@@ -27,6 +28,27 @@ describe("plugin system", () => {
27
28
  };
28
29
  }
29
30
 
31
+ function createTestConfig(overrides: Partial<TalonConfig> = {}): TalonConfig {
32
+ return {
33
+ frontend: "terminal",
34
+ backend: "claude",
35
+ model: "default",
36
+ maxMessageLength: 4000,
37
+ concurrency: 1,
38
+ pulse: true,
39
+ pulseIntervalMs: 300000,
40
+ heartbeat: false,
41
+ heartbeatIntervalMinutes: 60,
42
+ plugins: [],
43
+ botDisplayName: "Talon",
44
+ teamsWebhookPort: 19878,
45
+ teamsGraphPollMs: 10000,
46
+ systemPrompt: "test prompt",
47
+ workspace: "/tmp/workspace",
48
+ ...overrides,
49
+ };
50
+ }
51
+
30
52
  /** Import fresh plugin module with fs mocked to find entry + dynamic import returning plugin. */
31
53
  async function setup(plugin: ReturnType<typeof createMockPlugin>) {
32
54
  vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
@@ -247,6 +269,75 @@ describe("plugin system", () => {
247
269
  );
248
270
  });
249
271
 
272
+ it("does not let plugin env vars override bridge metadata", async () => {
273
+ const plugin = createMockPlugin({
274
+ mcpServer: {
275
+ command: "/usr/bin/python3",
276
+ args: ["-m", "my_server"],
277
+ },
278
+ getEnvVars: () => ({
279
+ TALON_BRIDGE_URL: "http://malicious.example",
280
+ TALON_CHAT_ID: "wrong-chat",
281
+ MY_KEY: "val",
282
+ }),
283
+ });
284
+ delete (plugin as Record<string, unknown>).mcpServerPath;
285
+ const { loadPlugins, getPluginMcpServers } = await setup(plugin);
286
+ await loadPlugins([{ path: "/fake/plugin" }]);
287
+
288
+ const servers = getPluginMcpServers("http://localhost:19876", "chat1");
289
+ expect(servers["test-plugin-tools"].env).toMatchObject({
290
+ TALON_BRIDGE_URL: "http://localhost:19876",
291
+ TALON_CHAT_ID: "chat1",
292
+ MY_KEY: "val",
293
+ });
294
+ });
295
+
296
+ it("builds standalone MCP server entries from config", async () => {
297
+ const { loadPlugins, getPluginMcpServers } =
298
+ await setup(createMockPlugin());
299
+ await loadPlugins([
300
+ {
301
+ name: "standalone",
302
+ command: "node",
303
+ args: ["/tmp/server.js"],
304
+ env: {
305
+ API_KEY: "secret",
306
+ TALON_BRIDGE_URL: "http://malicious.example",
307
+ TALON_CHAT_ID: "wrong-chat",
308
+ },
309
+ },
310
+ ]);
311
+
312
+ const servers = getPluginMcpServers("http://localhost:19876", "chat1");
313
+ expect(servers["standalone-tools"]).toEqual({
314
+ command: "node",
315
+ args: ["/tmp/server.js"],
316
+ env: {
317
+ API_KEY: "secret",
318
+ TALON_BRIDGE_URL: "http://localhost:19876",
319
+ TALON_CHAT_ID: "chat1",
320
+ },
321
+ });
322
+ });
323
+
324
+ it("skips path plugins that collide with standalone MCP names", async () => {
325
+ const initFn = vi.fn();
326
+ const plugin = createMockPlugin({ init: initFn });
327
+ const { loadPlugins, getPluginCount, getPluginMcpServers } =
328
+ await setup(plugin);
329
+ await loadPlugins([
330
+ { name: "test-plugin", command: "node", args: ["/tmp/server.js"] },
331
+ { path: "/fake/plugin" },
332
+ ]);
333
+
334
+ expect(getPluginCount()).toBe(0);
335
+ expect(initFn).not.toHaveBeenCalled();
336
+ expect(
337
+ getPluginMcpServers("http://localhost:19876", "chat1"),
338
+ ).toHaveProperty("test-plugin-tools");
339
+ });
340
+
250
341
  it("mcpServer takes priority over mcpServerPath when both are set", async () => {
251
342
  const plugin = createMockPlugin({
252
343
  mcpServerPath: "/fake/tools.ts",
@@ -276,12 +367,42 @@ describe("plugin system", () => {
276
367
  });
277
368
  });
278
369
 
370
+ describe("reload", () => {
371
+ it("clears standalone MCP entries on hot reload", async () => {
372
+ vi.resetModules();
373
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
374
+ vi.doMock("../util/config.js", () => ({
375
+ loadConfig: () => ({
376
+ frontend: "terminal",
377
+ model: "default",
378
+ plugins: [],
379
+ systemPrompt: "test prompt",
380
+ workspace: "/tmp/workspace",
381
+ }),
382
+ getFrontends: () => ["terminal"],
383
+ }));
384
+
385
+ const mod = await import("../core/plugin.js");
386
+ await mod.loadPlugins([{ name: "standalone", command: "node" }]);
387
+ expect(
388
+ mod.getPluginMcpServers("http://localhost:19876", "chat1"),
389
+ ).toHaveProperty("standalone-tools");
390
+
391
+ await mod.reloadPlugins();
392
+
393
+ expect(
394
+ mod.getPluginMcpServers("http://localhost:19876", "chat1"),
395
+ ).toEqual({});
396
+ });
397
+ });
398
+
279
399
  describe("registerPlugin (built-in)", () => {
280
400
  it("registers a built-in plugin directly", async () => {
281
401
  const plugin = createMockPlugin({ name: "built-in-test" });
282
402
  const { registerPlugin, getPlugin } = await setup(createMockPlugin());
283
403
 
284
- registerPlugin(plugin, { key: "val" });
404
+ const loaded = registerPlugin(plugin, { key: "val" });
405
+ expect(loaded?.path).toBe("(built-in)");
285
406
  expect(getPlugin("built-in-test")).toBeDefined();
286
407
  expect(getPlugin("built-in-test")!.path).toBe("(built-in)");
287
408
  });
@@ -306,9 +427,27 @@ describe("plugin system", () => {
306
427
  });
307
428
  const { registerPlugin, getPlugin } = await setup(createMockPlugin());
308
429
 
309
- registerPlugin(plugin, {});
430
+ expect(registerPlugin(plugin, {})).toBeNull();
310
431
  expect(getPlugin("builtin-invalid")).toBeUndefined();
311
432
  });
433
+
434
+ it("does not init a built-in plugin when duplicate registration is skipped", async () => {
435
+ const init = vi.fn();
436
+ const githubPlugin = createMockPlugin({ name: "github", init });
437
+
438
+ vi.doMock("../plugins/github/index.js", () => ({
439
+ createGitHubPlugin: () => githubPlugin,
440
+ }));
441
+
442
+ const mod = await import("../core/plugin.js");
443
+ await mod.loadPlugins([{ name: "github", command: "node" }]);
444
+ await mod.loadBuiltinPlugins(
445
+ createTestConfig({ github: { enabled: true } }),
446
+ );
447
+
448
+ expect(init).not.toHaveBeenCalled();
449
+ expect(mod.getPlugin("github")).toBeUndefined();
450
+ });
312
451
  });
313
452
 
314
453
  describe("system prompt additions", () => {
@@ -366,6 +505,20 @@ describe("plugin system", () => {
366
505
  expect(getPluginCount()).toBe(1);
367
506
  });
368
507
 
508
+ it("does not leave an init timeout running for plugins without init", async () => {
509
+ vi.useFakeTimers();
510
+ try {
511
+ const plugin = createMockPlugin();
512
+ const { loadPlugins } = await setup(plugin);
513
+
514
+ await loadPlugins([{ path: "/fake/plugin" }]);
515
+
516
+ expect(vi.getTimerCount()).toBe(0);
517
+ } finally {
518
+ vi.useRealTimers();
519
+ }
520
+ });
521
+
369
522
  it("catches destroy errors without crashing", async () => {
370
523
  const plugin = createMockPlugin({
371
524
  destroy: () => {
@@ -0,0 +1,113 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { clearModels, registerModels } from "../core/models.js";
3
+ import {
4
+ formatCompactModelLabel,
5
+ formatModelLabel,
6
+ formatModelOptionLabel,
7
+ getTelegramModelOptions,
8
+ isSelectedModel,
9
+ renderSettingsKeyboard,
10
+ } from "../frontend/telegram/helpers.js";
11
+
12
+ describe("telegram helpers", () => {
13
+ beforeEach(() => {
14
+ clearModels();
15
+ registerModels([
16
+ {
17
+ id: "default",
18
+ displayName: "Default (recommended)",
19
+ description: "Sonnet 4.6 · Best for everyday tasks",
20
+ aliases: ["sonnet", "claude-sonnet-4-6"],
21
+ provider: "anthropic",
22
+ capabilities: {
23
+ supports1mContext: true,
24
+ oneMillionContextModelId: "sonnet[1m]",
25
+ },
26
+ tier: "balanced",
27
+ fallback: "haiku",
28
+ },
29
+ {
30
+ id: "sonnet[1m]",
31
+ displayName: "Sonnet (1M context)",
32
+ description:
33
+ "Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
34
+ aliases: ["claude-sonnet-4-6[1m]"],
35
+ provider: "anthropic",
36
+ capabilities: { supports1mContext: true },
37
+ tier: "balanced",
38
+ fallback: "haiku",
39
+ },
40
+ {
41
+ id: "opus",
42
+ displayName: "Opus",
43
+ description: "Opus 4.6 · Most capable for complex work",
44
+ aliases: ["claude-opus-4-6"],
45
+ provider: "anthropic",
46
+ capabilities: {
47
+ supports1mContext: true,
48
+ oneMillionContextModelId: "opus[1m]",
49
+ },
50
+ tier: "premium",
51
+ fallback: "default",
52
+ },
53
+ {
54
+ id: "opus[1m]",
55
+ displayName: "Opus (1M context)",
56
+ description:
57
+ "Opus 4.6 with 1M context · Billed as extra usage · $5/$25 per Mtok",
58
+ aliases: ["claude-opus-4-6[1m]"],
59
+ provider: "anthropic",
60
+ capabilities: { supports1mContext: true },
61
+ tier: "premium",
62
+ fallback: "default",
63
+ },
64
+ {
65
+ id: "haiku",
66
+ displayName: "Haiku",
67
+ description: "Haiku 4.5 · Fastest for quick answers",
68
+ aliases: ["claude-haiku-4-5"],
69
+ provider: "anthropic",
70
+ capabilities: { supports1mContext: false },
71
+ tier: "economy",
72
+ },
73
+ ]);
74
+ });
75
+
76
+ it("matches legacy aliases to the canonical selected model", () => {
77
+ expect(isSelectedModel("claude-sonnet-4-6", "default")).toBe(true);
78
+ expect(isSelectedModel("sonnet[1m]", "default")).toBe(true);
79
+ expect(isSelectedModel("claude-sonnet-4-6", "haiku")).toBe(false);
80
+ });
81
+
82
+ it("formats clean model labels for telegram users", () => {
83
+ expect(formatModelLabel("default")).toBe("Sonnet 4.6");
84
+ expect(formatModelLabel("claude-sonnet-4-6")).toBe("Sonnet 4.6");
85
+ expect(formatModelLabel("sonnet[1m]")).toBe("Sonnet 4.6");
86
+ expect(formatModelOptionLabel(getTelegramModelOptions()[0]!)).toBe(
87
+ "Opus 4.6",
88
+ );
89
+ expect(formatCompactModelLabel(getTelegramModelOptions()[1]!)).toBe(
90
+ "Sonnet",
91
+ );
92
+ });
93
+
94
+ it("shows a single clean option per model family", () => {
95
+ expect(getTelegramModelOptions().map((model) => model.id)).toEqual([
96
+ "opus",
97
+ "default",
98
+ "haiku",
99
+ ]);
100
+ });
101
+
102
+ it("marks the canonical model button as selected for legacy aliases", () => {
103
+ const buttons = renderSettingsKeyboard(
104
+ "claude-sonnet-4-6",
105
+ "adaptive",
106
+ true,
107
+ )
108
+ .flat()
109
+ .map((button) => button.text);
110
+
111
+ expect(buttons).toContain("\u2713 Sonnet");
112
+ });
113
+ });