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
@@ -244,13 +244,13 @@ describe("cron-store", () => {
244
244
  expect(id.startsWith("cron_")).toBe(true);
245
245
  });
246
246
 
247
- it("contains a timestamp component", () => {
247
+ it("contains a UUID component after the prefix", () => {
248
248
  const id = generateCronId();
249
- const parts = id.split("_");
250
- expect(parts.length).toBe(3);
251
- const ts = Number(parts[1]);
252
- expect(ts).toBeGreaterThan(0);
253
- expect(ts).toBeLessThanOrEqual(Date.now());
249
+ // Format: "cron_<uuid>" — uuid is 36 chars including hyphens
250
+ const uuid = id.slice("cron_".length);
251
+ expect(uuid).toMatch(
252
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
253
+ );
254
254
  });
255
255
  });
256
256
 
@@ -295,9 +295,15 @@ describe("cron-store", () => {
295
295
  });
296
296
 
297
297
  it("validates with different valid timezones", () => {
298
- expect(validateCronExpression("0 9 * * *", "Europe/London").valid).toBe(true);
299
- expect(validateCronExpression("0 9 * * *", "Asia/Tokyo").valid).toBe(true);
300
- expect(validateCronExpression("0 9 * * *", "US/Pacific").valid).toBe(true);
298
+ expect(validateCronExpression("0 9 * * *", "Europe/London").valid).toBe(
299
+ true,
300
+ );
301
+ expect(validateCronExpression("0 9 * * *", "Asia/Tokyo").valid).toBe(
302
+ true,
303
+ );
304
+ expect(validateCronExpression("0 9 * * *", "US/Pacific").valid).toBe(
305
+ true,
306
+ );
301
307
  });
302
308
 
303
309
  it("returns no error field on valid expression", () => {
@@ -400,7 +406,8 @@ describe("cron-store", () => {
400
406
 
401
407
  expect(writeFileSyncMock).toHaveBeenCalled();
402
408
  // Last write call is the actual data (earlier calls may be .bak backups)
403
- const lastCall = writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1];
409
+ const lastCall =
410
+ writeFileSyncMock.mock.calls[writeFileSyncMock.mock.calls.length - 1];
404
411
  const writtenData = lastCall[1] as string;
405
412
  const parsed = JSON.parse(writtenData.trim());
406
413
  expect(parsed["flush-1"]).toBeDefined();
@@ -414,7 +421,9 @@ describe("cron-store", () => {
414
421
 
415
422
  addCronJob(makeCronJob({ id: "flush-mkdir-1" }));
416
423
 
417
- expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), { recursive: true });
424
+ expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), {
425
+ recursive: true,
426
+ });
418
427
  });
419
428
 
420
429
  it("does not write when not dirty", () => {
@@ -438,3 +447,128 @@ describe("cron-store", () => {
438
447
  });
439
448
  });
440
449
  });
