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.
@@ -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("claude-sonnet-4-6");
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("claude-sonnet-4-6");
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
- expect(config.plugins[0].path).toBe("./plugins/my-plugin");
199
- expect(config.plugins[0].config).toEqual({ key: "value" });
200
- expect(config.plugins[1].path).toBe("./plugins/another");
201
- expect(config.plugins[1].config).toBeUndefined();
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("default model is exactly claude-sonnet-4-6", async () => {
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("claude-sonnet-4-6");
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 'claude-sonnet-4-6' when neither dreamModel nor model set (line 135 FALSE??FALSE branch)", async () => {
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 "claude-sonnet-4-6" literal default
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", "claude-sonnet-4-6");
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 always resolve to claude model names", () => {
328
- const aliases = [
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(...aliases),
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).toMatch(/^claude-/);
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) {