talon-agent 1.2.0 → 1.4.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/package.json +7 -6
- package/prompts/dream.md +6 -2
- package/prompts/mempalace.md +57 -0
- package/src/__tests__/compose-tools.test.ts +216 -0
- package/src/__tests__/cron-store-extended.test.ts +1 -1
- package/src/__tests__/dream.test.ts +118 -1
- package/src/__tests__/fuzz.test.ts +1 -3
- package/src/__tests__/gateway-actions.test.ts +1 -423
- package/src/__tests__/gateway-retry.test.ts +0 -4
- package/src/__tests__/handlers.test.ts +0 -4
- package/src/__tests__/heartbeat.test.ts +3 -0
- package/src/__tests__/mempalace-plugin.test.ts +295 -0
- package/src/__tests__/plugin.test.ts +169 -0
- package/src/__tests__/storage-save-errors.test.ts +1 -1
- package/src/__tests__/time.test.ts +1 -1
- package/src/__tests__/watchdog.test.ts +1 -3
- package/src/__tests__/workspace.test.ts +0 -1
- package/src/backend/claude-sdk/index.ts +39 -54
- package/src/backend/opencode/index.ts +5 -20
- package/src/bootstrap.ts +140 -11
- package/src/core/dream.ts +40 -6
- package/src/core/gateway-actions.ts +0 -87
- package/src/core/plugin.ts +103 -16
- package/src/core/tools/bridge.ts +40 -0
- package/src/core/tools/chat.ts +52 -0
- package/src/core/tools/history.ts +80 -0
- package/src/core/tools/index.ts +82 -0
- package/src/core/tools/mcp-server.ts +64 -0
- package/src/core/tools/media.ts +23 -0
- package/src/core/tools/members.ts +46 -0
- package/src/core/tools/messaging.ts +300 -0
- package/src/core/tools/scheduling.ts +89 -0
- package/src/core/tools/stickers.ts +143 -0
- package/src/core/tools/types.ts +60 -0
- package/src/core/tools/web.ts +26 -0
- package/src/frontend/telegram/actions.ts +10 -1
- package/src/frontend/telegram/handlers.ts +5 -17
- package/src/plugins/github/index.ts +106 -0
- package/src/plugins/mempalace/index.ts +147 -0
- package/src/plugins/playwright/index.ts +82 -0
- package/src/storage/sessions.ts +0 -10
- package/src/util/config.ts +31 -1
- package/src/util/log.ts +4 -1
- package/src/util/paths.ts +9 -0
- package/src/backend/claude-sdk/tools.ts +0 -651
- package/src/frontend/teams/tools.ts +0 -175
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("../util/log.js", () => ({
|
|
4
|
+
log: vi.fn(),
|
|
5
|
+
logError: vi.fn(),
|
|
6
|
+
logWarn: vi.fn(),
|
|
7
|
+
logDebug: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const PROMPT_TEMPLATE = `## MemPalace — Long-term Memory
|
|
11
|
+
|
|
12
|
+
mempalace_search mempalace_add_drawer mempalace_kg_query mempalace_kg_invalidate
|
|
13
|
+
mempalace_kg_timeline mempalace_traverse mempalace_find_tunnels
|
|
14
|
+
mempalace_diary_write mempalace_diary_read mempalace_delete_drawer
|
|
15
|
+
Protocol
|
|
16
|
+
|
|
17
|
+
### Palace location: \`{{palacePath}}\`
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
describe("mempalace plugin", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("creates a plugin with correct name and MCP server config", async () => {
|
|
27
|
+
vi.doMock("node:fs", () => ({
|
|
28
|
+
existsSync: vi.fn(() => true),
|
|
29
|
+
mkdirSync: vi.fn(),
|
|
30
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
31
|
+
}));
|
|
32
|
+
vi.doMock("node:child_process", () => ({
|
|
33
|
+
execFileSync: vi.fn(() => "ok"),
|
|
34
|
+
execFile: vi.fn(
|
|
35
|
+
(
|
|
36
|
+
_cmd: string,
|
|
37
|
+
_args: string[],
|
|
38
|
+
_opts: unknown,
|
|
39
|
+
cb: (
|
|
40
|
+
err: Error | null,
|
|
41
|
+
result: { stdout: string; stderr: string },
|
|
42
|
+
) => void,
|
|
43
|
+
) => {
|
|
44
|
+
cb(null, { stdout: "Palace: 42 drawers", stderr: "" });
|
|
45
|
+
},
|
|
46
|
+
),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const { createMempalacePlugin } =
|
|
50
|
+
await import("../plugins/mempalace/index.js");
|
|
51
|
+
const plugin = createMempalacePlugin({
|
|
52
|
+
pythonPath: "/venv/bin/python",
|
|
53
|
+
palacePath: "/data/palace",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(plugin.name).toBe("mempalace");
|
|
57
|
+
expect(plugin.version).toBe("1.0.0");
|
|
58
|
+
expect(plugin.mcpServer).toEqual({
|
|
59
|
+
command: "/venv/bin/python",
|
|
60
|
+
args: ["-m", "mempalace.mcp_server", "--palace", "/data/palace"],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("validateConfig returns error when python binary not found", async () => {
|
|
65
|
+
vi.doMock("node:fs", () => ({
|
|
66
|
+
existsSync: vi.fn(() => false),
|
|
67
|
+
mkdirSync: vi.fn(),
|
|
68
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
69
|
+
}));
|
|
70
|
+
vi.doMock("node:child_process", () => ({
|
|
71
|
+
execFileSync: vi.fn(),
|
|
72
|
+
execFile: vi.fn(),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const { createMempalacePlugin } =
|
|
76
|
+
await import("../plugins/mempalace/index.js");
|
|
77
|
+
const plugin = createMempalacePlugin({
|
|
78
|
+
pythonPath: "/nonexistent/python",
|
|
79
|
+
palacePath: "/data/palace",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const errors = plugin.validateConfig!({});
|
|
83
|
+
expect(errors).toBeDefined();
|
|
84
|
+
expect(errors!.length).toBeGreaterThan(0);
|
|
85
|
+
expect(errors![0]).toContain("Python binary not found");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("validateConfig passes when python binary exists and mempalace is importable", async () => {
|
|
89
|
+
vi.doMock("node:fs", () => ({
|
|
90
|
+
existsSync: vi.fn(() => true),
|
|
91
|
+
mkdirSync: vi.fn(),
|
|
92
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
93
|
+
}));
|
|
94
|
+
vi.doMock("node:child_process", () => ({
|
|
95
|
+
execFileSync: vi.fn(() => "ok"),
|
|
96
|
+
execFile: vi.fn(),
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { createMempalacePlugin } =
|
|
100
|
+
await import("../plugins/mempalace/index.js");
|
|
101
|
+
const plugin = createMempalacePlugin({
|
|
102
|
+
pythonPath: "/venv/bin/python",
|
|
103
|
+
palacePath: "/data/palace",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const errors = plugin.validateConfig!({});
|
|
107
|
+
expect(errors).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("validateConfig returns error when mempalace is not importable", async () => {
|
|
111
|
+
vi.doMock("node:fs", () => ({
|
|
112
|
+
existsSync: vi.fn(() => true),
|
|
113
|
+
mkdirSync: vi.fn(),
|
|
114
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
115
|
+
}));
|
|
116
|
+
vi.doMock("node:child_process", () => ({
|
|
117
|
+
execFileSync: vi.fn(() => {
|
|
118
|
+
throw new Error("ModuleNotFoundError");
|
|
119
|
+
}),
|
|
120
|
+
execFile: vi.fn(),
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const { createMempalacePlugin } =
|
|
124
|
+
await import("../plugins/mempalace/index.js");
|
|
125
|
+
const plugin = createMempalacePlugin({
|
|
126
|
+
pythonPath: "/venv/bin/python",
|
|
127
|
+
palacePath: "/data/palace",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const errors = plugin.validateConfig!({});
|
|
131
|
+
expect(errors).toBeDefined();
|
|
132
|
+
expect(errors!.length).toBeGreaterThan(0);
|
|
133
|
+
expect(errors![0]).toContain("mempalace package not installed");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("init creates palace directory if missing", async () => {
|
|
137
|
+
const mkdirSyncMock = vi.fn();
|
|
138
|
+
vi.doMock("node:fs", () => ({
|
|
139
|
+
existsSync: vi.fn((p: string) =>
|
|
140
|
+
p === "/venv/bin/python" ? true : false,
|
|
141
|
+
),
|
|
142
|
+
mkdirSync: mkdirSyncMock,
|
|
143
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
144
|
+
}));
|
|
145
|
+
vi.doMock("node:child_process", () => ({
|
|
146
|
+
execFileSync: vi.fn(() => "ok"),
|
|
147
|
+
execFile: vi.fn(
|
|
148
|
+
(
|
|
149
|
+
_cmd: string,
|
|
150
|
+
_args: string[],
|
|
151
|
+
_opts: unknown,
|
|
152
|
+
cb: (
|
|
153
|
+
err: Error | null,
|
|
154
|
+
result: { stdout: string; stderr: string },
|
|
155
|
+
) => void,
|
|
156
|
+
) => {
|
|
157
|
+
cb(null, { stdout: "ok", stderr: "" });
|
|
158
|
+
},
|
|
159
|
+
),
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const { createMempalacePlugin } =
|
|
163
|
+
await import("../plugins/mempalace/index.js");
|
|
164
|
+
const plugin = createMempalacePlugin({
|
|
165
|
+
pythonPath: "/venv/bin/python",
|
|
166
|
+
palacePath: "/data/new-palace",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await plugin.init!({});
|
|
170
|
+
expect(mkdirSyncMock).toHaveBeenCalledWith("/data/new-palace", {
|
|
171
|
+
recursive: true,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("validateConfig returns error when python binary exists but mempalace import fails with ENOENT", async () => {
|
|
176
|
+
vi.doMock("node:fs", () => ({
|
|
177
|
+
existsSync: vi.fn(() => true),
|
|
178
|
+
mkdirSync: vi.fn(),
|
|
179
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
180
|
+
}));
|
|
181
|
+
vi.doMock("node:child_process", () => ({
|
|
182
|
+
execFileSync: vi.fn(() => {
|
|
183
|
+
const err = new Error("spawn ENOENT") as Error & { code: string };
|
|
184
|
+
err.code = "ENOENT";
|
|
185
|
+
throw err;
|
|
186
|
+
}),
|
|
187
|
+
execFile: vi.fn(),
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
const { createMempalacePlugin } =
|
|
191
|
+
await import("../plugins/mempalace/index.js");
|
|
192
|
+
const plugin = createMempalacePlugin({
|
|
193
|
+
pythonPath: "/venv/bin/python",
|
|
194
|
+
palacePath: "/data/palace",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const errors = plugin.validateConfig!({});
|
|
198
|
+
expect(errors).toBeDefined();
|
|
199
|
+
expect(errors!.length).toBeGreaterThan(0);
|
|
200
|
+
expect(errors![0]).toContain("Cannot execute Python");
|
|
201
|
+
expect(errors![0]).toContain("ENOENT");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("getEnvVars returns MEMPALACE_PALACE_PATH", async () => {
|
|
205
|
+
vi.doMock("node:fs", () => ({
|
|
206
|
+
existsSync: vi.fn(() => true),
|
|
207
|
+
mkdirSync: vi.fn(),
|
|
208
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
209
|
+
}));
|
|
210
|
+
vi.doMock("node:child_process", () => ({
|
|
211
|
+
execFileSync: vi.fn(),
|
|
212
|
+
execFile: vi.fn(),
|
|
213
|
+
}));
|
|
214
|
+
|
|
215
|
+
const { createMempalacePlugin } =
|
|
216
|
+
await import("../plugins/mempalace/index.js");
|
|
217
|
+
const plugin = createMempalacePlugin({
|
|
218
|
+
pythonPath: "/venv/bin/python",
|
|
219
|
+
palacePath: "/data/palace",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(plugin.getEnvVars!({})).toEqual({
|
|
223
|
+
MEMPALACE_PALACE_PATH: "/data/palace",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("getSystemPromptAddition loads from .md file and interpolates palacePath", async () => {
|
|
228
|
+
vi.doMock("node:fs", () => ({
|
|
229
|
+
existsSync: vi.fn(() => true),
|
|
230
|
+
mkdirSync: vi.fn(),
|
|
231
|
+
readFileSync: vi.fn(() => PROMPT_TEMPLATE),
|
|
232
|
+
}));
|
|
233
|
+
vi.doMock("node:child_process", () => ({
|
|
234
|
+
execFileSync: vi.fn(),
|
|
235
|
+
execFile: vi.fn(),
|
|
236
|
+
}));
|
|
237
|
+
|
|
238
|
+
const { createMempalacePlugin } =
|
|
239
|
+
await import("../plugins/mempalace/index.js");
|
|
240
|
+
const plugin = createMempalacePlugin({
|
|
241
|
+
pythonPath: "/venv/bin/python",
|
|
242
|
+
palacePath: "/custom/palace",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const addition = plugin.getSystemPromptAddition!({});
|
|
246
|
+
expect(addition).toContain("MemPalace");
|
|
247
|
+
expect(addition).toContain("mempalace_search");
|
|
248
|
+
expect(addition).toContain("mempalace_add_drawer");
|
|
249
|
+
expect(addition).toContain("mempalace_kg_query");
|
|
250
|
+
expect(addition).toContain("mempalace_kg_invalidate");
|
|
251
|
+
expect(addition).toContain("mempalace_kg_timeline");
|
|
252
|
+
expect(addition).toContain("mempalace_traverse");
|
|
253
|
+
expect(addition).toContain("mempalace_find_tunnels");
|
|
254
|
+
expect(addition).toContain("mempalace_diary_write");
|
|
255
|
+
expect(addition).toContain("mempalace_diary_read");
|
|
256
|
+
expect(addition).toContain("mempalace_delete_drawer");
|
|
257
|
+
expect(addition).toContain("Protocol");
|
|
258
|
+
expect(addition).toContain("/custom/palace");
|
|
259
|
+
// Verify interpolation happened — no raw placeholder
|
|
260
|
+
expect(addition).not.toContain("{{palacePath}}");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("getSystemPromptAddition returns fallback when .md file is missing", async () => {
|
|
264
|
+
vi.doMock("node:fs", () => ({
|
|
265
|
+
existsSync: vi.fn(() => true),
|
|
266
|
+
mkdirSync: vi.fn(),
|
|
267
|
+
readFileSync: vi.fn(() => {
|
|
268
|
+
throw new Error("ENOENT: no such file");
|
|
269
|
+
}),
|
|
270
|
+
}));
|
|
271
|
+
vi.doMock("node:child_process", () => ({
|
|
272
|
+
execFileSync: vi.fn(),
|
|
273
|
+
execFile: vi.fn(),
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
const { logWarn } = (await import("../util/log.js")) as unknown as {
|
|
277
|
+
logWarn: ReturnType<typeof vi.fn>;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const { createMempalacePlugin } =
|
|
281
|
+
await import("../plugins/mempalace/index.js");
|
|
282
|
+
const plugin = createMempalacePlugin({
|
|
283
|
+
pythonPath: "/venv/bin/python",
|
|
284
|
+
palacePath: "/data/palace",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const addition = plugin.getSystemPromptAddition!({});
|
|
288
|
+
expect(addition).toContain("MemPalace");
|
|
289
|
+
expect(addition).toContain("/data/palace");
|
|
290
|
+
expect(logWarn).toHaveBeenCalledWith(
|
|
291
|
+
"mempalace",
|
|
292
|
+
expect.stringContaining("Failed to load prompt"),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -218,6 +218,97 @@ describe("plugin system", () => {
|
|
|
218
218
|
});
|
|
219
219
|
}
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
it("builds MCP server entries for plugins with custom mcpServer", async () => {
|
|
223
|
+
const plugin = createMockPlugin({
|
|
224
|
+
mcpServer: {
|
|
225
|
+
command: "/usr/bin/python3",
|
|
226
|
+
args: ["-m", "mempalace.mcp_server", "--palace", "/tmp/palace"],
|
|
227
|
+
},
|
|
228
|
+
getEnvVars: () => ({ PALACE_PATH: "/tmp/palace" }),
|
|
229
|
+
});
|
|
230
|
+
// Remove mcpServerPath so mcpServer is used
|
|
231
|
+
delete (plugin as Record<string, unknown>).mcpServerPath;
|
|
232
|
+
const { loadPlugins, getPluginMcpServers } = await setup(plugin);
|
|
233
|
+
await loadPlugins([{ path: "/fake/plugin" }]);
|
|
234
|
+
|
|
235
|
+
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
236
|
+
expect(servers["test-plugin-tools"]).toBeDefined();
|
|
237
|
+
expect(servers["test-plugin-tools"].command).toBe("/usr/bin/python3");
|
|
238
|
+
expect(servers["test-plugin-tools"].args).toEqual([
|
|
239
|
+
"-m",
|
|
240
|
+
"mempalace.mcp_server",
|
|
241
|
+
"--palace",
|
|
242
|
+
"/tmp/palace",
|
|
243
|
+
]);
|
|
244
|
+
expect(servers["test-plugin-tools"].env.PALACE_PATH).toBe("/tmp/palace");
|
|
245
|
+
expect(servers["test-plugin-tools"].env.TALON_BRIDGE_URL).toBe(
|
|
246
|
+
"http://localhost:19876",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("mcpServer takes priority over mcpServerPath when both are set", async () => {
|
|
251
|
+
const plugin = createMockPlugin({
|
|
252
|
+
mcpServerPath: "/fake/tools.ts",
|
|
253
|
+
mcpServer: {
|
|
254
|
+
command: "/usr/bin/python3",
|
|
255
|
+
args: ["-m", "my_server"],
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const { loadPlugins, getPluginMcpServers } = await setup(plugin);
|
|
259
|
+
await loadPlugins([{ path: "/fake/plugin" }]);
|
|
260
|
+
|
|
261
|
+
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
262
|
+
// mcpServer should win over mcpServerPath
|
|
263
|
+
expect(servers["test-plugin-tools"].command).toBe("/usr/bin/python3");
|
|
264
|
+
expect(servers["test-plugin-tools"].args).toEqual(["-m", "my_server"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("skips plugins without mcpServer or mcpServerPath", async () => {
|
|
268
|
+
const plugin = createMockPlugin();
|
|
269
|
+
delete (plugin as Record<string, unknown>).mcpServerPath;
|
|
270
|
+
delete (plugin as Record<string, unknown>).mcpServer;
|
|
271
|
+
const { loadPlugins, getPluginMcpServers } = await setup(plugin);
|
|
272
|
+
await loadPlugins([{ path: "/fake/plugin" }]);
|
|
273
|
+
|
|
274
|
+
const servers = getPluginMcpServers("http://localhost:19876", "chat1");
|
|
275
|
+
expect(Object.keys(servers)).toHaveLength(0);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("registerPlugin (built-in)", () => {
|
|
280
|
+
it("registers a built-in plugin directly", async () => {
|
|
281
|
+
const plugin = createMockPlugin({ name: "built-in-test" });
|
|
282
|
+
const { registerPlugin, getPlugin } = await setup(createMockPlugin());
|
|
283
|
+
|
|
284
|
+
registerPlugin(plugin, { key: "val" });
|
|
285
|
+
expect(getPlugin("built-in-test")).toBeDefined();
|
|
286
|
+
expect(getPlugin("built-in-test")!.path).toBe("(built-in)");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("sets env vars from built-in plugin", async () => {
|
|
290
|
+
const plugin = createMockPlugin({
|
|
291
|
+
name: "builtin-env",
|
|
292
|
+
getEnvVars: () => ({
|
|
293
|
+
TEST_PLUGIN_BUILTIN_FOO: "baz",
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
const { registerPlugin } = await setup(createMockPlugin());
|
|
297
|
+
|
|
298
|
+
registerPlugin(plugin);
|
|
299
|
+
expect(process.env.TEST_PLUGIN_BUILTIN_FOO).toBe("baz");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("skips registration when validateConfig returns errors", async () => {
|
|
303
|
+
const plugin = createMockPlugin({
|
|
304
|
+
name: "builtin-invalid",
|
|
305
|
+
validateConfig: () => ["missing required field"],
|
|
306
|
+
});
|
|
307
|
+
const { registerPlugin, getPlugin } = await setup(createMockPlugin());
|
|
308
|
+
|
|
309
|
+
registerPlugin(plugin, {});
|
|
310
|
+
expect(getPlugin("builtin-invalid")).toBeUndefined();
|
|
311
|
+
});
|
|
221
312
|
});
|
|
222
313
|
|
|
223
314
|
describe("system prompt additions", () => {
|
|
@@ -409,6 +500,84 @@ describe("extractPlugin — invalid optional field types", () => {
|
|
|
409
500
|
expect(mod.getPluginCount()).toBe(0);
|
|
410
501
|
});
|
|
411
502
|
|
|
503
|
+
it("rejects plugin when mcpServer is not an object", async () => {
|
|
504
|
+
const plugin = { name: "bad-mcp-server", mcpServer: "not-an-object" };
|
|
505
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
506
|
+
const mod = await import("../core/plugin.js");
|
|
507
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
508
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-server" }]);
|
|
509
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("rejects plugin when mcpServer is null", async () => {
|
|
513
|
+
const plugin = { name: "null-mcp-server", mcpServer: null };
|
|
514
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
515
|
+
const mod = await import("../core/plugin.js");
|
|
516
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
517
|
+
await mod.loadPlugins([{ path: "/fake/null-mcp-server" }]);
|
|
518
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("rejects plugin when mcpServer.command is not a string", async () => {
|
|
522
|
+
const plugin = {
|
|
523
|
+
name: "bad-mcp-cmd",
|
|
524
|
+
mcpServer: { command: 123, args: [] },
|
|
525
|
+
};
|
|
526
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
527
|
+
const mod = await import("../core/plugin.js");
|
|
528
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
529
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-cmd" }]);
|
|
530
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("rejects plugin when mcpServer.args is not an array", async () => {
|
|
534
|
+
const plugin = {
|
|
535
|
+
name: "bad-mcp-args",
|
|
536
|
+
mcpServer: { command: "/usr/bin/python3", args: "not-an-array" },
|
|
537
|
+
};
|
|
538
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
539
|
+
const mod = await import("../core/plugin.js");
|
|
540
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
541
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-args" }]);
|
|
542
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("rejects plugin when mcpServer.command is empty string", async () => {
|
|
546
|
+
const plugin = {
|
|
547
|
+
name: "empty-mcp-cmd",
|
|
548
|
+
mcpServer: { command: "", args: ["-m", "server"] },
|
|
549
|
+
};
|
|
550
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
551
|
+
const mod = await import("../core/plugin.js");
|
|
552
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
553
|
+
await mod.loadPlugins([{ path: "/fake/empty-mcp-cmd" }]);
|
|
554
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("rejects plugin when mcpServer.args contains non-string elements", async () => {
|
|
558
|
+
const plugin = {
|
|
559
|
+
name: "bad-mcp-args-types",
|
|
560
|
+
mcpServer: { command: "/usr/bin/python3", args: ["-m", 42] },
|
|
561
|
+
};
|
|
562
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
563
|
+
const mod = await import("../core/plugin.js");
|
|
564
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
565
|
+
await mod.loadPlugins([{ path: "/fake/bad-mcp-args-types" }]);
|
|
566
|
+
expect(mod.getPluginCount()).toBe(0);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("accepts plugin with valid mcpServer object", async () => {
|
|
570
|
+
const plugin = {
|
|
571
|
+
name: "good-mcp-server",
|
|
572
|
+
mcpServer: { command: "/usr/bin/python3", args: ["-m", "server"] },
|
|
573
|
+
};
|
|
574
|
+
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
575
|
+
const mod = await import("../core/plugin.js");
|
|
576
|
+
mod._deps.importModule = async () => ({ default: plugin });
|
|
577
|
+
await mod.loadPlugins([{ path: "/fake/good-mcp-server" }]);
|
|
578
|
+
expect(mod.getPluginCount()).toBe(1);
|
|
579
|
+
});
|
|
580
|
+
|
|
412
581
|
it("catches and logs error when importModule throws", async () => {
|
|
413
582
|
vi.resetModules();
|
|
414
583
|
vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
|
|
@@ -49,7 +49,7 @@ describe("cron-store — save failure logs error", () => {
|
|
|
49
49
|
registerCleanup: vi.fn(),
|
|
50
50
|
}));
|
|
51
51
|
|
|
52
|
-
const { addCronJob, generateCronId
|
|
52
|
+
const { addCronJob, generateCronId } =
|
|
53
53
|
await import("../storage/cron-store.js");
|
|
54
54
|
|
|
55
55
|
const job = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
2
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -38,8 +38,6 @@ describe("watchdog", () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("updates lastProcessedAt timestamp", () => {
|
|
41
|
-
const beforeStatus = getHealthStatus();
|
|
42
|
-
const beforeMs = beforeStatus.msSinceLastMessage;
|
|
43
41
|
recordMessageProcessed();
|
|
44
42
|
const afterStatus = getHealthStatus();
|
|
45
43
|
// After recording, msSinceLastMessage should be very small (near 0)
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
setSessionName,
|
|
10
10
|
} from "../../storage/sessions.js";
|
|
11
11
|
import { getChatSettings, setChatModel } from "../../storage/chat-settings.js";
|
|
12
|
-
import { getRecentHistory } from "../../storage/history.js";
|
|
13
12
|
import { resolve } from "node:path";
|
|
14
13
|
import { classify } from "../../core/errors.js";
|
|
15
14
|
import {
|
|
@@ -19,7 +18,7 @@ import {
|
|
|
19
18
|
import { rebuildSystemPrompt } from "../../util/config.js";
|
|
20
19
|
import { log, logError, logWarn } from "../../util/log.js";
|
|
21
20
|
import { traceMessage } from "../../util/trace.js";
|
|
22
|
-
import {
|
|
21
|
+
import { formatFullDatetime } from "../../util/time.js";
|
|
23
22
|
|
|
24
23
|
import type { QueryParams, QueryResult } from "../../core/types.js";
|
|
25
24
|
|
|
@@ -115,66 +114,68 @@ export async function handleMessage(
|
|
|
115
114
|
"TaskOutput",
|
|
116
115
|
"TaskStop",
|
|
117
116
|
"AskUserQuestion",
|
|
117
|
+
// Always disable Claude Code built-in web tools — fetch_url is always
|
|
118
|
+
// available, and Brave Search MCP replaces WebSearch when configured.
|
|
119
|
+
"WebSearch",
|
|
120
|
+
"WebFetch",
|
|
118
121
|
],
|
|
119
122
|
...thinkingConfig,
|
|
120
123
|
mcpServers: {
|
|
121
|
-
// Register
|
|
124
|
+
// Register unified MCP tools server — one per messaging frontend.
|
|
125
|
+
// Terminal frontend relies on Claude Code built-in tools (Read, Write,
|
|
126
|
+
// Bash, etc.) and doesn't need a custom MCP tools server.
|
|
122
127
|
...(() => {
|
|
123
|
-
const
|
|
128
|
+
const allFrontends = Array.isArray(config.frontend)
|
|
124
129
|
? config.frontend
|
|
125
130
|
: [config.frontend];
|
|
131
|
+
const frontends = allFrontends.filter((f) => f !== "terminal");
|
|
126
132
|
const bridgeUrl = `http://127.0.0.1:${bridgePortFn()}`;
|
|
127
|
-
const mcpEnv = { TALON_BRIDGE_URL: bridgeUrl, TALON_CHAT_ID: chatId };
|
|
128
133
|
const servers: Record<
|
|
129
134
|
string,
|
|
130
135
|
{ command: string; args: string[]; env: Record<string, string> }
|
|
131
136
|
> = {};
|
|
132
|
-
// Resolve tsx from Talon's node_modules (cwd may be ~/.talon/workspace/ which has no node_modules)
|
|
133
137
|
// Resolve tsx from the package root (3 levels up from src/backend/claude-sdk/)
|
|
134
138
|
const tsxImport = resolve(
|
|
135
139
|
import.meta.dirname ?? ".",
|
|
136
140
|
"../../../node_modules/tsx/dist/esm/index.mjs",
|
|
137
141
|
);
|
|
142
|
+
// Unified MCP server in core/tools/
|
|
143
|
+
const mcpServerPath = resolve(
|
|
144
|
+
import.meta.dirname ?? ".",
|
|
145
|
+
"../../core/tools/mcp-server.ts",
|
|
146
|
+
);
|
|
138
147
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: [
|
|
146
|
-
"--import",
|
|
147
|
-
tsxImport,
|
|
148
|
-
resolve(import.meta.dirname ?? ".", "tools.ts"),
|
|
149
|
-
],
|
|
150
|
-
env: mcpEnv,
|
|
148
|
+
for (const frontend of frontends) {
|
|
149
|
+
const serverName = `${frontend}-tools`;
|
|
150
|
+
const mcpEnv = {
|
|
151
|
+
TALON_BRIDGE_URL: bridgeUrl,
|
|
152
|
+
TALON_CHAT_ID: chatId,
|
|
153
|
+
TALON_FRONTEND: frontend,
|
|
151
154
|
};
|
|
152
|
-
|
|
153
|
-
if (frontends.includes("teams")) {
|
|
154
|
-
servers["teams-tools"] = {
|
|
155
|
+
servers[serverName] = {
|
|
155
156
|
command: process.platform === "win32" ? "npx" : "node",
|
|
156
157
|
args:
|
|
157
158
|
process.platform === "win32"
|
|
158
|
-
? [
|
|
159
|
-
|
|
160
|
-
resolve(
|
|
161
|
-
import.meta.dirname ?? ".",
|
|
162
|
-
"../../frontend/teams/tools.ts",
|
|
163
|
-
),
|
|
164
|
-
]
|
|
165
|
-
: [
|
|
166
|
-
"--import",
|
|
167
|
-
tsxImport,
|
|
168
|
-
resolve(
|
|
169
|
-
import.meta.dirname ?? ".",
|
|
170
|
-
"../../frontend/teams/tools.ts",
|
|
171
|
-
),
|
|
172
|
-
],
|
|
159
|
+
? ["tsx", mcpServerPath]
|
|
160
|
+
: ["--import", tsxImport, mcpServerPath],
|
|
173
161
|
env: mcpEnv,
|
|
174
162
|
};
|
|
175
163
|
}
|
|
176
164
|
return servers;
|
|
177
165
|
})(),
|
|
166
|
+
// Brave Search MCP server — provides brave_web_search and brave_local_search
|
|
167
|
+
...(config.braveApiKey
|
|
168
|
+
? {
|
|
169
|
+
"brave-search": {
|
|
170
|
+
command: resolve(
|
|
171
|
+
import.meta.dirname ?? ".",
|
|
172
|
+
"../../../node_modules/.bin/brave-search-mcp-server",
|
|
173
|
+
),
|
|
174
|
+
args: [],
|
|
175
|
+
env: { BRAVE_API_KEY: config.braveApiKey },
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
: {}),
|
|
178
179
|
...getPluginMcpServers(`http://127.0.0.1:${bridgePortFn()}`, chatId),
|
|
179
180
|
},
|
|
180
181
|
...(session.sessionId ? { resume: session.sessionId } : {}),
|
|
@@ -183,25 +184,9 @@ export async function handleMessage(
|
|
|
183
184
|
const msgIdHint = params.messageId ? ` [msg_id:${params.messageId}]` : "";
|
|
184
185
|
const nowTag = `[${formatFullDatetime()}]`;
|
|
185
186
|
|
|
186
|
-
// Session continuity: when resuming a session that has history but no active
|
|
187
|
-
// SDK session (after restart or /resume), prepend recent messages for context.
|
|
188
|
-
let continuityPrefix = "";
|
|
189
|
-
if (!session.sessionId && session.turns > 0) {
|
|
190
|
-
const recentMsgs = getRecentHistory(chatId, 10);
|
|
191
|
-
if (recentMsgs.length > 0) {
|
|
192
|
-
const contextLines = recentMsgs
|
|
193
|
-
.map((m) => {
|
|
194
|
-
const time = formatSmartTimestamp(m.timestamp);
|
|
195
|
-
return `[${time}] ${m.senderName}: ${m.text.slice(0, 300)}`;
|
|
196
|
-
})
|
|
197
|
-
.join("\n");
|
|
198
|
-
continuityPrefix = `[Session resumed — recent conversation context:\n${contextLines}]\n\n`;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
187
|
const prompt = isGroup
|
|
203
|
-
? `${
|
|
204
|
-
: `${
|
|
188
|
+
? `${nowTag} [${senderName}]${msgIdHint}: ${text}`
|
|
189
|
+
: `${nowTag}${msgIdHint} ${text}`;
|
|
205
190
|
log("agent", `[${chatId}] <- (${text.length} chars)`);
|
|
206
191
|
traceMessage(chatId, "in", text, { senderName, isGroup });
|
|
207
192
|
|