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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/__tests__/chat-settings.test.ts +47 -36
- package/src/__tests__/claude-sdk-models.test.ts +157 -0
- package/src/__tests__/claude-sdk-options.test.ts +118 -0
- package/src/__tests__/config.test.ts +112 -8
- package/src/__tests__/dream.test.ts +3 -3
- package/src/__tests__/fuzz.test.ts +15 -15
- package/src/__tests__/plugin.test.ts +155 -2
- package/src/__tests__/telegram-helpers.test.ts +113 -0
- package/src/backend/claude-sdk/models.ts +385 -68
- package/src/backend/claude-sdk/options.ts +6 -4
- package/src/backend/claude-sdk/stream.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/core/models.ts +49 -5
- package/src/core/plugin.ts +207 -118
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +19 -10
- package/src/frontend/telegram/helpers.ts +78 -7
- package/src/plugins/playwright/index.ts +54 -20
- package/src/util/config.ts +98 -15
|
@@ -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
|
+
});
|