450
+
451
+ describe("cron-store — additional branch coverage", () => {
452
+ it("loadCronJobs with count=0 (empty store) does not log", async () => {
453
+ const { log } = await import("../util/log.js");
454
+ vi.mocked(log).mockClear();
455
+
456
+ // File exists but has no jobs — count = 0, no log call
457
+ existsSyncMock.mockReturnValue(true);
458
+ readFileSyncMock.mockReturnValue("{}");
459
+ loadCronJobs();
460
+
461
+ // log should not be called for empty store (count > 0 check is false)
462
+ expect(vi.mocked(log)).not.toHaveBeenCalledWith(
463
+ "cron",
464
+ expect.stringContaining("Loaded"),
465
+ );
466
+ });
467
+
468
+ it("save() logs error when writeFileAtomic throws", async () => {
469
+ const { logError } = await import("../util/log.js");
470
+ vi.mocked(logError).mockClear();
471
+
472
+ existsSyncMock.mockReturnValue(false);
473
+ writeFileSyncMock.mockImplementationOnce(() => {
474
+ throw new Error("io error");
475
+ });
476
+
477
+ // addCronJob sets dirty=true and calls save()
478
+ expect(() => addCronJob(makeCronJob({ id: "save-fail-1" }))).not.toThrow();
479
+ expect(vi.mocked(logError)).toHaveBeenCalled();
480
+ });
481
+
482
+ it("loadCronJobs loads from backup when primary file is corrupt", async () => {
483
+ const backupJob = {
484
+ "backup-job-1": {
485
+ id: "backup-job-1",
486
+ chatId: "chat-bak",
487
+ schedule: "0 9 * * *",
488
+ type: "message",
489
+ content: "From backup",
490
+ name: "Backup Job",
491
+ enabled: true,
492
+ createdAt: 1000,
493
+ runCount: 0,
494
+ },
495
+ };
496
+
497
+ // First existsSync call: primary file exists
498
+ // Second existsSync call (inside catch): backup file exists
499
+ existsSyncMock
500
+ .mockReturnValueOnce(true) // STORE_FILE exists
501
+ .mockReturnValueOnce(true); // bakFile exists
502
+
503
+ readFileSyncMock
504
+ .mockReturnValueOnce("not valid json{{{") // primary is corrupt
505
+ .mockReturnValueOnce(JSON.stringify(backupJob)); // backup is valid
506
+
507
+ loadCronJobs();
508
+
509
+ expect(getCronJob("backup-job-1")).toBeDefined();
510
+ expect(getCronJob("backup-job-1")!.content).toBe("From backup");
511
+ });
512
+
513
+ it("loadCronJobs with invalid timezone clears it and logs", async () => {
514
+ const { log } = await import("../util/log.js");
515
+ const stored = {
516
+ "tz-invalid-1": {
517
+ id: "tz-invalid-1",
518
+ chatId: "chat-tz",
519
+ schedule: "0 9 * * *",
520
+ type: "message",
521
+ content: "Hello",
522
+ name: "TZ test",
523
+ enabled: true,
524
+ createdAt: 1000,
525
+ runCount: 0,
526
+ timezone: "Not/A/Real/Timezone",
527
+ },
528
+ };
529
+ existsSyncMock.mockReturnValue(true);
530
+ readFileSyncMock.mockReturnValue(JSON.stringify(stored));
531
+
532
+ loadCronJobs();
533
+
534
+ const job = getCronJob("tz-invalid-1");
535
+ expect(job).toBeDefined();
536
+ // timezone was invalid — should be cleared
537
+ expect(job!.timezone).toBeUndefined();
538
+ // log should mention invalid timezone
539
+ expect(vi.mocked(log)).toHaveBeenCalledWith(
540
+ "cron",
541
+ expect.stringContaining("invalid timezone"),
542
+ );
543
+ });
544
+
545
+ it("save() logs error with String(err) when writeFileAtomic throws a non-Error", async () => {
546
+ const { logError } = await import("../util/log.js");
547
+ vi.mocked(logError).mockClear();
548
+
549
+ // Reset existsSyncMock to false so no backup is attempted (avoids consuming mockImplementationOnce early)
550
+ existsSyncMock.mockReturnValue(false);
551
+
552
+ // Throw a plain string (non-Error) to cover the `err instanceof Error ? ... : err` false branch on line 103
553
+ writeFileSyncMock.mockImplementation(() => {
554
+ throw "disk quota exceeded";
555
+ }); // eslint-disable-line @typescript-eslint/no-throw-literal
556
+
557
+ expect(() =>
558
+ addCronJob(makeCronJob({ id: "save-non-error-string" })),
559
+ ).not.toThrow();
560
+ expect(vi.mocked(logError)).toHaveBeenCalled();
561
+
562
+ writeFileSyncMock.mockReset(); // restore default behavior
563
+ });
564
+
565
+ it("validateCronExpression returns error with String(err) for non-Error throw", () => {
566
+ // croner throws an Error for invalid expressions, but we test the fallback branch
567
+ // by passing an expression that may cause a non-Error to be thrown indirectly.
568
+ // Actually croner always throws Error, so the String(err) branch on line 139 is a safety net.
569
+ // We can cover it by calling validateCronExpression with a badly malformed expression.
570
+ const result = validateCronExpression("not a cron expression at all !!!");
571
+ expect(result.valid).toBe(false);
572
+ expect(result.error).toBeDefined();
573
+ });
574
+ });
@@ -17,6 +17,7 @@ vi.mock("../util/log.js", () => ({
17
17
  }));
18
18
  import { join } from "node:path";
19
19
  import { tmpdir } from "node:os";
20
+ import { toYMD } from "../util/time.js";
20
21
 
21
22
  // Use a unique temp directory for each test run
