talon-agent 1.0.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 +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
vi.mock("write-file-atomic", () => ({
|
|
4
|
+
default: { sync: vi.fn() },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
describe("config", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.resetModules();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function mockFs(
|
|
13
|
+
configJson: Record<string, unknown> | null,
|
|
14
|
+
promptFiles: Record<string, string> = {},
|
|
15
|
+
workspaceEntries?: { name: string; isDir: boolean; size?: number; children?: { name: string; size: number }[] }[],
|
|
16
|
+
) {
|
|
17
|
+
vi.doMock("node:fs", () => ({
|
|
18
|
+
existsSync: vi.fn((path: string) => {
|
|
19
|
+
if (path.includes("config.json") || path.includes("talon.json")) return configJson !== null;
|
|
20
|
+
// .talon directory checks (root, data)
|
|
21
|
+
if (path.endsWith(".talon") || path.endsWith("/data")) return true;
|
|
22
|
+
// workspace directory check
|
|
23
|
+
if (path.endsWith("workspace") && workspaceEntries !== undefined) return true;
|
|
24
|
+
if (typeof path === "string") {
|
|
25
|
+
for (const key of Object.keys(promptFiles)) {
|
|
26
|
+
if (path.includes(key)) return true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}),
|
|
31
|
+
readFileSync: vi.fn((path: string) => {
|
|
32
|
+
if (path.includes("config.json") || path.includes("talon.json")) return JSON.stringify(configJson ?? {});
|
|
33
|
+
for (const [key, val] of Object.entries(promptFiles)) {
|
|
34
|
+
if (path.includes(key)) return val;
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}),
|
|
38
|
+
mkdirSync: vi.fn(),
|
|
39
|
+
readdirSync: vi.fn((dir: string) => {
|
|
40
|
+
if (!workspaceEntries) return [];
|
|
41
|
+
// If this is a subdirectory, find its children
|
|
42
|
+
for (const entry of workspaceEntries) {
|
|
43
|
+
if (entry.isDir && dir.endsWith(entry.name) && entry.children) {
|
|
44
|
+
return entry.children.map((c) => ({
|
|
45
|
+
name: c.name,
|
|
46
|
+
isDirectory: () => false,
|
|
47
|
+
isFile: () => true,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Top-level workspace dir
|
|
52
|
+
if (dir.endsWith("workspace")) {
|
|
53
|
+
return workspaceEntries.map((e) => ({
|
|
54
|
+
name: e.name,
|
|
55
|
+
isDirectory: () => e.isDir,
|
|
56
|
+
isFile: () => !e.isDir,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}),
|
|
61
|
+
statSync: vi.fn((filePath: string) => {
|
|
62
|
+
// Find matching file in workspace entries
|
|
63
|
+
if (workspaceEntries) {
|
|
64
|
+
for (const entry of workspaceEntries) {
|
|
65
|
+
if (!entry.isDir && filePath.endsWith(entry.name)) {
|
|
66
|
+
return { size: entry.size ?? 100 };
|
|
67
|
+
}
|
|
68
|
+
if (entry.isDir && entry.children) {
|
|
69
|
+
for (const child of entry.children) {
|
|
70
|
+
if (filePath.endsWith(child.name)) {
|
|
71
|
+
return { size: child.size };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { size: 0 };
|
|
78
|
+
}),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("loadConfig", () => {
|
|
83
|
+
it("loads config with terminal frontend (no token needed)", async () => {
|
|
84
|
+
mockFs({ frontend: "terminal" });
|
|
85
|
+
|
|
86
|
+
const { loadConfig } = await import("../util/config.js");
|
|
87
|
+
const config = loadConfig();
|
|
88
|
+
expect(config.frontend).toBe("terminal");
|
|
89
|
+
expect(config.model).toBe("claude-sonnet-4-6");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("throws when telegram frontend has no botToken", async () => {
|
|
93
|
+
mockFs({ frontend: "telegram" });
|
|
94
|
+
|
|
95
|
+
const { loadConfig } = await import("../util/config.js");
|
|
96
|
+
expect(() => loadConfig()).toThrow("botToken");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("loads config from talon.json", async () => {
|
|
100
|
+
mockFs({ botToken: "test-token-123", model: "claude-opus-4-6" });
|
|
101
|
+
|
|
102
|
+
const { loadConfig } = await import("../util/config.js");
|
|
103
|
+
const config = loadConfig();
|
|
104
|
+
expect(config.botToken).toBe("test-token-123");
|
|
105
|
+
expect(config.model).toBe("claude-opus-4-6");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("applies defaults for missing fields", async () => {
|
|
109
|
+
mockFs({ botToken: "test-token" });
|
|
110
|
+
|
|
111
|
+
const { loadConfig } = await import("../util/config.js");
|
|
112
|
+
const config = loadConfig();
|
|
113
|
+
expect(config.model).toBe("claude-sonnet-4-6");
|
|
114
|
+
expect(config.maxMessageLength).toBe(4000);
|
|
115
|
+
expect(config.concurrency).toBe(1);
|
|
116
|
+
expect(config.pulse).toBe(true);
|
|
117
|
+
expect(config.pulseIntervalMs).toBe(300000);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("reads custom maxMessageLength", async () => {
|
|
121
|
+
mockFs({ botToken: "test-token", maxMessageLength: 8000 });
|
|
122
|
+
|
|
123
|
+
const { loadConfig } = await import("../util/config.js");
|
|
124
|
+
const config = loadConfig();
|
|
125
|
+
expect(config.maxMessageLength).toBe(8000);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("defaults concurrency to 1", async () => {
|
|
129
|
+
mockFs({ botToken: "test-token" });
|
|
130
|
+
|
|
131
|
+
const { loadConfig } = await import("../util/config.js");
|
|
132
|
+
const config = loadConfig();
|
|
133
|
+
expect(config.concurrency).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("reads adminUserId from config", async () => {
|
|
137
|
+
mockFs({ botToken: "test-token", adminUserId: 352042062 });
|
|
138
|
+
|
|
139
|
+
const { loadConfig } = await import("../util/config.js");
|
|
140
|
+
const config = loadConfig();
|
|
141
|
+
expect(config.adminUserId).toBe(352042062);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads apiId and apiHash from config", async () => {
|
|
145
|
+
mockFs({ botToken: "test-token", apiId: 12345, apiHash: "abc123" });
|
|
146
|
+
|
|
147
|
+
const { loadConfig } = await import("../util/config.js");
|
|
148
|
+
const config = loadConfig();
|
|
149
|
+
expect(config.apiId).toBe(12345);
|
|
150
|
+
expect(config.apiHash).toBe("abc123");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("reads pulse settings from config", async () => {
|
|
154
|
+
mockFs({ botToken: "test-token", pulse: false, pulseIntervalMs: 600000 });
|
|
155
|
+
|
|
156
|
+
const { loadConfig } = await import("../util/config.js");
|
|
157
|
+
const config = loadConfig();
|
|
158
|
+
expect(config.pulse).toBe(false);
|
|
159
|
+
expect(config.pulseIntervalMs).toBe(600000);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("accepts frontend as an array", async () => {
|
|
163
|
+
mockFs({ frontend: ["telegram", "terminal"], botToken: "test-token" });
|
|
164
|
+
|
|
165
|
+
const { loadConfig } = await import("../util/config.js");
|
|
166
|
+
const config = loadConfig();
|
|
167
|
+
expect(Array.isArray(config.frontend)).toBe(true);
|
|
168
|
+
expect(config.frontend).toEqual(["telegram", "terminal"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("throws when frontend array includes telegram without botToken", async () => {
|
|
172
|
+
mockFs({ frontend: ["telegram", "terminal"] });
|
|
173
|
+
|
|
174
|
+
const { loadConfig } = await import("../util/config.js");
|
|
175
|
+
expect(() => loadConfig()).toThrow("botToken");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("parses plugins array in config", async () => {
|
|
179
|
+
mockFs({
|
|
180
|
+
frontend: "terminal",
|
|
181
|
+
plugins: [
|
|
182
|
+
{ path: "./plugins/my-plugin", config: { key: "value" } },
|
|
183
|
+
{ path: "./plugins/another" },
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const { loadConfig } = await import("../util/config.js");
|
|
188
|
+
const config = loadConfig();
|
|
189
|
+
expect(config.plugins).toHaveLength(2);
|
|
190
|
+
expect(config.plugins[0].path).toBe("./plugins/my-plugin");
|
|
191
|
+
expect(config.plugins[0].config).toEqual({ key: "value" });
|
|
192
|
+
expect(config.plugins[1].path).toBe("./plugins/another");
|
|
193
|
+
expect(config.plugins[1].config).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("defaults plugins to empty array", async () => {
|
|
197
|
+
mockFs({ frontend: "terminal" });
|
|
198
|
+
|
|
199
|
+
const { loadConfig } = await import("../util/config.js");
|
|
200
|
+
const config = loadConfig();
|
|
201
|
+
expect(config.plugins).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("creates config file when talon.json does not exist", async () => {
|
|
205
|
+
const writeFileAtomic = await import("write-file-atomic");
|
|
206
|
+
mockFs(null);
|
|
207
|
+
|
|
208
|
+
const { loadConfig } = await import("../util/config.js");
|
|
209
|
+
// loadConfig will call ensureConfigFile which writes defaults, then reads (but file won't exist so reads empty)
|
|
210
|
+
// Since no botToken and default frontend is telegram, it will throw
|
|
211
|
+
expect(() => loadConfig()).toThrow("botToken");
|
|
212
|
+
expect(writeFileAtomic.default.sync).toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("sets workspace to resolved workspace path", async () => {
|
|
216
|
+
mockFs({ frontend: "terminal" });
|
|
217
|
+
|
|
218
|
+
const { loadConfig } = await import("../util/config.js");
|
|
219
|
+
const config = loadConfig();
|
|
220
|
+
expect(config.workspace).toContain("workspace");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("loads config with terminal-only frontend array (no token needed)", async () => {
|
|
224
|
+
mockFs({ frontend: ["terminal"] });
|
|
225
|
+
|
|
226
|
+
const { loadConfig } = await import("../util/config.js");
|
|
227
|
+
const config = loadConfig();
|
|
228
|
+
expect(config.frontend).toEqual(["terminal"]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("system prompt", () => {
|
|
233
|
+
it("builds system prompt from prompt files", async () => {
|
|
234
|
+
mockFs(
|
|
235
|
+
{ botToken: "test-token" },
|
|
236
|
+
{ "identity.md": "I am Talon.", "base.md": "Be helpful." },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const { loadConfig } = await import("../util/config.js");
|
|
240
|
+
const config = loadConfig();
|
|
241
|
+
expect(config.systemPrompt).toContain("I am Talon.");
|
|
242
|
+
expect(config.systemPrompt).toContain("Be helpful.");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("includes current date in system prompt", async () => {
|
|
246
|
+
mockFs({ botToken: "test-token" });
|
|
247
|
+
|
|
248
|
+
const { loadConfig } = await import("../util/config.js");
|
|
249
|
+
const config = loadConfig();
|
|
250
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
251
|
+
expect(config.systemPrompt).toContain(today);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("includes workspace instructions in system prompt", async () => {
|
|
255
|
+
mockFs({ botToken: "test-token" });
|
|
256
|
+
|
|
257
|
+
const { loadConfig } = await import("../util/config.js");
|
|
258
|
+
const config = loadConfig();
|
|
259
|
+
expect(config.systemPrompt).toContain("workspace");
|
|
260
|
+
expect(config.systemPrompt).toContain("Cron Jobs");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("loads terminal.md prompt for terminal frontend", async () => {
|
|
264
|
+
mockFs(
|
|
265
|
+
{ frontend: "terminal" },
|
|
266
|
+
{ "terminal.md": "You are running in terminal mode." },
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const { loadConfig } = await import("../util/config.js");
|
|
270
|
+
const config = loadConfig();
|
|
271
|
+
expect(config.systemPrompt).toContain("You are running in terminal mode.");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("loads telegram.md prompt for telegram frontend", async () => {
|
|
275
|
+
mockFs(
|
|
276
|
+
{ botToken: "test-token", frontend: "telegram" },
|
|
277
|
+
{ "telegram.md": "You are a Telegram bot." },
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const { loadConfig } = await import("../util/config.js");
|
|
281
|
+
const config = loadConfig();
|
|
282
|
+
expect(config.systemPrompt).toContain("You are a Telegram bot.");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("uses default fallback when no base.md or custom.md exist", async () => {
|
|
286
|
+
mockFs({ frontend: "terminal" });
|
|
287
|
+
|
|
288
|
+
const { loadConfig } = await import("../util/config.js");
|
|
289
|
+
const config = loadConfig();
|
|
290
|
+
expect(config.systemPrompt).toContain("You are a sharp and helpful AI assistant.");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("custom.md overrides base.md", async () => {
|
|
294
|
+
mockFs(
|
|
295
|
+
{ frontend: "terminal" },
|
|
296
|
+
{ "custom.md": "Custom prompt override.", "base.md": "Default base prompt." },
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const { loadConfig } = await import("../util/config.js");
|
|
300
|
+
const config = loadConfig();
|
|
301
|
+
expect(config.systemPrompt).toContain("Custom prompt override.");
|
|
302
|
+
expect(config.systemPrompt).not.toContain("Default base prompt.");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("loads identity.md as the first section", async () => {
|
|
306
|
+
mockFs(
|
|
307
|
+
{ frontend: "terminal" },
|
|
308
|
+
{ "identity.md": "Identity section.", "base.md": "Base instructions." },
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const { loadConfig } = await import("../util/config.js");
|
|
312
|
+
const config = loadConfig();
|
|
313
|
+
// identity.md should come before base.md in the prompt
|
|
314
|
+
const identityIdx = config.systemPrompt.indexOf("Identity section.");
|
|
315
|
+
const baseIdx = config.systemPrompt.indexOf("Base instructions.");
|
|
316
|
+
expect(identityIdx).toBeGreaterThanOrEqual(0);
|
|
317
|
+
expect(baseIdx).toBeGreaterThan(identityIdx);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("includes memory.md in persistent memory section", async () => {
|
|
321
|
+
mockFs(
|
|
322
|
+
{ frontend: "terminal" },
|
|
323
|
+
{ "memory.md": "User prefers dark mode." },
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const { loadConfig } = await import("../util/config.js");
|
|
327
|
+
const config = loadConfig();
|
|
328
|
+
expect(config.systemPrompt).toContain("Persistent Memory");
|
|
329
|
+
expect(config.systemPrompt).toContain("User prefers dark mode.");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("includes workspace file listing when files exist", async () => {
|
|
333
|
+
mockFs(
|
|
334
|
+
{ frontend: "terminal" },
|
|
335
|
+
{},
|
|
336
|
+
[
|
|
337
|
+
{ name: "notes.txt", isDir: false, size: 512 },
|
|
338
|
+
{ name: "data.csv", isDir: false, size: 2048 },
|
|
339
|
+
],
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const { loadConfig } = await import("../util/config.js");
|
|
343
|
+
const config = loadConfig();
|
|
344
|
+
expect(config.systemPrompt).toContain("notes.txt");
|
|
345
|
+
expect(config.systemPrompt).toContain("512B");
|
|
346
|
+
expect(config.systemPrompt).toContain("data.csv");
|
|
347
|
+
expect(config.systemPrompt).toContain("2KB");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("skips hidden files and node_modules in workspace listing", async () => {
|
|
351
|
+
mockFs(
|
|
352
|
+
{ frontend: "terminal" },
|
|
353
|
+
{},
|
|
354
|
+
[
|
|
355
|
+
{ name: ".hidden", isDir: false, size: 100 },
|
|
356
|
+
{ name: "node_modules", isDir: true },
|
|
357
|
+
{ name: "talon.log", isDir: false, size: 500 },
|
|
358
|
+
{ name: "visible.txt", isDir: false, size: 200 },
|
|
359
|
+
],
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const { loadConfig } = await import("../util/config.js");
|
|
363
|
+
const config = loadConfig();
|
|
364
|
+
expect(config.systemPrompt).toContain("visible.txt");
|
|
365
|
+
expect(config.systemPrompt).not.toContain(".hidden");
|
|
366
|
+
expect(config.systemPrompt).not.toContain("node_modules");
|
|
367
|
+
expect(config.systemPrompt).not.toContain("talon.log");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("shows subdirectory summary when it has more than 8 files", async () => {
|
|
371
|
+
// Create a subdirectory with > 8 children
|
|
372
|
+
const manyChildren = [];
|
|
373
|
+
for (let i = 0; i < 10; i++) {
|
|
374
|
+
manyChildren.push({ name: `file${i}.txt`, size: 100 });
|
|
375
|
+
}
|
|
376
|
+
mockFs(
|
|
377
|
+
{ frontend: "terminal" },
|
|
378
|
+
{},
|
|
379
|
+
[
|
|
380
|
+
{ name: "bigdir", isDir: true, children: manyChildren },
|
|
381
|
+
],
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
const { loadConfig } = await import("../util/config.js");
|
|
385
|
+
const config = loadConfig();
|
|
386
|
+
expect(config.systemPrompt).toContain("bigdir/ (10 files)");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("lists subdirectory files when 8 or fewer", async () => {
|
|
390
|
+
mockFs(
|
|
391
|
+
{ frontend: "terminal" },
|
|
392
|
+
{},
|
|
393
|
+
[
|
|
394
|
+
{
|
|
395
|
+
name: "smalldir",
|
|
396
|
+
isDir: true,
|
|
397
|
+
children: [
|
|
398
|
+
{ name: "a.txt", size: 50 },
|
|
399
|
+
{ name: "b.txt", size: 75 },
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
const { loadConfig } = await import("../util/config.js");
|
|
406
|
+
const config = loadConfig();
|
|
407
|
+
expect(config.systemPrompt).toContain("smalldir/a.txt");
|
|
408
|
+
expect(config.systemPrompt).toContain("smalldir/b.txt");
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("getFrontends", () => {
|
|
413
|
+
it("returns array when frontend is a single string", async () => {
|
|
414
|
+
mockFs({ frontend: "terminal" });
|
|
415
|
+
|
|
416
|
+
const { loadConfig, getFrontends } = await import("../util/config.js");
|
|
417
|
+
const config = loadConfig();
|
|
418
|
+
const frontends = getFrontends(config);
|
|
419
|
+
expect(frontends).toEqual(["terminal"]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("returns array as-is when frontend is already an array", async () => {
|
|
423
|
+
mockFs({ frontend: ["telegram", "terminal"], botToken: "test-token" });
|
|
424
|
+
|
|
425
|
+
const { loadConfig, getFrontends } = await import("../util/config.js");
|
|
426
|
+
const config = loadConfig();
|
|
427
|
+
const frontends = getFrontends(config);
|
|
428
|
+
expect(frontends).toEqual(["telegram", "terminal"]);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("rebuildSystemPrompt", () => {
|
|
433
|
+
it("does nothing when pluginAdditions is empty", async () => {
|
|
434
|
+
mockFs({ frontend: "terminal" });
|
|
435
|
+
|
|
436
|
+
const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
|
|
437
|
+
const config = loadConfig();
|
|
438
|
+
const originalPrompt = config.systemPrompt;
|
|
439
|
+
rebuildSystemPrompt(config, []);
|
|
440
|
+
expect(config.systemPrompt).toBe(originalPrompt);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("appends plugin prompt additions to system prompt", async () => {
|
|
444
|
+
mockFs({ frontend: "terminal" });
|
|
445
|
+
|
|
446
|
+
const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
|
|
447
|
+
const config = loadConfig();
|
|
448
|
+
rebuildSystemPrompt(config, [
|
|
449
|
+
"## Plugin A\nPlugin A instructions.",
|
|
450
|
+
"## Plugin B\nPlugin B instructions.",
|
|
451
|
+
]);
|
|
452
|
+
expect(config.systemPrompt).toContain("Plugin A instructions.");
|
|
453
|
+
expect(config.systemPrompt).toContain("Plugin B instructions.");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("rebuilds prompt with correct frontend from array config", async () => {
|
|
457
|
+
mockFs(
|
|
458
|
+
{ frontend: ["terminal", "telegram"], botToken: "test-token" },
|
|
459
|
+
{ "terminal.md": "Terminal-specific prompt." },
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
|
|
463
|
+
const config = loadConfig();
|
|
464
|
+
rebuildSystemPrompt(config, ["## Test Plugin\nTest addition."]);
|
|
465
|
+
// Should use terminal (first in array) as the active frontend
|
|
466
|
+
expect(config.systemPrompt).toContain("Terminal-specific prompt.");
|
|
467
|
+
expect(config.systemPrompt).toContain("Test addition.");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("rebuilds prompt with single string frontend", async () => {
|
|
471
|
+
mockFs(
|
|
472
|
+
{ frontend: "terminal" },
|
|
473
|
+
{ "terminal.md": "Terminal mode active." },
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const { loadConfig, rebuildSystemPrompt } = await import("../util/config.js");
|
|
477
|
+
const config = loadConfig();
|
|
478
|
+
rebuildSystemPrompt(config, ["## My Plugin\nDo special things."]);
|
|
479
|
+
expect(config.systemPrompt).toContain("Terminal mode active.");
|
|
480
|
+
expect(config.systemPrompt).toContain("Do special things.");
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("zod validation boundaries", () => {
|
|
485
|
+
it("rejects concurrency above max 20", async () => {
|
|
486
|
+
mockFs({ frontend: "terminal", concurrency: 25 });
|
|
487
|
+
|
|
488
|
+
const { loadConfig } = await import("../util/config.js");
|
|
489
|
+
expect(() => loadConfig()).toThrow();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("rejects concurrency below min 1", async () => {
|
|
493
|
+
mockFs({ frontend: "terminal", concurrency: 0 });
|
|
494
|
+
|
|
495
|
+
const { loadConfig } = await import("../util/config.js");
|
|
496
|
+
expect(() => loadConfig()).toThrow();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("rejects maxMessageLength below min 100", async () => {
|
|
500
|
+
mockFs({ frontend: "terminal", maxMessageLength: 50 });
|
|
501
|
+
|
|
502
|
+
const { loadConfig } = await import("../util/config.js");
|
|
503
|
+
expect(() => loadConfig()).toThrow();
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("default model is exactly claude-sonnet-4-6", async () => {
|
|
507
|
+
mockFs({ frontend: "terminal" });
|
|
508
|
+
|
|
509
|
+
const { loadConfig } = await import("../util/config.js");
|
|
510
|
+
const config = loadConfig();
|
|
511
|
+
expect(config.model).toBe("claude-sonnet-4-6");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("default pulse is exactly true", async () => {
|
|
515
|
+
mockFs({ frontend: "terminal" });
|
|
516
|
+
|
|
517
|
+
const { loadConfig } = await import("../util/config.js");
|
|
518
|
+
const config = loadConfig();
|
|
519
|
+
expect(config.pulse).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("loadConfigFile edge cases", () => {
|
|
524
|
+
it("handles corrupt talon.json gracefully", async () => {
|
|
525
|
+
// Simulate a corrupt JSON by having readFileSync throw
|
|
526
|
+
vi.doMock("node:fs", () => ({
|
|
527
|
+
existsSync: vi.fn((path: string) => {
|
|
528
|
+
if (path.includes("config.json") || path.includes("talon.json")) return true;
|
|
529
|
+
if (path.endsWith(".talon") || path.endsWith("/data")) return true;
|
|
530
|
+
return false;
|
|
531
|
+
}),
|
|
532
|
+
readFileSync: vi.fn((path: string) => {
|
|
533
|
+
if (path.includes("config.json") || path.includes("talon.json")) throw new Error("corrupt file");
|
|
534
|
+
return "";
|
|
535
|
+
}),
|
|
536
|
+
mkdirSync: vi.fn(),
|
|
537
|
+
readdirSync: vi.fn(() => []),
|
|
538
|
+
statSync: vi.fn(() => ({ size: 0 })),
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
const { loadConfig } = await import("../util/config.js");
|
|
542
|
+
// With corrupt config, it falls back to empty config => default frontend=telegram => no botToken => throws
|
|
543
|
+
expect(() => loadConfig()).toThrow("botToken");
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|