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,302 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
|
|
4
|
+
// ── Module mocks ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
vi.mock("../util/log.js", () => ({
|
|
7
|
+
log: vi.fn(),
|
|
8
|
+
logError: vi.fn(),
|
|
9
|
+
logWarn: vi.fn(),
|
|
10
|
+
logDebug: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("write-file-atomic", () => ({
|
|
14
|
+
default: { sync: vi.fn() },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
existsSync: vi.fn(() => true),
|
|
19
|
+
readFileSync: vi.fn(() => "{}"),
|
|
20
|
+
writeFileSync: vi.fn(),
|
|
21
|
+
mkdirSync: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("../storage/history.js", () => ({
|
|
25
|
+
getRecentFormatted: vi.fn(() => ""),
|
|
26
|
+
searchHistory: vi.fn(() => ""),
|
|
27
|
+
getMessagesByUser: vi.fn(() => ""),
|
|
28
|
+
getKnownUsers: vi.fn(() => ""),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("../storage/media-index.js", () => ({
|
|
32
|
+
formatMediaIndex: vi.fn(() => ""),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("../storage/cron-store.js", () => ({
|
|
36
|
+
addCronJob: vi.fn(),
|
|
37
|
+
getCronJob: vi.fn(),
|
|
38
|
+
getCronJobsForChat: vi.fn(() => []),
|
|
39
|
+
updateCronJob: vi.fn(),
|
|
40
|
+
deleteCronJob: vi.fn(),
|
|
41
|
+
validateCronExpression: vi.fn(() => ({ valid: true })),
|
|
42
|
+
generateCronId: vi.fn(() => "test-id"),
|
|
43
|
+
loadCronJobs: vi.fn(),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const { classify, TalonError } = await import("../core/errors.js");
|
|
49
|
+
const { validateCronExpression } = await import("../storage/cron-store.js");
|
|
50
|
+
const { handleSharedAction } = await import("../core/gateway-actions.js");
|
|
51
|
+
const { resolveModelName } = await import("../storage/chat-settings.js");
|
|
52
|
+
const { Cron } = await import("croner");
|
|
53
|
+
|
|
54
|
+
// ── Fuzz tests ───────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("fuzz: classify()", () => {
|
|
57
|
+
it("never throws on random string inputs", () => {
|
|
58
|
+
fc.assert(
|
|
59
|
+
fc.property(fc.string(), (input) => {
|
|
60
|
+
const result = classify(input);
|
|
61
|
+
expect(result).toBeInstanceOf(TalonError);
|
|
62
|
+
expect(typeof result.reason).toBe("string");
|
|
63
|
+
expect(typeof result.retryable).toBe("boolean");
|
|
64
|
+
expect(typeof result.message).toBe("string");
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("never throws on random Error inputs", () => {
|
|
70
|
+
fc.assert(
|
|
71
|
+
fc.property(fc.string(), (msg) => {
|
|
72
|
+
const result = classify(new Error(msg));
|
|
73
|
+
expect(result).toBeInstanceOf(TalonError);
|
|
74
|
+
expect(result.reason).toBeTruthy();
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("never throws on random non-string/non-Error inputs", () => {
|
|
80
|
+
fc.assert(
|
|
81
|
+
fc.property(
|
|
82
|
+
fc.oneof(
|
|
83
|
+
fc.integer(),
|
|
84
|
+
fc.boolean(),
|
|
85
|
+
fc.constant(null),
|
|
86
|
+
fc.constant(undefined),
|
|
87
|
+
fc.double(),
|
|
88
|
+
fc.object(),
|
|
89
|
+
),
|
|
90
|
+
(input) => {
|
|
91
|
+
const result = classify(input);
|
|
92
|
+
expect(result).toBeInstanceOf(TalonError);
|
|
93
|
+
expect(result.reason).toBeTruthy();
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("always returns a valid ErrorReason", () => {
|
|
100
|
+
const validReasons = new Set([
|
|
101
|
+
"rate_limit",
|
|
102
|
+
"overloaded",
|
|
103
|
+
"network",
|
|
104
|
+
"auth",
|
|
105
|
+
"context_length",
|
|
106
|
+
"session_expired",
|
|
107
|
+
"bad_request",
|
|
108
|
+
"forbidden",
|
|
109
|
+
"telegram_api",
|
|
110
|
+
"unknown",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
fc.assert(
|
|
114
|
+
fc.property(fc.string(), (input) => {
|
|
115
|
+
const result = classify(input);
|
|
116
|
+
expect(validReasons.has(result.reason)).toBe(true);
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("retryAfterMs is never negative when present", () => {
|
|
122
|
+
fc.assert(
|
|
123
|
+
fc.property(fc.string(), (input) => {
|
|
124
|
+
const result = classify(input);
|
|
125
|
+
if (result.retryAfterMs !== undefined) {
|
|
126
|
+
expect(result.retryAfterMs).toBeGreaterThanOrEqual(0);
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Real cron validator (the import is mocked, so we use Cron directly)
|
|
134
|
+
function realValidateCron(expr: string, timezone?: string): { valid: boolean; error?: string } {
|
|
135
|
+
try {
|
|
136
|
+
const cron = new Cron(expr, { timezone: timezone ?? undefined });
|
|
137
|
+
cron.nextRun();
|
|
138
|
+
return { valid: true };
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { valid: false, error: err instanceof Error ? err.message : String(err) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("fuzz: validateCronExpression()", () => {
|
|
145
|
+
it("never throws on random strings, always returns { valid: boolean }", () => {
|
|
146
|
+
fc.assert(
|
|
147
|
+
fc.property(fc.string(), (input) => {
|
|
148
|
+
const result = realValidateCron(input);
|
|
149
|
+
expect(typeof result.valid).toBe("boolean");
|
|
150
|
+
if (!result.valid) {
|
|
151
|
+
expect(typeof result.error).toBe("string");
|
|
152
|
+
}
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("never throws on random strings with random timezone strings", () => {
|
|
158
|
+
fc.assert(
|
|
159
|
+
fc.property(fc.string(), fc.string(), (expr, tz) => {
|
|
160
|
+
const result = realValidateCron(expr, tz);
|
|
161
|
+
expect(typeof result.valid).toBe("boolean");
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("fuzz: handleSharedAction() — unknown actions", () => {
|
|
168
|
+
it("returns null for random action names (never crashes)", () => {
|
|
169
|
+
fc.assert(
|
|
170
|
+
fc.asyncProperty(fc.string(), async (actionName) => {
|
|
171
|
+
// Skip known action names to test only the default/unknown path
|
|
172
|
+
const knownActions = new Set([
|
|
173
|
+
"read_history", "search_history", "get_user_messages",
|
|
174
|
+
"list_known_users", "list_media", "web_search", "fetch_url",
|
|
175
|
+
"create_cron_job", "list_cron_jobs", "edit_cron_job", "delete_cron_job",
|
|
176
|
+
]);
|
|
177
|
+
if (knownActions.has(actionName)) return;
|
|
178
|
+
|
|
179
|
+
const result = await handleSharedAction({ action: actionName }, 123);
|
|
180
|
+
expect(result).toBeNull();
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("never crashes with random body objects", () => {
|
|
186
|
+
fc.assert(
|
|
187
|
+
fc.asyncProperty(
|
|
188
|
+
fc.record({
|
|
189
|
+
action: fc.string(),
|
|
190
|
+
extra: fc.string(),
|
|
191
|
+
num: fc.integer(),
|
|
192
|
+
}),
|
|
193
|
+
async (body) => {
|
|
194
|
+
const knownActions = new Set([
|
|
195
|
+
"read_history", "search_history", "get_user_messages",
|
|
196
|
+
"list_known_users", "list_media", "web_search", "fetch_url",
|
|
197
|
+
"create_cron_job", "list_cron_jobs", "edit_cron_job", "delete_cron_job",
|
|
198
|
+
]);
|
|
199
|
+
if (knownActions.has(body.action)) return;
|
|
200
|
+
|
|
201
|
+
const result = await handleSharedAction(body, 123);
|
|
202
|
+
expect(result).toBeNull();
|
|
203
|
+
},
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("fuzz: fetch_url URL validation", () => {
|
|
210
|
+
let originalFetch: typeof globalThis.fetch;
|
|
211
|
+
|
|
212
|
+
it("random strings as URLs return ok:false or null, never throw", () => {
|
|
213
|
+
originalFetch = globalThis.fetch;
|
|
214
|
+
// Mock fetch to prevent real network calls
|
|
215
|
+
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("mocked")));
|
|
216
|
+
|
|
217
|
+
fc.assert(
|
|
218
|
+
fc.asyncProperty(fc.string(), async (url) => {
|
|
219
|
+
const result = await handleSharedAction({ action: "fetch_url", url }, 123);
|
|
220
|
+
// Should either return an error result or handle gracefully
|
|
221
|
+
expect(result).not.toBeUndefined();
|
|
222
|
+
if (result !== null) {
|
|
223
|
+
expect(typeof result.ok).toBe("boolean");
|
|
224
|
+
if (!result.ok) {
|
|
225
|
+
expect(typeof result.error).toBe("string");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
globalThis.fetch = originalFetch;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("rejects all non-http/https protocols", () => {
|
|
235
|
+
fc.assert(
|
|
236
|
+
fc.asyncProperty(
|
|
237
|
+
fc.constantFrom("ftp", "file", "javascript", "data", "ws", "wss", "ssh"),
|
|
238
|
+
fc.webUrl(),
|
|
239
|
+
async (protocol, path) => {
|
|
240
|
+
const url = `${protocol}://${path.replace(/^https?:\/\//, "")}`;
|
|
241
|
+
const result = await handleSharedAction({ action: "fetch_url", url }, 123);
|
|
242
|
+
if (result !== null) {
|
|
243
|
+
// Should not be ok for non-http protocols
|
|
244
|
+
// (some invalid URLs will get "Invalid URL" error at parse time)
|
|
245
|
+
expect(result.ok).toBe(false);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("fuzz: resolveModelName()", () => {
|
|
254
|
+
it("never throws on random strings, always returns a string", () => {
|
|
255
|
+
fc.assert(
|
|
256
|
+
fc.property(fc.string(), (input) => {
|
|
257
|
+
const result = resolveModelName(input);
|
|
258
|
+
expect(typeof result).toBe("string");
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("always returns trimmed output", () => {
|
|
264
|
+
fc.assert(
|
|
265
|
+
fc.property(fc.string(), (input) => {
|
|
266
|
+
const result = resolveModelName(input);
|
|
267
|
+
expect(result).toBe(result.trim());
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("known aliases always resolve to claude model names", () => {
|
|
273
|
+
const aliases = ["sonnet", "opus", "haiku", "sonnet-4.6", "opus-4.6", "haiku-4.5", "sonnet-4-6", "opus-4-6", "haiku-4-5"];
|
|
274
|
+
fc.assert(
|
|
275
|
+
fc.property(
|
|
276
|
+
fc.constantFrom(...aliases),
|
|
277
|
+
fc.constantFrom("", " ", " "),
|
|
278
|
+
(alias, padding) => {
|
|
279
|
+
const result = resolveModelName(padding + alias + padding);
|
|
280
|
+
expect(result).toMatch(/^claude-/);
|
|
281
|
+
},
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("unknown strings pass through trimmed", () => {
|
|
287
|
+
fc.assert(
|
|
288
|
+
fc.property(fc.string(), (input) => {
|
|
289
|
+
const trimmed = input.trim().toLowerCase();
|
|
290
|
+
const knownAliases = [
|
|
291
|
+
"sonnet", "opus", "haiku",
|
|
292
|
+
"sonnet-4.6", "opus-4.6", "haiku-4.5",
|
|
293
|
+
"sonnet-4-6", "opus-4-6", "haiku-4-5",
|
|
294
|
+
];
|
|
295
|
+
if (knownAliases.includes(trimmed)) return;
|
|
296
|
+
|
|
297
|
+
const result = resolveModelName(input);
|
|
298
|
+
expect(result).toBe(input.trim());
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|