22
23
  const TEST_ROOT = join(tmpdir(), `talon-daily-log-test-${Date.now()}`);
@@ -24,7 +25,7 @@ const LOGS_DIR = join(TEST_ROOT, ".talon", "workspace", "logs");
24
25
 
25
26
  // paths.ts uses os.homedir() — mock it to point to our temp directory
26
27
  vi.mock("node:os", async (importOriginal) => {
27
- const actual = await importOriginal() as Record<string, unknown>;
28
+ const actual = (await importOriginal()) as Record<string, unknown>;
28
29
  return { ...actual, homedir: () => TEST_ROOT };
29
30
  });
30
31
 
@@ -41,9 +42,8 @@ describe("daily-log", () => {
41
42
  describe("appendDailyLog", () => {
42
43
  it("creates the log file if missing", async () => {
43
44
  // Re-import to pick up the mocked cwd
44
- const { appendDailyLog, getLogsDir } = await import(
45
- "../storage/daily-log.js"
46
- );
45
+ const { appendDailyLog, getLogsDir } =
46
+ await import("../storage/daily-log.js");
47
47
 
48
48
  // Ensure logs dir doesn't exist yet
49
49
  expect(existsSync(LOGS_DIR)).toBe(false);
@@ -64,9 +64,8 @@ describe("daily-log", () => {
64
64
  });
65
65
 
66
66
  it("appends to existing file", async () => {
67
- const { appendDailyLog, getLogsDir } = await import(
68
- "../storage/daily-log.js"
69
- );
67
+ const { appendDailyLog, getLogsDir } =
68
+ await import("../storage/daily-log.js");
70
69
 
71
70
  appendDailyLog("Chat1", "First entry");
72
71
  appendDailyLog("Chat2", "Second entry");
@@ -83,9 +82,8 @@ describe("daily-log", () => {
83
82
  });
84
83
 
85
84
  it("uses correct log format (## HH:MM -- [name])", async () => {
86
- const { appendDailyLog, getLogsDir } = await import(
87
- "../storage/daily-log.js"
88
- );
85
+ const { appendDailyLog, getLogsDir } =
86
+ await import("../storage/daily-log.js");
89
87
 
90
88
  appendDailyLog("MyChat", "Did some testing");
91
89
 
@@ -102,9 +100,8 @@ describe("daily-log", () => {
102
100
  });
103
101
 
104
102
  it("uses .talon/workspace/logs/ directory", async () => {
105
- const { appendDailyLog, getLogsDir } = await import(
106
- "../storage/daily-log.js"
107
- );
103
+ const { appendDailyLog, getLogsDir } =
104
+ await import("../storage/daily-log.js");
108
105
 
109
106
  appendDailyLog("LogDirTest", "checking path");
110
107
 
@@ -142,5 +139,219 @@ describe("daily-log", () => {
142
139
  const { cleanupOldLogs } = await import("../storage/daily-log.js");
143
140
  expect(() => cleanupOldLogs()).not.toThrow();
144
141
  });
142
+
143
+ it("does not log when no files are deleted (line 93 FALSE branch: deleted=0)", async () => {
144
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
145
+ mkdirSync(LOGS_DIR, { recursive: true });
146
+
147
+ // Only a recent file — not old enough to be deleted
148
+ const recentDate = new Date();
149
+ recentDate.setDate(recentDate.getDate() - 5);
150
+ const recentName = recentDate.toISOString().slice(0, 10) + ".md";
151
+ writeFileSync(join(LOGS_DIR, recentName), "recent content");
152
+
153
+ cleanupOldLogs();
154
+
155
+ // File should still be present (not deleted)
156
+ expect(readdirSync(LOGS_DIR)).toContain(recentName);
157
+ });
158
+ });
159
+
160
+ describe("cleanupOldLogs — daily memory files", () => {
161
+ const DAILY_MEM_DIR = join(
162
+ TEST_ROOT,
163
+ ".talon",
164
+ "workspace",
165
+ "memory",
166
+ "daily",
167
+ );
168
+
169
+ it("deletes daily memory files older than 30 days", async () => {
170
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
171
+ mkdirSync(LOGS_DIR, { recursive: true });
172
+ mkdirSync(DAILY_MEM_DIR, { recursive: true });
173
+
174
+ // Create an old daily memory file (40 days ago)
175
+ const oldDate = new Date();
176
+ oldDate.setDate(oldDate.getDate() - 40);
177
+ const oldName = toYMD(oldDate) + ".md";
178
+ writeFileSync(join(DAILY_MEM_DIR, oldName), "old daily memory");
179
+
180
+ // Create a recent daily memory file (5 days ago)
181
+ const recentDate = new Date();
182
+ recentDate.setDate(recentDate.getDate() - 5);
183
+ const recentName = toYMD(recentDate) + ".md";
184
+ writeFileSync(join(DAILY_MEM_DIR, recentName), "recent daily memory");
185
+
186
+ cleanupOldLogs();
187
+
188
+ const remaining = readdirSync(DAILY_MEM_DIR);
189
+ expect(remaining).not.toContain(oldName);
190
+ expect(remaining).toContain(recentName);
191
+ });
192
+
193
+ it("handles missing daily memory directory gracefully", async () => {
194
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
195
+ // Create logs dir but NOT the daily memory dir
196
+ mkdirSync(LOGS_DIR, { recursive: true });
197
+ expect(() => cleanupOldLogs()).not.toThrow();
198
+ });
199
+
200
+ it("ignores non-YYYY-MM-DD.md files in daily memory dir", async () => {
201
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
202
+ mkdirSync(LOGS_DIR, { recursive: true });
203
+ mkdirSync(DAILY_MEM_DIR, { recursive: true });
204
+
205
+ // A file that would sort before the cutoff but doesn't match the pattern
206
+ writeFileSync(join(DAILY_MEM_DIR, "2020-summary.md"), "should survive");
207
+ writeFileSync(join(DAILY_MEM_DIR, "notes.md"), "also should survive");
208
+
209
+ cleanupOldLogs();
210
+
211
+ const remaining = readdirSync(DAILY_MEM_DIR);
212
+ expect(remaining).toContain("2020-summary.md");
213
+ expect(remaining).toContain("notes.md");
214
+ });
215
+
216
+ it("ignores non-YYYY-MM-DD.md files in logs dir", async () => {
217
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
218
+ mkdirSync(LOGS_DIR, { recursive: true });
219
+
220
+ writeFileSync(join(LOGS_DIR, "2020-summary.md"), "should survive");
221
+
222
+ cleanupOldLogs();
223
+
224
+ expect(readdirSync(LOGS_DIR)).toContain("2020-summary.md");
225
+ });
226
+
227
+ it("does not delete recent daily memory files", async () => {
228
+ const { cleanupOldLogs } = await import("../storage/daily-log.js");
229
+ mkdirSync(LOGS_DIR, { recursive: true });
230
+ mkdirSync(DAILY_MEM_DIR, { recursive: true });
231
+
232
+ const recentDate = new Date();
233
+ recentDate.setDate(recentDate.getDate() - 3);
234
+ const recentName = toYMD(recentDate) + ".md";
235
+ writeFileSync(join(DAILY_MEM_DIR, recentName), "keep me");
236
+
237
+ cleanupOldLogs();
238
+
239
+ expect(readdirSync(DAILY_MEM_DIR)).toContain(recentName);
240
+ });
241
+ });
242
+
243
+ describe("appendDailyLogResponse", () => {
244
+ it("writes bot response with chat title context", async () => {
245
+ const { appendDailyLogResponse, getLogsDir } =
246
+ await import("../storage/daily-log.js");
247
+ appendDailyLogResponse("Talon", "Here is the weather.", {
248
+ chatTitle: "MyGroup",
249
+ });
250
+ const logsDir = getLogsDir();
251
+ const todayStr = new Date().toISOString().slice(0, 10);
252
+ const content = readFileSync(join(logsDir, `${todayStr}.md`), "utf-8");
253
+ expect(content).toContain("Talon in MyGroup");
254
+ expect(content).toContain("Here is the weather.");
255
+ });
256
+
257
+ it("writes bot response without chat title", async () => {
258
+ const { appendDailyLogResponse, getLogsDir } =
259
+ await import("../storage/daily-log.js");
260
+ appendDailyLogResponse("Talon", "Standalone response");
261
+ const logsDir = getLogsDir();
262
+ const todayStr = new Date().toISOString().slice(0, 10);
263
+ const content = readFileSync(join(logsDir, `${todayStr}.md`), "utf-8");
264
+ expect(content).toContain("[Talon]");
265
+ expect(content).toContain("Standalone response");
266
+ });
267
+
268
+ it("formats response with ## HH:MM -- [label] header", async () => {
269
+ const { appendDailyLogResponse, getLogsDir } =
270
+ await import("../storage/daily-log.js");
271
+ appendDailyLogResponse("BotName", "response text");
272
+ const logsDir = getLogsDir();
273
+ const todayStr = new Date().toISOString().slice(0, 10);
274
+ const content = readFileSync(join(logsDir, `${todayStr}.md`), "utf-8");
275
+ expect(content).toMatch(/## \d{2}:\d{2} -- \[BotName\]/);
276
+ });
277
+ });
278
+
279
+ describe("appendDailyLog — chat context labels", () => {
280
+ it("includes username in label", async () => {
281
+ const { appendDailyLog, getLogsDir } =
282
+ await import("../storage/daily-log.js");
283
+ appendDailyLog("Alice", "hello", { username: "alice_tg" });
284
+ const logsDir = getLogsDir();
285
+ const todayStr = new Date().toISOString().slice(0, 10);
286
+ const content = readFileSync(join(logsDir, `${todayStr}.md`), "utf-8");
287
+ expect(content).toContain("Alice (@alice_tg)");
288
+ });
289
+
290
+ it("includes chat title and username together", async () => {
291
+ const { appendDailyLog, getLogsDir } =
292
+ await import("../storage/daily-log.js");
293
+ appendDailyLog("Bob", "test message", {
294
+ chatTitle: "DevGroup",
295
+ username: "bob_dev",
296
+ });
297
+ const logsDir = getLogsDir();
298
+ const todayStr = new Date().toISOString().slice(0, 10);
299
+ const content = readFileSync(join(logsDir, `${todayStr}.md`), "utf-8");
300
+ expect(content).toContain("Bob (@bob_dev) in DevGroup");
301
+ });
302
+ });
303
+ });
304
+
305
+ describe("daily-log — error resilience", () => {
306
+ it("appendDailyLog does not throw when write fails (e.g. permissions)", async () => {
307
+ vi.resetModules();
308
+ // Mock node:fs to make appendFileSync throw
309
+ vi.doMock("node:fs", () => ({
310
+ existsSync: vi.fn(() => true),
311
+ mkdirSync: vi.fn(),
312
+ appendFileSync: vi.fn(() => {
313
+ throw new Error("EPERM: permission denied");
314
+ }),
315
+ readdirSync: vi.fn(() => []),
316
+ unlinkSync: vi.fn(),
317
+ }));
318
+ vi.doMock("../util/log.js", () => ({
319
+ log: vi.fn(),
320
+ logError: vi.fn(),
321
+ logWarn: vi.fn(),
322
+ logDebug: vi.fn(),
323
+ }));
324
+ vi.doMock("node:os", async (importOriginal) => {
325
+ const actual = (await importOriginal()) as Record<string, unknown>;
326
+ return { ...actual, homedir: () => TEST_ROOT };
327
+ });
328
+ const { appendDailyLog } = await import("../storage/daily-log.js");
329
+ // Should swallow the error, not throw
330
+ expect(() => appendDailyLog("Test", "message")).not.toThrow();
331
+ });
332
+
333
+ it("appendDailyLogResponse does not throw when write fails", async () => {
334
+ vi.resetModules();
335
+ vi.doMock("node:fs", () => ({
336
+ existsSync: vi.fn(() => true),
337
+ mkdirSync: vi.fn(),
338
+ appendFileSync: vi.fn(() => {
339
+ throw new Error("EROFS: read-only file system");
340
+ }),
341
+ readdirSync: vi.fn(() => []),
342
+ unlinkSync: vi.fn(),
343
+ }));
344
+ vi.doMock("../util/log.js", () => ({
345
+ log: vi.fn(),
346
+ logError: vi.fn(),
347
+ logWarn: vi.fn(),
348
+ logDebug: vi.fn(),
349
+ }));
350
+ vi.doMock("node:os", async (importOriginal) => {
351
+ const actual = (await importOriginal()) as Record<string, unknown>;
352
+ return { ...actual, homedir: () => TEST_ROOT };
353
+ });
354
+ const { appendDailyLogResponse } = await import("../storage/daily-log.js");
355
+ expect(() => appendDailyLogResponse("Bot", "response")).not.toThrow();
145
356
  });
146
357
  });