talon-agent 1.6.0 → 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 +184 -50
- 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/handler.ts +1 -1
- 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 +13 -8
- 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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockGetSession = vi.fn();
|
|
4
|
+
const mockGetChatSettings = vi.fn();
|
|
5
|
+
const mockGetPluginMcpServers = vi.fn();
|
|
6
|
+
const mockGetConfig = vi.fn();
|
|
7
|
+
const mockGetBridgePort = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("../storage/sessions.js", () => ({
|
|
10
|
+
getSession: (...args: unknown[]) =>
|
|
11
|
+
mockGetSession(...(args as Parameters<typeof mockGetSession>)),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../storage/chat-settings.js", () => ({
|
|
15
|
+
getChatSettings: (...args: unknown[]) =>
|
|
16
|
+
mockGetChatSettings(...(args as Parameters<typeof mockGetChatSettings>)),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("../core/plugin.js", () => ({
|
|
20
|
+
getPluginMcpServers: (...args: unknown[]) =>
|
|
21
|
+
mockGetPluginMcpServers(
|
|
22
|
+
...(args as Parameters<typeof mockGetPluginMcpServers>),
|
|
23
|
+
),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../backend/claude-sdk/state.js", () => ({
|
|
27
|
+
getConfig: (...args: unknown[]) =>
|
|
28
|
+
mockGetConfig(...(args as Parameters<typeof mockGetConfig>)),
|
|
29
|
+
getBridgePort: (...args: unknown[]) =>
|
|
30
|
+
mockGetBridgePort(...(args as Parameters<typeof mockGetBridgePort>)),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
describe("buildSdkOptions", () => {
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
vi.resetModules();
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
|
|
38
|
+
mockGetSession.mockReturnValue({ sessionId: null });
|
|
39
|
+
mockGetChatSettings.mockReturnValue({});
|
|
40
|
+
mockGetPluginMcpServers.mockReturnValue({});
|
|
41
|
+
mockGetConfig.mockReturnValue({
|
|
42
|
+
model: "claude-sonnet-4-6",
|
|
43
|
+
frontend: "terminal",
|
|
44
|
+
systemPrompt: "test prompt",
|
|
45
|
+
workspace: "/tmp/workspace",
|
|
46
|
+
});
|
|
47
|
+
mockGetBridgePort.mockReturnValue(19876);
|
|
48
|
+
|
|
49
|
+
const { clearModels, registerModels } = await import("../core/models.js");
|
|
50
|
+
clearModels();
|
|
51
|
+
registerModels([
|
|
52
|
+
{
|
|
53
|
+
id: "default",
|
|
54
|
+
displayName: "Default (recommended)",
|
|
55
|
+
description: "Sonnet 4.6 · Best for everyday tasks",
|
|
56
|
+
aliases: ["claude-sonnet-4-6"],
|
|
57
|
+
provider: "anthropic",
|
|
58
|
+
capabilities: {
|
|
59
|
+
supports1mContext: true,
|
|
60
|
+
oneMillionContextModelId: "sonnet[1m]",
|
|
61
|
+
},
|
|
62
|
+
tier: "balanced",
|
|
63
|
+
fallback: "haiku",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "sonnet[1m]",
|
|
67
|
+
displayName: "Sonnet (1M context)",
|
|
68
|
+
description:
|
|
69
|
+
"Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
|
|
70
|
+
aliases: ["claude-sonnet-4-6[1m]"],
|
|
71
|
+
provider: "anthropic",
|
|
72
|
+
capabilities: { supports1mContext: true },
|
|
73
|
+
tier: "balanced",
|
|
74
|
+
fallback: "haiku",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "haiku",
|
|
78
|
+
displayName: "Haiku",
|
|
79
|
+
description: "Haiku 4.5 · Fastest for quick answers",
|
|
80
|
+
aliases: ["claude-haiku-4-5"],
|
|
81
|
+
provider: "anthropic",
|
|
82
|
+
capabilities: { supports1mContext: false },
|
|
83
|
+
tier: "economy",
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("uses the exact mapped 1M SDK model for legacy Sonnet IDs", async () => {
|
|
89
|
+
const { buildSdkOptions } =
|
|
90
|
+
await import("../backend/claude-sdk/options.js");
|
|
91
|
+
|
|
92
|
+
const { activeModel, options } = buildSdkOptions("chat-1");
|
|
93
|
+
|
|
94
|
+
expect(activeModel).toBe("claude-sonnet-4-6");
|
|
95
|
+
expect(options.model).toBe("sonnet[1m]");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("leaves models without a mapped 1M variant unchanged", async () => {
|
|
99
|
+
mockGetChatSettings.mockReturnValue({ model: "haiku" });
|
|
100
|
+
|
|
101
|
+
const { buildSdkOptions } =
|
|
102
|
+
await import("../backend/claude-sdk/options.js");
|
|
103
|
+
const { options } = buildSdkOptions("chat-2");
|
|
104
|
+
|
|
105
|
+
expect(options.model).toBe("haiku");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("resolves legacy 1M aliases to canonical SDK model IDs", async () => {
|
|
109
|
+
mockGetChatSettings.mockReturnValue({ model: "claude-sonnet-4-6[1m]" });
|
|
110
|
+
|
|
111
|
+
const { buildSdkOptions } =
|
|
112
|
+
await import("../backend/claude-sdk/options.js");
|
|
113
|
+
const { activeModel, options } = buildSdkOptions("chat-3");
|
|
114
|
+
|
|
115
|
+
expect(activeModel).toBe("claude-sonnet-4-6[1m]");
|
|
116
|
+
expect(options.model).toBe("sonnet[1m]");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -94,7 +94,7 @@ describe("config", () => {
|
|
|
94
94
|
const { loadConfig } = await import("../util/config.js");
|
|
95
95
|
const config = loadConfig();
|
|
96
96
|
expect(config.frontend).toBe("terminal");
|
|
97
|
-
expect(config.model).toBe("
|
|
97
|
+
expect(config.model).toBe("default");
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
it("throws when telegram frontend has no botToken", async () => {
|
|
@@ -118,7 +118,7 @@ describe("config", () => {
|
|
|
118
118
|
|
|
119
119
|
const { loadConfig } = await import("../util/config.js");
|
|
120
120
|
const config = loadConfig();
|
|
121
|
-
expect(config.model).toBe("
|
|
121
|
+
expect(config.model).toBe("default");
|
|
122
122
|
expect(config.maxMessageLength).toBe(4000);
|
|
123
123
|
expect(config.concurrency).toBe(1);
|
|
124
124
|
expect(config.pulse).toBe(true);
|
|
@@ -195,10 +195,91 @@ describe("config", () => {
|
|
|
195
195
|
const { loadConfig } = await import("../util/config.js");
|
|
196
196
|
const config = loadConfig();
|
|
197
197
|
expect(config.plugins).toHaveLength(2);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
expect(
|
|
201
|
-
|
|
198
|
+
const [firstPlugin, secondPlugin] = config.plugins;
|
|
199
|
+
|
|
200
|
+
expect("path" in firstPlugin).toBe(true);
|
|
201
|
+
if ("path" in firstPlugin) {
|
|
202
|
+
expect(firstPlugin.path).toBe("./plugins/my-plugin");
|
|
203
|
+
expect(firstPlugin.config).toEqual({ key: "value" });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
expect("path" in secondPlugin).toBe(true);
|
|
207
|
+
if ("path" in secondPlugin) {
|
|
208
|
+
expect(secondPlugin.path).toBe("./plugins/another");
|
|
209
|
+
expect(secondPlugin.config).toBeUndefined();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("parses standalone MCP plugins in config", async () => {
|
|
214
|
+
mockFs({
|
|
215
|
+
frontend: "terminal",
|
|
216
|
+
plugins: [
|
|
217
|
+
{
|
|
218
|
+
name: "polymarket",
|
|
219
|
+
command: "node",
|
|
220
|
+
args: ["/tmp/polymarket.js"],
|
|
221
|
+
env: { POLYMARKET_PRIVATE_KEY: "0x123" },
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const { loadConfig } = await import("../util/config.js");
|
|
227
|
+
const config = loadConfig();
|
|
228
|
+
|
|
229
|
+
expect(config.plugins).toEqual([
|
|
230
|
+
{
|
|
231
|
+
name: "polymarket",
|
|
232
|
+
command: "node",
|
|
233
|
+
args: ["/tmp/polymarket.js"],
|
|
234
|
+
env: { POLYMARKET_PRIVATE_KEY: "0x123" },
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("rejects plugin entries that mix path and standalone MCP fields", async () => {
|
|
240
|
+
mockFs({
|
|
241
|
+
frontend: "terminal",
|
|
242
|
+
plugins: [
|
|
243
|
+
{
|
|
244
|
+
path: "./plugins/extras",
|
|
245
|
+
name: "extras",
|
|
246
|
+
command: "node",
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const { loadConfig } = await import("../util/config.js");
|
|
252
|
+
expect(() => loadConfig()).toThrow("exactly one format");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects standalone MCP entries missing required fields", async () => {
|
|
256
|
+
mockFs({
|
|
257
|
+
frontend: "terminal",
|
|
258
|
+
plugins: [{ name: "polymarket" }],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const { loadConfig } = await import("../util/config.js");
|
|
262
|
+
expect(() => loadConfig()).toThrow(
|
|
263
|
+
"MCP plugin entries must include 'command'",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("rejects standalone MCP entries with config blocks", async () => {
|
|
268
|
+
mockFs({
|
|
269
|
+
frontend: "terminal",
|
|
270
|
+
plugins: [
|
|
271
|
+
{
|
|
272
|
+
name: "polymarket",
|
|
273
|
+
command: "node",
|
|
274
|
+
config: { market: "crypto" },
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const { loadConfig } = await import("../util/config.js");
|
|
280
|
+
expect(() => loadConfig()).toThrow(
|
|
281
|
+
"MCP plugin entries cannot include 'config'",
|
|
282
|
+
);
|
|
202
283
|
});
|
|
203
284
|
|
|
204
285
|
it("defaults plugins to empty array", async () => {
|
|
@@ -235,6 +316,29 @@ describe("config", () => {
|
|
|
235
316
|
const config = loadConfig();
|
|
236
317
|
expect(config.frontend).toEqual(["terminal"]);
|
|
237
318
|
});
|
|
319
|
+
|
|
320
|
+
it("preserves Playwright endpoint settings from config", async () => {
|
|
321
|
+
mockFs({
|
|
322
|
+
frontend: "terminal",
|
|
323
|
+
playwright: {
|
|
324
|
+
enabled: true,
|
|
325
|
+
browser: "firefox",
|
|
326
|
+
endpoint: "ws://127.0.0.1:9222/devtools/browser/test",
|
|
327
|
+
endpointFile: "/tmp/camoufox-endpoint.txt",
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const { loadConfig } = await import("../util/config.js");
|
|
332
|
+
const config = loadConfig();
|
|
333
|
+
|
|
334
|
+
expect(config.playwright).toEqual({
|
|
335
|
+
enabled: true,
|
|
336
|
+
browser: "firefox",
|
|
337
|
+
headless: true,
|
|
338
|
+
endpoint: "ws://127.0.0.1:9222/devtools/browser/test",
|
|
339
|
+
endpointFile: "/tmp/camoufox-endpoint.txt",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
238
342
|
});
|
|
239
343
|
|
|
240
344
|
describe("system prompt", () => {
|
|
@@ -533,12 +637,12 @@ describe("config", () => {
|
|
|
533
637
|
expect(() => loadConfig()).toThrow();
|
|
534
638
|
});
|
|
535
639
|
|
|
536
|
-
it("
|
|
640
|
+
it("defaults the canonical Claude model to default", async () => {
|
|
537
641
|
mockFs({ frontend: "terminal" });
|
|
538
642
|
|
|
539
643
|
const { loadConfig } = await import("../util/config.js");
|
|
540
644
|
const config = loadConfig();
|
|
541
|
-
expect(config.model).toBe("
|
|
645
|
+
expect(config.model).toBe("default");
|
|
542
646
|
});
|
|
543
647
|
|
|
544
648
|
it("default pulse is exactly true", async () => {
|
|
@@ -881,7 +881,7 @@ describe("dream error paths", () => {
|
|
|
881
881
|
);
|
|
882
882
|
});
|
|
883
883
|
|
|
884
|
-
it("model defaults to '
|
|
884
|
+
it("model defaults to 'default' when neither dreamModel nor model set (line 135 FALSE??FALSE branch)", async () => {
|
|
885
885
|
vi.doMock("node:fs", () => ({
|
|
886
886
|
existsSync: vi.fn(() => false),
|
|
887
887
|
readFileSync: vi.fn(() => "dream prompt"),
|
|
@@ -913,14 +913,14 @@ describe("dream error paths", () => {
|
|
|
913
913
|
vi.doMock("@anthropic-ai/claude-agent-sdk", () => ({ query: queryMock }));
|
|
914
914
|
|
|
915
915
|
const mod = await import("../core/dream.js");
|
|
916
|
-
// No model or dreamModel → falls through to
|
|
916
|
+
// No model or dreamModel → falls through to the canonical SDK default model
|
|
917
917
|
mod.initDream({ workspace: "/fake/ws" });
|
|
918
918
|
await mod.forceDream();
|
|
919
919
|
|
|
920
920
|
const callArgs = (queryMock.mock.calls[0] as unknown[])[0] as {
|
|
921
921
|
options: Record<string, unknown>;
|
|
922
922
|
};
|
|
923
|
-
expect(callArgs.options).toHaveProperty("model", "
|
|
923
|
+
expect(callArgs.options).toHaveProperty("model", "default");
|
|
924
924
|
});
|
|
925
925
|
});
|
|
926
926
|
|
|
@@ -324,25 +324,25 @@ describe("fuzz: resolveModelName()", () => {
|
|
|
324
324
|
);
|
|
325
325
|
});
|
|
326
326
|
|
|
327
|
-
it("known aliases
|
|
328
|
-
const
|
|
329
|
-
"sonnet",
|
|
330
|
-
"opus",
|
|
331
|
-
"haiku",
|
|
332
|
-
"sonnet-4.6",
|
|
333
|
-
"opus-4.6",
|
|
334
|
-
"haiku-4.5",
|
|
335
|
-
"sonnet-4-6",
|
|
336
|
-
"opus-4-6",
|
|
337
|
-
"haiku-4-5",
|
|
338
|
-
];
|
|
327
|
+
it("known aliases resolve to the expected SDK model IDs", () => {
|
|
328
|
+
const aliasMappings = [
|
|
329
|
+
["sonnet", "default"],
|
|
330
|
+
["opus", "opus"],
|
|
331
|
+
["haiku", "haiku"],
|
|
332
|
+
["sonnet-4.6", "default"],
|
|
333
|
+
["opus-4.6", "opus"],
|
|
334
|
+
["haiku-4.5", "haiku"],
|
|
335
|
+
["sonnet-4-6", "default"],
|
|
336
|
+
["opus-4-6", "opus"],
|
|
337
|
+
["haiku-4-5", "haiku"],
|
|
338
|
+
] as const;
|
|
339
339
|
fc.assert(
|
|
340
340
|
fc.property(
|
|
341
|
-
fc.constantFrom(...
|
|
341
|
+
fc.constantFrom(...aliasMappings),
|
|
342
342
|
fc.constantFrom("", " ", " "),
|
|
343
|
-
(alias, padding) => {
|
|
343
|
+
([alias, expectedModelId], padding) => {
|
|
344
344
|
const result = resolveModelName(padding + alias + padding);
|
|
345
|
-
expect(result).
|
|
345
|
+
expect(result).toBe(expectedModelId);
|
|
346
346
|
},
|
|
347
347
|
),
|
|
348
348
|
fcParams,
|
|
@@ -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
|
+
});
|
|
@@ -122,7 +122,7 @@ export async function handleMessage(
|
|
|
122
122
|
|
|
123
123
|
// Final result — read token counts and context info
|
|
124
124
|
if (isResult(message)) {
|
|
125
|
-
processResultMessage(message, state);
|
|
125
|
+
processResultMessage(message, state, options.model ?? activeModel);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
} catch (err) {
|