talon-agent 1.6.1 → 1.8.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 (58) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/__tests__/chat-settings.test.ts +47 -36
  4. package/src/__tests__/claude-sdk-models.test.ts +142 -0
  5. package/src/__tests__/claude-sdk-options.test.ts +110 -0
  6. package/src/__tests__/config.test.ts +112 -8
  7. package/src/__tests__/dream.test.ts +3 -3
  8. package/src/__tests__/fixtures/test-mcp-server.ts +37 -0
  9. package/src/__tests__/fuzz.test.ts +15 -15
  10. package/src/__tests__/handlers.test.ts +43 -0
  11. package/src/__tests__/mcp-lifecycle.test.ts +165 -0
  12. package/src/__tests__/metrics.test.ts +76 -0
  13. package/src/__tests__/opencode-models.test.ts +117 -0
  14. package/src/__tests__/opencode-summary.test.ts +105 -0
  15. package/src/__tests__/opencode-ui.test.ts +94 -0
  16. package/src/__tests__/plugin.test.ts +156 -2
  17. package/src/__tests__/reload-plugins.test.ts +137 -0
  18. package/src/__tests__/sessions.test.ts +0 -5
  19. package/src/__tests__/telegram-helpers.test.ts +156 -0
  20. package/src/__tests__/terminal-commands.test.ts +175 -1
  21. package/src/backend/claude-sdk/handler.ts +24 -2
  22. package/src/backend/claude-sdk/index.ts +2 -1
  23. package/src/backend/claude-sdk/model-provider.ts +209 -0
  24. package/src/backend/claude-sdk/models.ts +332 -80
  25. package/src/backend/claude-sdk/options.ts +4 -7
  26. package/src/backend/claude-sdk/stream.ts +1 -1
  27. package/src/backend/opencode/handler.ts +198 -0
  28. package/src/backend/opencode/index.ts +39 -232
  29. package/src/backend/opencode/model-provider.ts +167 -0
  30. package/src/backend/opencode/models.ts +742 -0
  31. package/src/backend/opencode/server.ts +382 -0
  32. package/src/backend/opencode/sessions.ts +492 -0
  33. package/src/bootstrap.ts +56 -2
  34. package/src/cli.ts +1 -1
  35. package/src/core/cron.ts +23 -2
  36. package/src/core/dream.ts +5 -4
  37. package/src/core/gateway-actions.ts +38 -2
  38. package/src/core/heartbeat.ts +5 -3
  39. package/src/core/models.ts +67 -39
  40. package/src/core/plugin.ts +222 -118
  41. package/src/core/tools/mcp-server.ts +23 -0
  42. package/src/core/types.ts +72 -0
  43. package/src/frontend/teams/index.ts +2 -0
  44. package/src/frontend/telegram/actions.ts +13 -4
  45. package/src/frontend/telegram/callbacks.ts +71 -31
  46. package/src/frontend/telegram/commands.ts +151 -44
  47. package/src/frontend/telegram/handlers.ts +78 -21
  48. package/src/frontend/telegram/helpers.ts +207 -14
  49. package/src/frontend/telegram/index.ts +4 -1
  50. package/src/frontend/terminal/commands.ts +248 -5
  51. package/src/frontend/terminal/index.ts +2 -0
  52. package/src/plugins/playwright/index.ts +54 -20
  53. package/src/storage/media-index.ts +3 -3
  54. package/src/storage/sessions.ts +5 -0
  55. package/src/util/cleanup-registry.ts +4 -2
  56. package/src/util/config.ts +98 -15
  57. package/src/util/metrics.ts +80 -0
  58. package/src/util/trace.ts +4 -2
package/README.md CHANGED
@@ -209,7 +209,7 @@ Config file: `~/.talon/config.json`
209
209
  | `frontend` | `"telegram"` | `"telegram"`, `"terminal"`, `"teams"`, or an array |
210
210
  | `backend` | `"claude"` | `"claude"` or `"opencode"` |
