talon-agent 1.0.0 → 1.2.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/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- package/src/util/workspace.ts +51 -10
|
@@ -23,12 +23,8 @@ vi.mock("write-file-atomic", () => ({
|
|
|
23
23
|
default: { sync: (...args: unknown[]) => writeFileSyncMock(...args) },
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
|
-
const {
|
|
27
|
-
|
|
28
|
-
flushHistory,
|
|
29
|
-
pushMessage,
|
|
30
|
-
getRecentHistory,
|
|
31
|
-
} = await import("../storage/history.js");
|
|
26
|
+
const { loadHistory, flushHistory, pushMessage, getRecentHistory } =
|
|
27
|
+
await import("../storage/history.js");
|
|
32
28
|
|
|
33
29
|
describe("history persistence", () => {
|
|
34
30
|
beforeEach(() => {
|
|
@@ -37,10 +33,31 @@ describe("history persistence", () => {
|
|
|
37
33
|
|
|
38
34
|
describe("loadHistory", () => {
|
|
39
35
|
it("loads history from a JSON file when it exists", () => {
|
|
40
|
-
const data: Record<
|
|
36
|
+
const data: Record<
|
|
37
|
+
string,
|
|
38
|
+
Array<{
|
|
39
|
+
msgId: number;
|
|
40
|
+
senderId: number;
|
|
41
|
+
senderName: string;
|
|
42
|
+
text: string;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
}>
|
|
45
|
+
> = {
|
|
41
46
|
"chat-1": [
|
|
42
|
-
{
|
|
43
|
-
|
|
47
|
+
{
|
|
48
|
+
msgId: 1,
|
|
49
|
+
senderId: 100,
|
|
50
|
+
senderName: "Alice",
|
|
51
|
+
text: "hello",
|
|
52
|
+
timestamp: 1000,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
msgId: 2,
|
|
56
|
+
senderId: 200,
|
|
57
|
+
senderName: "Bob",
|
|
58
|
+
text: "hi",
|
|
59
|
+
timestamp: 2000,
|
|
60
|
+
},
|
|
44
61
|
],
|
|
45
62
|
};
|
|
46
63
|
existsSyncMock.mockReturnValue(true);
|
|
@@ -89,9 +106,15 @@ describe("history persistence", () => {
|
|
|
89
106
|
|
|
90
107
|
it("loads multiple chats", () => {
|
|
91
108
|
const data = {
|
|
92
|
-
"chat-a": [
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
"chat-a": [
|
|
110
|
+
{ msgId: 1, senderId: 1, senderName: "A", text: "a", timestamp: 1 },
|
|
111
|
+
],
|
|
112
|
+
"chat-b": [
|
|
113
|
+
{ msgId: 2, senderId: 2, senderName: "B", text: "b", timestamp: 2 },
|
|
114
|
+
],
|
|
115
|
+
"chat-c": [
|
|
116
|
+
{ msgId: 3, senderId: 3, senderName: "C", text: "c", timestamp: 3 },
|
|
117
|
+
],
|
|
95
118
|
};
|
|
96
119
|
existsSyncMock.mockReturnValue(true);
|
|
97
120
|
readFileSyncMock.mockReturnValue(JSON.stringify(data));
|
|
@@ -122,7 +145,8 @@ describe("history persistence", () => {
|
|
|
122
145
|
|
|
123
146
|
expect(writeFileSyncMock).toHaveBeenCalled();
|
|
124
147
|
// Last write call is the actual data (earlier calls may be .bak backups)
|
|
125
|
-
const lastCall =
|
|
148
|
+
const lastCall =
|
|
149
|
+
writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1];
|
|
126
150
|
const writtenData = lastCall[1] as string;
|
|
127
151
|
const parsed = JSON.parse(writtenData.trim());
|
|
128
152
|
expect(parsed[id]).toBeDefined();
|
|
@@ -144,13 +168,12 @@ describe("history persistence", () => {
|
|
|
144
168
|
|
|
145
169
|
flushHistory();
|
|
146
170
|
|
|
147
|
-
expect(mkdirSyncMock).toHaveBeenCalledWith(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
);
|
|
171
|
+
expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), {
|
|
172
|
+
recursive: true,
|
|
173
|
+
});
|
|
151
174
|
});
|
|
152
175
|
|
|
153
|
-
it("handles write errors gracefully", () => {
|
|
176
|
+
it("handles write errors gracefully (line 96 TRUE branch: Error thrown on data write)", () => {
|
|
154
177
|
const id = `flush-err-${Date.now()}`;
|
|
155
178
|
pushMessage(id, {
|
|
156
179
|
msgId: 1,
|
|
@@ -160,7 +183,8 @@ describe("history persistence", () => {
|
|
|
160
183
|
timestamp: Date.now(),
|
|
161
184
|
});
|
|
162
185
|
|
|
163
|
-
|
|
186
|
+
// existsSync=false skips the .bak write so the Error is thrown on the actual data write
|
|
187
|
+
existsSyncMock.mockReturnValue(false);
|
|
164
188
|
writeFileSyncMock.mockImplementationOnce(() => {
|
|
165
189
|
throw new Error("disk full");
|
|
166
190
|
});
|
|
@@ -170,3 +194,34 @@ describe("history persistence", () => {
|
|
|
170
194
|
});
|
|
171
195
|
});
|
|
172
196
|
});
|
|
197
|
+
|
|
198
|
+
describe("history — non-Error throw coverage", () => {
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
vi.clearAllMocks();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("saveHistory covers String(err) when writeFileAtomic throws a non-Error", async () => {
|
|
204
|
+
const { logError } = await import("../util/log.js");
|
|
205
|
+
vi.mocked(logError).mockClear();
|
|
206
|
+
|
|
207
|
+
const id = `flush-non-error-${Date.now()}`;
|
|
208
|
+
pushMessage(id, {
|
|
209
|
+
msgId: 1,
|
|
210
|
+
senderId: 1,
|
|
211
|
+
senderName: "TestUser",
|
|
212
|
+
text: "test non-error throw",
|
|
213
|
+
timestamp: Date.now(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
existsSyncMock.mockReturnValue(false); // no backup attempt
|
|
217
|
+
// Throw a plain string (non-Error) to cover `err instanceof Error ? ... : err`
|
|
218
|
+
writeFileSyncMock.mockImplementation(() => {
|
|
219
|
+
throw "disk quota string";
|
|
220
|
+
}); // eslint-disable-line @typescript-eslint/no-throw-literal
|
|
221
|
+
|
|
222
|
+
expect(() => flushHistory()).not.toThrow();
|
|
223
|
+
expect(vi.mocked(logError)).toHaveBeenCalled();
|
|
224
|
+
|
|
225
|
+
writeFileSyncMock.mockReset();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -64,15 +64,18 @@ describe("history", () => {
|
|
|
64
64
|
|
|
65
65
|
it("preserves all optional fields on the message", () => {
|
|
66
66
|
const id = chatId();
|
|
67
|
-
pushMessage(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
pushMessage(
|
|
68
|
+
id,
|
|
69
|
+
makeMsg({
|
|
70
|
+
msgId: 1,
|
|
71
|
+
senderName: "Alice",
|
|
72
|
+
text: "photo msg",
|
|
73
|
+
replyToMsgId: 99,
|
|
74
|
+
mediaType: "photo",
|
|
75
|
+
stickerFileId: "stk123",
|
|
76
|
+
filePath: "/tmp/photo.jpg",
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
76
79
|
const history = getRecentHistory(id);
|
|
77
80
|
expect(history[0].replyToMsgId).toBe(99);
|
|
78
81
|
expect(history[0].mediaType).toBe("photo");
|
|
@@ -119,7 +122,9 @@ describe("history", () => {
|
|
|
119
122
|
|
|
120
123
|
it("is a no-op for nonexistent chat", () => {
|
|
121
124
|
// Should not throw
|
|
122
|
-
expect(() =>
|
|
125
|
+
expect(() =>
|
|
126
|
+
setMessageFilePath("no-such-chat", 1, "/tmp/x.jpg"),
|
|
127
|
+
).not.toThrow();
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
it("is a no-op for nonexistent message", () => {
|
|
@@ -218,7 +223,10 @@ describe("history", () => {
|
|
|
218
223
|
it("respects the limit parameter", () => {
|
|
219
224
|
const id = chatId();
|
|
220
225
|
for (let i = 0; i < 10; i++) {
|
|
221
|
-
pushMessage(
|
|
226
|
+
pushMessage(
|
|
227
|
+
id,
|
|
228
|
+
makeMsg({ msgId: i, senderName: "Alice", text: `msg ${i}` }),
|
|
229
|
+
);
|
|
222
230
|
}
|
|
223
231
|
const result = getMessagesByUser(id, "Alice", 3);
|
|
224
232
|
const lines = result.split("\n");
|
|
@@ -306,66 +314,84 @@ describe("history", () => {
|
|
|
306
314
|
|
|
307
315
|
it("shows time ago for users seen minutes ago", () => {
|
|
308
316
|
const id = chatId();
|
|
309
|
-
pushMessage(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
317
|
+
pushMessage(
|
|
318
|
+
id,
|
|
319
|
+
makeMsg({
|
|
320
|
+
msgId: 1,
|
|
321
|
+
senderId: 100,
|
|
322
|
+
senderName: "Alice",
|
|
323
|
+
timestamp: Date.now() - 5 * 60_000, // 5 minutes ago
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
315
326
|
const result = getKnownUsers(id);
|
|
316
327
|
expect(result).toContain("5m ago");
|
|
317
328
|
});
|
|
318
329
|
|
|
319
330
|
it("shows time ago for users seen hours ago", () => {
|
|
320
331
|
const id = chatId();
|
|
321
|
-
pushMessage(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
332
|
+
pushMessage(
|
|
333
|
+
id,
|
|
334
|
+
makeMsg({
|
|
335
|
+
msgId: 1,
|
|
336
|
+
senderId: 100,
|
|
337
|
+
senderName: "Alice",
|
|
338
|
+
timestamp: Date.now() - 3 * 3_600_000, // 3 hours ago
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
327
341
|
const result = getKnownUsers(id);
|
|
328
342
|
expect(result).toContain("3h ago");
|
|
329
343
|
});
|
|
330
344
|
|
|
331
345
|
it("shows time ago for users seen days ago", () => {
|
|
332
346
|
const id = chatId();
|
|
333
|
-
pushMessage(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
347
|
+
pushMessage(
|
|
348
|
+
id,
|
|
349
|
+
makeMsg({
|
|
350
|
+
msgId: 1,
|
|
351
|
+
senderId: 100,
|
|
352
|
+
senderName: "Alice",
|
|
353
|
+
timestamp: Date.now() - 2 * 86_400_000, // 2 days ago
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
339
356
|
const result = getKnownUsers(id);
|
|
340
357
|
expect(result).toContain("2d ago");
|
|
341
358
|
});
|
|
342
359
|
|
|
343
360
|
it("shows 'just now' for users seen less than a minute ago", () => {
|
|
344
361
|
const id = chatId();
|
|
345
|
-
pushMessage(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
362
|
+
pushMessage(
|
|
363
|
+
id,
|
|
364
|
+
makeMsg({
|
|
365
|
+
msgId: 1,
|
|
366
|
+
senderId: 100,
|
|
367
|
+
senderName: "Alice",
|
|
368
|
+
timestamp: Date.now() - 10_000, // 10 seconds ago
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
351
371
|
const result = getKnownUsers(id);
|
|
352
372
|
expect(result).toContain("just now");
|
|
353
373
|
});
|
|
354
374
|
|
|
355
375
|
it("sorts users by last seen (most recent first)", () => {
|
|
356
376
|
const id = chatId();
|
|
357
|
-
pushMessage(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
377
|
+
pushMessage(
|
|
378
|
+
id,
|
|
379
|
+
makeMsg({
|
|
380
|
+
msgId: 1,
|
|
381
|
+
senderId: 100,
|
|
382
|
+
senderName: "OldUser",
|
|
383
|
+
timestamp: Date.now() - 86_400_000,
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
pushMessage(
|
|
387
|
+
id,
|
|
388
|
+
makeMsg({
|
|
389
|
+
msgId: 2,
|
|
390
|
+
senderId: 200,
|
|
391
|
+
senderName: "NewUser",
|
|
392
|
+
timestamp: Date.now() - 60_000,
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
369
395
|
const result = getKnownUsers(id);
|
|
370
396
|
const newIdx = result.indexOf("NewUser");
|
|
371
397
|
const oldIdx = result.indexOf("OldUser");
|
|
@@ -374,18 +400,24 @@ describe("history", () => {
|
|
|
374
400
|
|
|
375
401
|
it("updates user name to the latest seen name", () => {
|
|
376
402
|
const id = chatId();
|
|
377
|
-
pushMessage(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
403
|
+
pushMessage(
|
|
404
|
+
id,
|
|
405
|
+
makeMsg({
|
|
406
|
+
msgId: 1,
|
|
407
|
+
senderId: 100,
|
|
408
|
+
senderName: "OldName",
|
|
409
|
+
timestamp: Date.now() - 60_000,
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
pushMessage(
|
|
413
|
+
id,
|
|
414
|
+
makeMsg({
|
|
415
|
+
msgId: 2,
|
|
416
|
+
senderId: 100,
|
|
417
|
+
senderName: "NewName",
|
|
418
|
+
timestamp: Date.now(),
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
389
421
|
const result = getKnownUsers(id);
|
|
390
422
|
expect(result).toContain("NewName");
|
|
391
423
|
expect(result).not.toContain("OldName");
|
|
@@ -399,18 +431,12 @@ describe("history", () => {
|
|
|
399
431
|
id,
|
|
400
432
|
makeMsg({ msgId: 1, senderId: 100, senderName: "Alice" }),
|
|
401
433
|
);
|
|
402
|
-
pushMessage(
|
|
403
|
-
id,
|
|
404
|
-
makeMsg({ msgId: 2, senderId: 200, senderName: "Bob" }),
|
|
405
|
-
);
|
|
434
|
+
pushMessage(id, makeMsg({ msgId: 2, senderId: 200, senderName: "Bob" }));
|
|
406
435
|
pushMessage(
|
|
407
436
|
id,
|
|
408
437
|
makeMsg({ msgId: 3, senderId: 100, senderName: "Alice" }),
|
|
409
438
|
);
|
|
410
|
-
pushMessage(
|
|
411
|
-
id,
|
|
412
|
-
makeMsg({ msgId: 4, senderId: 200, senderName: "Bob" }),
|
|
413
|
-
);
|
|
439
|
+
pushMessage(id, makeMsg({ msgId: 4, senderId: 200, senderName: "Bob" }));
|
|
414
440
|
|
|
415
441
|
const result = getRecentBySenderId(id, 100);
|
|
416
442
|
expect(result).toHaveLength(2);
|
|
@@ -556,8 +582,14 @@ describe("history", () => {
|
|
|
556
582
|
|
|
557
583
|
it("formats multiple messages joined by newlines", () => {
|
|
558
584
|
const id = chatId();
|
|
559
|
-
pushMessage(
|
|
560
|
-
|
|
585
|
+
pushMessage(
|
|
586
|
+
id,
|
|
587
|
+
makeMsg({ msgId: 1, text: "first", timestamp: 1000000000000 }),
|
|
588
|
+
);
|
|
589
|
+
pushMessage(
|
|
590
|
+
id,
|
|
591
|
+
makeMsg({ msgId: 2, text: "second", timestamp: 1000000060000 }),
|
|
592
|
+
);
|
|
561
593
|
const result = getRecentFormatted(id, 5);
|
|
562
594
|
const lines = result.split("\n");
|
|
563
595
|
expect(lines).toHaveLength(2);
|
|
@@ -567,9 +599,17 @@ describe("history", () => {
|
|
|
567
599
|
|
|
568
600
|
it("includes all media type variants in tags", () => {
|
|
569
601
|
const id = chatId();
|
|
570
|
-
const types: Array<HistoryMessage["mediaType"]> = [
|
|
602
|
+
const types: Array<HistoryMessage["mediaType"]> = [
|
|
603
|
+
"document",
|
|
604
|
+
"voice",
|
|
605
|
+
"video",
|
|
606
|
+
"animation",
|
|
607
|
+
];
|
|
571
608
|
types.forEach((type, i) => {
|
|
572
|
-
pushMessage(
|
|
609
|
+
pushMessage(
|
|
610
|
+
id,
|
|
611
|
+
makeMsg({ msgId: i + 1, text: `media ${type}`, mediaType: type }),
|
|
612
|
+
);
|
|
573
613
|
});
|
|
574
614
|
const result = getRecentFormatted(id, 10);
|
|
575
615
|
expect(result).toContain("[document]");
|
|
@@ -612,14 +652,8 @@ describe("history", () => {
|
|
|
612
652
|
const id = chatId();
|
|
613
653
|
const ts1 = Date.now() - 10000;
|
|
614
654
|
const ts2 = Date.now() - 5000;
|
|
615
|
-
pushMessage(
|
|
616
|
-
|
|
617
|
-
makeMsg({ msgId: 1, senderId: 100, timestamp: ts1 }),
|
|
618
|
-
);
|
|
619
|
-
pushMessage(
|
|
620
|
-
id,
|
|
621
|
-
makeMsg({ msgId: 2, senderId: 200, timestamp: ts2 }),
|
|
622
|
-
);
|
|
655
|
+
pushMessage(id, makeMsg({ msgId: 1, senderId: 100, timestamp: ts1 }));
|
|
656
|
+
pushMessage(id, makeMsg({ msgId: 2, senderId: 200, timestamp: ts2 }));
|
|
623
657
|
|
|
624
658
|
const stats = getHistoryStats(id);
|
|
625
659
|
expect(stats.totalMessages).toBe(2);
|
|
@@ -8,7 +8,9 @@ import { initDispatcher, execute } from "../core/dispatcher.js";
|
|
|
8
8
|
import type { QueryBackend, ContextManager } from "../core/types.js";
|
|
9
9
|
import { TalonError } from "../core/errors.js";
|
|
10
10
|
|
|
11
|
-
function setup(
|
|
11
|
+
function setup(
|
|
12
|
+
overrides: { queryResult?: Record<string, unknown>; queryError?: Error } = {},
|
|
13
|
+
) {
|
|
12
14
|
const acquired: number[] = [];
|
|
13
15
|
const released: number[] = [];
|
|
14
16
|
const typingCalls: number[] = [];
|
|
@@ -38,16 +40,28 @@ function setup(overrides: { queryResult?: Record<string, unknown>; queryError?:
|
|
|
38
40
|
initDispatcher({
|
|
39
41
|
backend,
|
|
40
42
|
context,
|
|
41
|
-
sendTyping: vi.fn(async (id: number) => {
|
|
42
|
-
|
|
43
|
+
sendTyping: vi.fn(async (id: number) => {
|
|
44
|
+
typingCalls.push(id);
|
|
45
|
+
}),
|
|
46
|
+
onActivity: vi.fn(() => {
|
|
47
|
+
activityCount++;
|
|
48
|
+
}),
|
|
43
49
|
});
|
|
44
50
|
|
|
45
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
backend,
|
|
53
|
+
context,
|
|
54
|
+
acquired,
|
|
55
|
+
released,
|
|
56
|
+
typingCalls,
|
|
57
|
+
getActivityCount: () => activityCount,
|
|
58
|
+
};
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
describe("integration: dispatcher lifecycle", () => {
|
|
49
62
|
it("full happy path: acquire → type → query → activity → release", async () => {
|
|
50
|
-
const { backend, acquired, released, typingCalls, getActivityCount } =
|
|
63
|
+
const { backend, acquired, released, typingCalls, getActivityCount } =
|
|
64
|
+
setup();
|
|
51
65
|
|
|
52
66
|
const result = await execute({
|
|
53
67
|
chatId: "123",
|
|
@@ -136,7 +150,14 @@ describe("integration: dispatcher lifecycle", () => {
|
|
|
136
150
|
order.push(`start:${params.chatId}`);
|
|
137
151
|
await new Promise((r) => setTimeout(r, 20));
|
|
138
152
|
order.push(`end:${params.chatId}`);
|
|
139
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
text: "",
|
|
155
|
+
durationMs: 20,
|
|
156
|
+
inputTokens: 0,
|
|
157
|
+
outputTokens: 0,
|
|
158
|
+
cacheRead: 0,
|
|
159
|
+
cacheWrite: 0,
|
|
160
|
+
};
|
|
140
161
|
}),
|
|
141
162
|
};
|
|
142
163
|
|
|
@@ -153,8 +174,22 @@ describe("integration: dispatcher lifecycle", () => {
|
|
|
153
174
|
|
|
154
175
|
// Fire two queries simultaneously
|
|
155
176
|
await Promise.all([
|
|
156
|
-
execute({
|
|
157
|
-
|
|
177
|
+
execute({
|
|
178
|
+
chatId: "A",
|
|
179
|
+
numericChatId: 1,
|
|
180
|
+
prompt: "a",
|
|
181
|
+
senderName: "U",
|
|
182
|
+
isGroup: false,
|
|
183
|
+
source: "message",
|
|
184
|
+
}),
|
|
185
|
+
execute({
|
|
186
|
+
chatId: "B",
|
|
187
|
+
numericChatId: 2,
|
|
188
|
+
prompt: "b",
|
|
189
|
+
senderName: "U",
|
|
190
|
+
isGroup: false,
|
|
191
|
+
source: "message",
|
|
192
|
+
}),
|
|
158
193
|
]);
|
|
159
194
|
|
|
160
195
|
// True concurrency — both start before either ends
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for log.ts module-level initialization code.
|
|
3
|
+
* Uses vi.resetModules() + vi.doMock() to control the file system state
|
|
4
|
+
* during module load so we can cover the initialization branches.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
describe("log.ts — module-level initialization branches", () => {
|
|
9
|
+
const mockMkdirSync = vi.fn();
|
|
10
|
+
const mockRenameSync = vi.fn();
|
|
11
|
+
const mockUnlinkSync = vi.fn();
|
|
12
|
+
const mockLogFn = vi.fn();
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
mockMkdirSync.mockClear();
|
|
17
|
+
mockRenameSync.mockClear();
|
|
18
|
+
mockUnlinkSync.mockClear();
|
|
19
|
+
mockLogFn.mockClear();
|
|
20
|
+
delete process.env.TALON_QUIET;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
delete process.env.TALON_QUIET;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates .talon dir when it does not exist (line 39 TRUE branch)", async () => {
|
|
28
|
+
vi.doMock("../util/paths.js", () => ({
|
|
29
|
+
dirs: { root: "/fake/.talon" },
|
|
30
|
+
files: {
|
|
31
|
+
log: "/fake/.talon/talon.log",
|
|
32
|
+
config: "/fake/.talon/config.json",
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
vi.doMock("node:fs", () => ({
|
|
36
|
+
existsSync: vi.fn(() => false), // root dir doesn't exist
|
|
37
|
+
mkdirSync: mockMkdirSync,
|
|
38
|
+
statSync: vi.fn(() => ({ size: 0 })),
|
|
39
|
+
renameSync: mockRenameSync,
|
|
40
|
+
unlinkSync: mockUnlinkSync,
|
|
41
|
+
readFileSync: vi.fn(() => "{}"),
|
|
42
|
+
}));
|
|
43
|
+
vi.doMock("pino", () => ({
|
|
44
|
+
default: () => ({
|
|
45
|
+
info: mockLogFn,
|
|
46
|
+
error: mockLogFn,
|
|
47
|
+
warn: mockLogFn,
|
|
48
|
+
debug: mockLogFn,
|
|
49
|
+
}),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
await import("../util/log.js");
|
|
53
|
+
|
|
54
|
+
// mkdirSync should have been called to create the missing root dir
|
|
55
|
+
expect(mockMkdirSync).toHaveBeenCalledWith("/fake/.talon", {
|
|
56
|
+
recursive: true,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rotates log file when it exceeds 10MB (line 46 TRUE branch)", async () => {
|
|
61
|
+
vi.doMock("../util/paths.js", () => ({
|
|
62
|
+
dirs: { root: "/fake/.talon" },
|
|
63
|
+
files: {
|
|
64
|
+
log: "/fake/.talon/talon.log",
|
|
65
|
+
config: "/fake/.talon/config.json",
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
vi.doMock("node:fs", () => ({
|
|
69
|
+
existsSync: vi.fn(() => true), // both root dir and log file exist
|
|
70
|
+
mkdirSync: mockMkdirSync,
|
|
71
|
+
statSync: vi.fn(() => ({ size: 11 * 1024 * 1024 })), // > 10MB → triggers rotation
|
|
72
|
+
renameSync: mockRenameSync,
|
|
73
|
+
unlinkSync: mockUnlinkSync,
|
|
74
|
+
readFileSync: vi.fn(() => "{}"),
|
|
75
|
+
}));
|
|
76
|
+
vi.doMock("pino", () => ({
|
|
77
|
+
default: () => ({
|
|
78
|
+
info: mockLogFn,
|
|
79
|
+
error: mockLogFn,
|
|
80
|
+
warn: mockLogFn,
|
|
81
|
+
debug: mockLogFn,
|
|
82
|
+
}),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
await import("../util/log.js");
|
|
86
|
+
|
|
87
|
+
// renameSync should have been called to rotate the oversized log file
|
|
88
|
+
expect(mockRenameSync).toHaveBeenCalledWith(
|
|
89
|
+
"/fake/.talon/talon.log",
|
|
90
|
+
"/fake/.talon/talon.log.old",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips config read when TALON_QUIET=1 (line 59 FALSE branch: if(!quiet) not entered)", async () => {
|
|
95
|
+
process.env.TALON_QUIET = "1";
|
|
96
|
+
vi.doMock("../util/paths.js", () => ({
|
|
97
|
+
dirs: { root: "/fake/.talon" },
|
|
98
|
+
files: {
|
|
99
|
+
log: "/fake/.talon/talon.log",
|
|
100
|
+
config: "/fake/.talon/config.json",
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
const readFileSyncMock = vi.fn(() => "{}");
|
|
104
|
+
vi.doMock("node:fs", () => ({
|
|
105
|
+
existsSync: vi.fn(() => true),
|
|
106
|
+
mkdirSync: mockMkdirSync,
|
|
107
|
+
statSync: vi.fn(() => ({ size: 0 })),
|
|
108
|
+
renameSync: mockRenameSync,
|
|
109
|
+
unlinkSync: mockUnlinkSync,
|
|
110
|
+
readFileSync: readFileSyncMock,
|
|
111
|
+
}));
|
|
112
|
+
vi.doMock("pino", () => ({
|
|
113
|
+
default: () => ({
|
|
114
|
+
info: mockLogFn,
|
|
115
|
+
error: mockLogFn,
|
|
116
|
+
warn: mockLogFn,
|
|
117
|
+
debug: mockLogFn,
|
|
118
|
+
}),
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
await import("../util/log.js");
|
|
122
|
+
|
|
123
|
+
// When TALON_QUIET=1, quiet=true before `if (!quiet)` — readFileSync for config never called
|
|
124
|
+
const configCalls = readFileSyncMock.mock.calls.filter((c: unknown[]) =>
|
|
125
|
+
String(c[0]).includes("config"),
|
|
126
|
+
);
|
|
127
|
+
expect(configCalls).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -29,14 +29,29 @@ describe("log", () => {
|
|
|
29
29
|
it("calls pino.info with component and message", () => {
|
|
30
30
|
log("bot", "started successfully");
|
|
31
31
|
expect(mockInfo).toHaveBeenCalledOnce();
|
|
32
|
-
expect(mockInfo).toHaveBeenCalledWith(
|
|
32
|
+
expect(mockInfo).toHaveBeenCalledWith(
|
|
33
|
+
{ component: "bot" },
|
|
34
|
+
"started successfully",
|
|
35
|
+
);
|
|
33
36
|
});
|
|
34
37
|
|
|
35
38
|
it("works with all valid component types", () => {
|
|
36
39
|
const components = [
|
|
37
|
-
"bot",
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
+
"bot",
|
|
41
|
+
"bridge",
|
|
42
|
+
"agent",
|
|
43
|
+
"pulse",
|
|
44
|
+
"userbot",
|
|
45
|
+
"users",
|
|
46
|
+
"watchdog",
|
|
47
|
+
"workspace",
|
|
48
|
+
"shutdown",
|
|
49
|
+
"file",
|
|
50
|
+
"sessions",
|
|
51
|
+
"settings",
|
|
52
|
+
"commands",
|
|
53
|
+
"cron",
|
|
54
|
+
"dispatcher",
|
|
40
55
|
] as const;
|
|
41
56
|
for (const component of components) {
|
|
42
57
|
mockInfo.mockClear();
|
|
@@ -50,7 +65,10 @@ describe("log", () => {
|
|
|
50
65
|
it("calls pino.error with component and message", () => {
|
|
51
66
|
logError("bot", "failed to start");
|
|
52
67
|
expect(mockError).toHaveBeenCalledOnce();
|
|
53
|
-
expect(mockError).toHaveBeenCalledWith(
|
|
68
|
+
expect(mockError).toHaveBeenCalledWith(
|
|
69
|
+
{ component: "bot" },
|
|
70
|
+
"failed to start",
|
|
71
|
+
);
|
|
54
72
|
});
|
|
55
73
|
|
|
56
74
|
it("includes Error message in context", () => {
|