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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. 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
- loadHistory,
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<string, Array<{ msgId: number; senderId: number; senderName: string; text: string; timestamp: number }>> = {
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
- { msgId: 1, senderId: 100, senderName: "Alice", text: "hello", timestamp: 1000 },
43
- { msgId: 2, senderId: 200, senderName: "Bob", text: "hi", timestamp: 2000 },
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": [{ msgId: 1, senderId: 1, senderName: "A", text: "a", timestamp: 1 }],
93
- "chat-b": [{ msgId: 2, senderId: 2, senderName: "B", text: "b", timestamp: 2 }],
94
- "chat-c": [{ msgId: 3, senderId: 3, senderName: "C", text: "c", timestamp: 3 }],
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 = writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1];
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
- expect.any(String),
149
- { recursive: true },
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
- existsSyncMock.mockReturnValue(true);
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(id, makeMsg({
68
- msgId: 1,
69
- senderName: "Alice",
70
- text: "photo msg",
71
- replyToMsgId: 99,
72
- mediaType: "photo",
73
- stickerFileId: "stk123",
74
- filePath: "/tmp/photo.jpg",
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(() => setMessageFilePath("no-such-chat", 1, "/tmp/x.jpg")).not.toThrow();
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(id, makeMsg({ msgId: i, senderName: "Alice", text: `msg ${i}` }));
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(id, makeMsg({
310
- msgId: 1,
311
- senderId: 100,
312
- senderName: "Alice",
313
- timestamp: Date.now() - 5 * 60_000, // 5 minutes ago
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(id, makeMsg({
322
- msgId: 1,
323
- senderId: 100,
324
- senderName: "Alice",
325
- timestamp: Date.now() - 3 * 3_600_000, // 3 hours ago
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(id, makeMsg({
334
- msgId: 1,
335
- senderId: 100,
336
- senderName: "Alice",
337
- timestamp: Date.now() - 2 * 86_400_000, // 2 days ago
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(id, makeMsg({
346
- msgId: 1,
347
- senderId: 100,
348
- senderName: "Alice",
349
- timestamp: Date.now() - 10_000, // 10 seconds ago
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(id, makeMsg({
358
- msgId: 1,
359
- senderId: 100,
360
- senderName: "OldUser",
361
- timestamp: Date.now() - 86_400_000,
362
- }));
363
- pushMessage(id, makeMsg({
364
- msgId: 2,
365
- senderId: 200,
366
- senderName: "NewUser",
367
- timestamp: Date.now() - 60_000,
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(id, makeMsg({
378
- msgId: 1,
379
- senderId: 100,
380
- senderName: "OldName",
381
- timestamp: Date.now() - 60_000,
382
- }));
383
- pushMessage(id, makeMsg({
384
- msgId: 2,
385
- senderId: 100,
386
- senderName: "NewName",
387
- timestamp: Date.now(),
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(id, makeMsg({ msgId: 1, text: "first", timestamp: 1000000000000 }));
560
- pushMessage(id, makeMsg({ msgId: 2, text: "second", timestamp: 1000000060000 }));
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"]> = ["document", "voice", "video", "animation"];
602
+ const types: Array<HistoryMessage["mediaType"]> = [
603
+ "document",
604
+ "voice",
605
+ "video",
606
+ "animation",
607
+ ];
571
608
  types.forEach((type, i) => {
572
- pushMessage(id, makeMsg({ msgId: i + 1, text: `media ${type}`, mediaType: type }));
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
- id,
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(overrides: { queryResult?: Record<string, unknown>; queryError?: Error } = {}) {
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) => { typingCalls.push(id); }),
42
- onActivity: vi.fn(() => { activityCount++; }),
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 { backend, context, acquired, released, typingCalls, getActivityCount: () => activityCount };
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 } = setup();
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 { text: "", durationMs: 20, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 };
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({ chatId: "A", numericChatId: 1, prompt: "a", senderName: "U", isGroup: false, source: "message" }),
157
- execute({ chatId: "B", numericChatId: 2, prompt: "b", senderName: "U", isGroup: false, source: "message" }),
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({ component: "bot" }, "started successfully");
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", "bridge", "agent", "pulse", "userbot", "users",
38
- "watchdog", "workspace", "shutdown", "file", "sessions",
39
- "settings", "commands", "cron", "dispatcher",
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({ component: "bot" }, "failed to start");
68
+ expect(mockError).toHaveBeenCalledWith(
69
+ { component: "bot" },
70
+ "failed to start",
71
+ );
54
72
  });
55
73
 
56
74
  it("includes Error message in context", () => {