211
211
  | `botToken` | --- | Telegram bot token |
212
- | `model` | `"claude-sonnet-4-6"` | Default model (discovered from SDK at startup) |
212
+ | `model` | `"default"` | Default Claude model. Legacy `claude-*` aliases are still accepted. |
213
213
  | `concurrency` | `1` | Max concurrent AI queries (1--20) |
214
214
  | `pulse` | `true` | Periodic group engagement |
215
215
  | `heartbeat` | `false` | Background maintenance agent |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.6.1",
3
+ "version": "1.8.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -51,7 +51,7 @@
51
51
  "format:check": "prettier --check src/ prompts/"
52
52
  },
53
53
  "dependencies": {
54
- "@anthropic-ai/claude-agent-sdk": "^0.2.104",
54
+ "@anthropic-ai/claude-agent-sdk": "^0.2.108",
55
55
  "@brave/brave-search-mcp-server": "^2.0.75",
56
56
  "@clack/prompts": "^1.2.0",
57
57
  "@grammyjs/auto-retry": "^2.0.2",
@@ -38,6 +38,12 @@ const { registerClaudeModelsStatic, CLAUDE_MODELS_STATIC } =
38
38
  await import("../backend/claude-sdk/models.js");
39
39
  registerClaudeModelsStatic(CLAUDE_MODELS_STATIC);
40
40
 
41
+ const SDK_MODEL_IDS = {
42
+ sonnet: "default",
43
+ opus: "opus",
44
+ haiku: "haiku",
45
+ } as const;
46
+
41
47
  describe("chat-settings", () => {
42
48
  describe("getChatSettings", () => {
43
49
  it("returns empty object for unknown chat", () => {
@@ -85,62 +91,67 @@ describe("chat-settings", () => {
85
91
  });
86
92
 
87
93
  describe("resolveModelName", () => {
88
- it("resolves 'sonnet' to claude-sonnet-4-6", () => {
89
- expect(resolveModelName("sonnet")).toBe("claude-sonnet-4-6");
94
+ it("resolves 'sonnet' to the SDK default model ID", () => {
95
+ expect(resolveModelName("sonnet")).toBe(SDK_MODEL_IDS.sonnet);
90
96
  });
91
97
 
92
- it("resolves 'opus' to claude-opus-4-6", () => {
93
- expect(resolveModelName("opus")).toBe("claude-opus-4-6");
98
+ it("resolves 'opus' to the SDK Opus model ID", () => {
99
+ expect(resolveModelName("opus")).toBe(SDK_MODEL_IDS.opus);
94
100
  });
95
101
 
96
- it("resolves 'haiku' to claude-haiku-4-5", () => {
97
- expect(resolveModelName("haiku")).toBe("claude-haiku-4-5");
102
+ it("resolves 'haiku' to the SDK Haiku model ID", () => {
103
+ expect(resolveModelName("haiku")).toBe(SDK_MODEL_IDS.haiku);
98
104
  });
99
105
 
100
106
  it("resolves versioned aliases", () => {
101
- expect(resolveModelName("sonnet-4.6")).toBe("claude-sonnet-4-6");
102
- expect(resolveModelName("opus-4.6")).toBe("claude-opus-4-6");
103
- expect(resolveModelName("haiku-4.5")).toBe("claude-haiku-4-5");
107
+ expect(resolveModelName("sonnet-4.6")).toBe(SDK_MODEL_IDS.sonnet);
108
+ expect(resolveModelName("opus-4.6")).toBe(SDK_MODEL_IDS.opus);
109
+ expect(resolveModelName("haiku-4.5")).toBe(SDK_MODEL_IDS.haiku);
104
110
  });
105
111
 
106
112
  it("resolves dash-separated aliases", () => {
107
- expect(resolveModelName("sonnet-4-6")).toBe("claude-sonnet-4-6");
108
- expect(resolveModelName("opus-4-6")).toBe("claude-opus-4-6");
109
- expect(resolveModelName("haiku-4-5")).toBe("claude-haiku-4-5");
113
+ expect(resolveModelName("sonnet-4-6")).toBe(SDK_MODEL_IDS.sonnet);
114
+ expect(resolveModelName("opus-4-6")).toBe(SDK_MODEL_IDS.opus);
115
+ expect(resolveModelName("haiku-4-5")).toBe(SDK_MODEL_IDS.haiku);
110
116
  });
111
117
 
112
118
  it("is case-insensitive", () => {
113
- expect(resolveModelName("Sonnet")).toBe("claude-sonnet-4-6");
114
- expect(resolveModelName("OPUS")).toBe("claude-opus-4-6");
119
+ expect(resolveModelName("Sonnet")).toBe(SDK_MODEL_IDS.sonnet);
120
+ expect(resolveModelName("OPUS")).toBe(SDK_MODEL_IDS.opus);
115
121
  });
116
122
 
117
123
  it("trims whitespace", () => {
118
- expect(resolveModelName(" sonnet ")).toBe("claude-sonnet-4-6");
124
+ expect(resolveModelName(" sonnet ")).toBe(SDK_MODEL_IDS.sonnet);
119
125
  });
120
126
 
121
127
  it("passes through unknown model names unchanged", () => {
122
128
  expect(resolveModelName("gpt-4")).toBe("gpt-4");
123
- expect(resolveModelName("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
129
+ });
130
+
131
+ it("resolves legacy claude-* aliases to the current SDK IDs", () => {
132
+ expect(resolveModelName("claude-sonnet-4-6")).toBe(SDK_MODEL_IDS.sonnet);
133
+ expect(resolveModelName("claude-opus-4-6")).toBe(SDK_MODEL_IDS.opus);
134
+ expect(resolveModelName("claude-haiku-4-5")).toBe(SDK_MODEL_IDS.haiku);
124
135
  });
125
136
  });
126
137
 
127
138
  describe("resolveModelName — exhaustive alias coverage", () => {
128
139
  it("resolves all base aliases correctly", () => {
129
- expect(resolveModelName("sonnet")).toBe("claude-sonnet-4-6");
130
- expect(resolveModelName("opus")).toBe("claude-opus-4-6");
131
- expect(resolveModelName("haiku")).toBe("claude-haiku-4-5");
140
+ expect(resolveModelName("sonnet")).toBe(SDK_MODEL_IDS.sonnet);
141
+ expect(resolveModelName("opus")).toBe(SDK_MODEL_IDS.opus);
142
+ expect(resolveModelName("haiku")).toBe(SDK_MODEL_IDS.haiku);
132
143
  });
133
144
 
134
145
  it("resolves all dot-separated version aliases", () => {
135
- expect(resolveModelName("sonnet-4.6")).toBe("claude-sonnet-4-6");
136
- expect(resolveModelName("opus-4.6")).toBe("claude-opus-4-6");
137
- expect(resolveModelName("haiku-4.5")).toBe("claude-haiku-4-5");
146
+ expect(resolveModelName("sonnet-4.6")).toBe(SDK_MODEL_IDS.sonnet);
147
+ expect(resolveModelName("opus-4.6")).toBe(SDK_MODEL_IDS.opus);
148
+ expect(resolveModelName("haiku-4.5")).toBe(SDK_MODEL_IDS.haiku);
138
149
  });
139
150
 
140
151
  it("resolves all dash-separated version aliases", () => {
141
- expect(resolveModelName("sonnet-4-6")).toBe("claude-sonnet-4-6");
142
- expect(resolveModelName("opus-4-6")).toBe("claude-opus-4-6");
143
- expect(resolveModelName("haiku-4-5")).toBe("claude-haiku-4-5");
152
+ expect(resolveModelName("sonnet-4-6")).toBe(SDK_MODEL_IDS.sonnet);
153
+ expect(resolveModelName("opus-4-6")).toBe(SDK_MODEL_IDS.opus);
154
+ expect(resolveModelName("haiku-4-5")).toBe(SDK_MODEL_IDS.haiku);
144
155
  });
145
156
 
146
157
  it("passes through completely unknown model names unchanged", () => {
@@ -149,10 +160,10 @@ describe("chat-settings", () => {
149
160
  expect(resolveModelName("mistral-large")).toBe("mistral-large");
150
161
  });
151
162
 
152
- it("passes through full claude model names unchanged (not aliases)", () => {
153
- expect(resolveModelName("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
154
- expect(resolveModelName("claude-opus-4-6")).toBe("claude-opus-4-6");
155
- expect(resolveModelName("claude-haiku-4-5")).toBe("claude-haiku-4-5");
163
+ it("maps full claude compatibility aliases to the current SDK IDs", () => {
164
+ expect(resolveModelName("claude-sonnet-4-6")).toBe(SDK_MODEL_IDS.sonnet);
165
+ expect(resolveModelName("claude-opus-4-6")).toBe(SDK_MODEL_IDS.opus);
166
+ expect(resolveModelName("claude-haiku-4-5")).toBe(SDK_MODEL_IDS.haiku);
156
167
  });
157
168
 
158
169
  it("preserves original casing for unknown models", () => {
@@ -171,16 +182,16 @@ describe("chat-settings", () => {
171
182
  });
172
183
 
173
184
  describe("model alias resolution (via registry)", () => {
174
- it("resolves short aliases to full model IDs", () => {
175
- expect(resolveModelName("sonnet")).toBe("claude-sonnet-4-6");
176
- expect(resolveModelName("opus")).toBe("claude-opus-4-6");
177
- expect(resolveModelName("haiku")).toBe("claude-haiku-4-5");
185
+ it("resolves short aliases to SDK model IDs", () => {
186
+ expect(resolveModelName("sonnet")).toBe(SDK_MODEL_IDS.sonnet);
187
+ expect(resolveModelName("opus")).toBe(SDK_MODEL_IDS.opus);
188
+ expect(resolveModelName("haiku")).toBe(SDK_MODEL_IDS.haiku);
178
189
  });
179
190
 
180
191
  it("resolves versioned aliases", () => {
181
- expect(resolveModelName("sonnet-4-6")).toBe("claude-sonnet-4-6");
182
- expect(resolveModelName("opus-4.6")).toBe("claude-opus-4-6");
183
- expect(resolveModelName("haiku-4.5")).toBe("claude-haiku-4-5");
192
+ expect(resolveModelName("sonnet-4-6")).toBe(SDK_MODEL_IDS.sonnet);
193
+ expect(resolveModelName("opus-4.6")).toBe(SDK_MODEL_IDS.opus);
194
+ expect(resolveModelName("haiku-4.5")).toBe(SDK_MODEL_IDS.haiku);
184
195
  });
185
196
 
186
197
  it("passes through unknown names unchanged", () => {
@@ -0,0 +1,142 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mockSupportedModels = vi.fn();
4
+
5
+ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
6
+ query: vi.fn(() => ({
7
+ supportedModels: mockSupportedModels,
8
+ [Symbol.asyncIterator]() {
9
+ return {
10
+ next: async () => ({ done: true, value: undefined }),
11
+ };
12
+ },
13
+ })),
14
+ }));
15
+
16
+ const sdkModels = [
17
+ {
18
+ value: "default",
19
+ displayName: "Default (recommended)",
20
+ description: "Sonnet 4.6 · Best for everyday tasks",
21
+ },
22
+ {
23
+ value: "sonnet[1m]",
24
+ displayName: "Sonnet (1M context)",
25
+ description:
26
+ "Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
27
+ },
28
+ {
29
+ value: "opus",
30
+ displayName: "Opus",
31
+ description: "Opus 4.6 · Most capable for complex work",
32
+ },
33
+ {
34
+ value: "opus[1m]",
35
+ displayName: "Opus (1M context)",
36
+ description:
37
+ "Opus 4.6 with 1M context · Billed as extra usage · $5/$25 per Mtok",
38
+ },
39
+ {
40
+ value: "haiku",
41
+ displayName: "Haiku",
42
+ description: "Haiku 4.5 · Fastest for quick answers",
43
+ },
44
+ {
45
+ value: "claude-sonnet-4-6",
46
+ displayName: "Sonnet 4.6",
47
+ description: "claude-sonnet-4-6",
48
+ },
49
+ ];
50
+
51
+ describe("registerClaudeModels", () => {
52
+ beforeEach(async () => {
53
+ vi.resetModules();
54
+ vi.clearAllMocks();
55
+ mockSupportedModels.mockResolvedValue(sdkModels);
56
+
57
+ const { clearModels } = await import("../core/models.js");
58
+ clearModels();
59
+ });
60
+
61
+ it("keeps SDK IDs/display names and collapses duplicates", async () => {
62
+ const { registerClaudeModels } =
63
+ await import("../backend/claude-sdk/models.js");
64
+ const { getModels, resolveModelId } = await import("../core/models.js");
65
+
66
+ await registerClaudeModels({ model: "default" });
67
+
68
+ const anthropicModels = getModels("anthropic");
69
+ expect(anthropicModels.map((model) => model.id)).toEqual([
70
+ "default",
71
+ "sonnet[1m]",
72
+ "opus",
73
+ "opus[1m]",
74
+ "haiku",
75
+ ]);
76
+
77
+ expect(
78
+ anthropicModels.find((model) => model.id === "default")?.displayName,
79
+ ).toBe("Default (recommended)");
80
+ expect(
81
+ anthropicModels.find((model) => model.id === "sonnet[1m]")?.displayName,
82
+ ).toBe("Sonnet (1M context)");
83
+ // claude-sonnet-4-6 collapsed into "default" as alias
84
+ expect(
85
+ anthropicModels.some((model) => model.id === "claude-sonnet-4-6"),
86
+ ).toBe(false);
87
+
88
+ expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
89
+ expect(resolveModelId("claude-sonnet-4-6[1m]")).toBe("sonnet[1m]");
90
+ expect(resolveModelId("claude-opus-4-6")).toBe("opus");
91
+ });
92
+
93
+ it("derives compatibility aliases from SDK metadata instead of hardcoded versions", async () => {
94
+ mockSupportedModels.mockResolvedValue([
95
+ {
96
+ value: "default",
97
+ displayName: "Default (recommended)",
98
+ description: "Sonnet 5.0 · Best for everyday tasks",
99
+ },
100
+ {
101
+ value: "sonnet[1m]",
102
+ displayName: "Sonnet (1M context)",
103
+ description:
104
+ "Sonnet 5.0 with 1M context · Billed as extra usage · $3/$15 per Mtok",
105
+ },
106
+ {
107
+ value: "opus",
108
+ displayName: "Opus",
109
+ description: "Opus 5.0 · Most capable for complex work",
110
+ },
111
+ {
112
+ value: "opus[1m]",
113
+ displayName: "Opus (1M context)",
114
+ description:
115
+ "Opus 5.0 with 1M context · Billed as extra usage · $5/$25 per Mtok",
116
+ },
117
+ {
118
+ value: "haiku",
119
+ displayName: "Haiku",
120
+ description: "Haiku 5.0 · Fastest for quick answers",
121
+ },
122
+ {
123
+ value: "claude-sonnet-5-0",
124
+ displayName: "Sonnet 5.0",
125
+ description: "claude-sonnet-5-0",
126
+ },
127
+ ]);
128
+
129
+ const { registerClaudeModels } =
130
+ await import("../backend/claude-sdk/models.js");
131
+ const { resolveModelId } = await import("../core/models.js");
132
+
133
+ await registerClaudeModels({ model: "default" });
134
+
135
+ expect(resolveModelId("claude-sonnet-5-0")).toBe("default");
136
+ expect(resolveModelId("claude-sonnet-4-6")).toBe("default");
137
+ expect(resolveModelId("claude-opus-5-0")).toBe("opus");
138
+ expect(resolveModelId("claude-opus-4-6")).toBe("opus");
139
+ expect(resolveModelId("claude-haiku-5-0")).toBe("haiku");
140
+ expect(resolveModelId("claude-haiku-4-5")).toBe("haiku");
141
+ });
142
+ });
@@ -0,0 +1,110 @@
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
+ fallback: "haiku",
59
+ },
60
+ {
61
+ id: "sonnet[1m]",
62
+ displayName: "Sonnet (1M context)",
63
+ description:
64
+ "Sonnet 4.6 with 1M context · Billed as extra usage · $3/$15 per Mtok",
65
+ aliases: ["claude-sonnet-4-6[1m]"],
66
+ provider: "anthropic",
67
+ fallback: "haiku",
68
+ },
69
+ {
70
+ id: "haiku",
71
+ displayName: "Haiku",
72
+ description: "Haiku 4.5 · Fastest for quick answers",
73
+ aliases: ["claude-haiku-4-5"],
74
+ provider: "anthropic",
75
+ },
76
+ ]);
77
+ });
78
+
79
+ it("resolves legacy aliases to canonical model ID and passes through", async () => {
80
+ const { buildSdkOptions } =
81
+ await import("../backend/claude-sdk/options.js");
82
+
83
+ const { activeModel, options } = buildSdkOptions("chat-1");
84
+
85
+ expect(activeModel).toBe("claude-sonnet-4-6");
86
+ // Model is passed through as resolved — SDK handles context window
87
+ expect(options.model).toBe("default");
88
+ });
89
+
90
+ it("passes model through unchanged when no alias resolution needed", async () => {
91
+ mockGetChatSettings.mockReturnValue({ model: "haiku" });
92
+
93
+ const { buildSdkOptions } =
94
+ await import("../backend/claude-sdk/options.js");
95
+ const { options } = buildSdkOptions("chat-2");
96
+
97
+ expect(options.model).toBe("haiku");
98
+ });
99
+
100
+ it("resolves 1M aliases to their canonical SDK model ID", async () => {
101
+ mockGetChatSettings.mockReturnValue({ model: "claude-sonnet-4-6[1m]" });
102
+
103
+ const { buildSdkOptions } =
104
+ await import("../backend/claude-sdk/options.js");
105
+ const { activeModel, options } = buildSdkOptions("chat-3");
106
+
107
+ expect(activeModel).toBe("claude-sonnet-4-6[1m]");
108
+ expect(options.model).toBe("sonnet[1m]");
109
+ });
110
+ });
@@ -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
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Minimal MCP server fixture for lifecycle integration tests.
3
+ *
4
+ * Uses the same McpServer + StdioServerTransport + stdin-close pattern
5
+ * as the real src/core/tools/mcp-server.ts, but without Talon tool
6
+ * composition so it starts fast and has no external dependencies.
7
+ *
8
+ * Signals readiness by writing "READY\n" to stderr.
9
+ */
10
+
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+
14
+ const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
15
+
16
+ // Register a trivial tool so the server has something to serve
17
+ server.tool("ping", "health check", {}, async () => ({
18
+ content: [{ type: "text", text: "pong" }],
19
+ }));
20
+
21
+ async function main() {
22
+ const transport = new StdioServerTransport();
23
+ await server.connect(transport);
24
+
25
+ // Same graceful self-termination as the real mcp-server.ts
26
+ process.stdin.on("end", () => {
27
+ server.close().finally(() => process.exit(0));
28
+ });
29
+
30
+ // Signal readiness to the test harness
31
+ process.stderr.write("READY\n");
32
+ }
33
+
34
+ main().catch((err) => {
35
+ process.stderr.write(`test-mcp-server error: ${err}\n`);
36
+ process.exit(1);
37
+ });