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
|
@@ -244,13 +244,13 @@ describe("cron-store", () => {
|
|
|
244
244
|
expect(id.startsWith("cron_")).toBe(true);
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
-
it("contains a
|
|
247
|
+
it("contains a UUID component after the prefix", () => {
|
|
248
248
|
const id = generateCronId();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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(
|
|
299
|
-
|
|
300
|
-
|
|
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 =
|
|
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), {
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
});
|