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
|
@@ -5,9 +5,8 @@ vi.mock("../storage/history.js", () => ({
|
|
|
5
5
|
}));
|
|
6
6
|
|
|
7
7
|
const { getRecentBySenderId } = await import("../storage/history.js");
|
|
8
|
-
const { enrichDMPrompt, enrichGroupPrompt } =
|
|
9
|
-
"../core/prompt-builder.js"
|
|
10
|
-
);
|
|
8
|
+
const { enrichDMPrompt, enrichGroupPrompt } =
|
|
9
|
+
await import("../core/prompt-builder.js");
|
|
11
10
|
|
|
12
11
|
describe("enrichDMPrompt", () => {
|
|
13
12
|
it("prepends DM metadata", () => {
|
|
@@ -39,7 +38,13 @@ describe("enrichGroupPrompt", () => {
|
|
|
39
38
|
|
|
40
39
|
it("returns prompt unchanged when only one message (current)", () => {
|
|
41
40
|
vi.mocked(getRecentBySenderId).mockReturnValue([
|
|
42
|
-
{
|
|
41
|
+
{
|
|
42
|
+
msgId: 1,
|
|
43
|
+
senderId: 42,
|
|
44
|
+
senderName: "Alice",
|
|
45
|
+
text: "hello",
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
},
|
|
43
48
|
]);
|
|
44
49
|
const result = enrichGroupPrompt("hello", "chat1", 42);
|
|
45
50
|
expect(result).toBe("hello");
|
|
@@ -47,9 +52,27 @@ describe("enrichGroupPrompt", () => {
|
|
|
47
52
|
|
|
48
53
|
it("prepends prior messages for threading context", () => {
|
|
49
54
|
vi.mocked(getRecentBySenderId).mockReturnValue([
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
{
|
|
56
|
+
msgId: 1,
|
|
57
|
+
senderId: 42,
|
|
58
|
+
senderName: "Alice",
|
|
59
|
+
text: "first message",
|
|
60
|
+
timestamp: new Date("2025-01-01T10:00:00Z").getTime(),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
msgId: 2,
|
|
64
|
+
senderId: 42,
|
|
65
|
+
senderName: "Alice",
|
|
66
|
+
text: "second message",
|
|
67
|
+
timestamp: new Date("2025-01-01T10:01:00Z").getTime(),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
msgId: 3,
|
|
71
|
+
senderId: 42,
|
|
72
|
+
senderName: "Alice",
|
|
73
|
+
text: "current",
|
|
74
|
+
timestamp: new Date("2025-01-01T10:02:00Z").getTime(),
|
|
75
|
+
},
|
|
53
76
|
]);
|
|
54
77
|
const result = enrichGroupPrompt("current", "chat1", 42);
|
|
55
78
|
expect(result).toContain("Alice's recent messages");
|
|
@@ -61,8 +84,20 @@ describe("enrichGroupPrompt", () => {
|
|
|
61
84
|
it("truncates long messages to 200 chars", () => {
|
|
62
85
|
const longText = "x".repeat(300);
|
|
63
86
|
vi.mocked(getRecentBySenderId).mockReturnValue([
|
|
64
|
-
{
|
|
65
|
-
|
|
87
|
+
{
|
|
88
|
+
msgId: 1,
|
|
89
|
+
senderId: 42,
|
|
90
|
+
senderName: "Bob",
|
|
91
|
+
text: longText,
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
msgId: 2,
|
|
96
|
+
senderId: 42,
|
|
97
|
+
senderName: "Bob",
|
|
98
|
+
text: "current",
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
},
|
|
66
101
|
]);
|
|
67
102
|
const result = enrichGroupPrompt("current", "chat1", 42);
|
|
68
103
|
expect(result).not.toContain(longText); // full text shouldn't appear
|
|
@@ -494,10 +494,10 @@ describe("sessions", () => {
|
|
|
494
494
|
// cacheRead * pricing.cacheRead + output * pricing.output) / 1_000_000
|
|
495
495
|
// Sonnet: input=$3/M, output=$15/M, cacheRead=$0.3/M, cacheWrite=$3.75/M
|
|
496
496
|
recordUsage(chatId, {
|
|
497
|
-
inputTokens: 500_000,
|
|
498
|
-
outputTokens: 100_000,
|
|
499
|
-
cacheRead: 200_000,
|
|
500
|
-
cacheWrite: 100_000,
|
|
497
|
+
inputTokens: 500_000, // 500k * 3 / 1M = $1.50
|
|
498
|
+
outputTokens: 100_000, // 100k * 15 / 1M = $1.50
|
|
499
|
+
cacheRead: 200_000, // 200k * 0.3 / 1M = $0.06
|
|
500
|
+
cacheWrite: 100_000, // 100k * 3.75 / 1M = $0.375
|
|
501
501
|
model: "claude-sonnet-4-6",
|
|
502
502
|
});
|
|
503
503
|
|
|
@@ -592,3 +592,257 @@ describe("sessions", () => {
|
|
|
592
592
|
});
|
|
593
593
|
});
|
|
594
594
|
});
|
|
595
|
+
|
|
596
|
+
describe("sessions — migration of legacy field formats", () => {
|
|
597
|
+
it("adds missing usage object to legacy session", () => {
|
|
598
|
+
// Simulate a session loaded without usage (old format)
|
|
599
|
+
// We do this by calling loadSessions with a mocked file that has no usage field
|
|
600
|
+
vi.mocked(existsSync).mockReturnValueOnce(true);
|
|
601
|
+
vi.mocked(readFileSync).mockReturnValueOnce(
|
|
602
|
+
JSON.stringify({
|
|
603
|
+
"migrate-chat-1": {
|
|
604
|
+
sessionId: undefined,
|
|
605
|
+
turns: 5,
|
|
606
|
+
lastActive: 1000,
|
|
607
|
+
createdAt: 1000,
|
|
608
|
+
},
|
|
609
|
+
}),
|
|
610
|
+
);
|
|
611
|
+
loadSessions();
|
|
612
|
+
const session = getSession("migrate-chat-1");
|
|
613
|
+
expect(session.usage).toBeDefined();
|
|
614
|
+
expect(session.usage.totalInputTokens).toBe(0);
|
|
615
|
+
expect(session.usage.fastestResponseMs).toBe(Infinity);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("fixes missing createdAt by using lastActive", () => {
|
|
619
|
+
vi.mocked(existsSync).mockReturnValueOnce(true);
|
|
620
|
+
vi.mocked(readFileSync).mockReturnValueOnce(
|
|
621
|
+
JSON.stringify({
|
|
622
|
+
"migrate-chat-2": {
|
|
623
|
+
sessionId: undefined,
|
|
624
|
+
turns: 3,
|
|
625
|
+
lastActive: 9999999,
|
|
626
|
+
usage: {
|
|
627
|
+
totalInputTokens: 0,
|
|
628
|
+
totalOutputTokens: 0,
|
|
629
|
+
totalCacheRead: 0,
|
|
630
|
+
totalCacheWrite: 0,
|
|
631
|
+
lastPromptTokens: 0,
|
|
632
|
+
estimatedCostUsd: 0,
|
|
633
|
+
totalResponseMs: 0,
|
|
634
|
+
lastResponseMs: 0,
|
|
635
|
+
fastestResponseMs: Infinity,
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
loadSessions();
|
|
641
|
+
const session = getSession("migrate-chat-2");
|
|
642
|
+
expect(session.createdAt).toBe(9999999);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("fixes fastestResponseMs of 0 to Infinity", () => {
|
|
646
|
+
vi.mocked(existsSync).mockReturnValueOnce(true);
|
|
647
|
+
vi.mocked(readFileSync).mockReturnValueOnce(
|
|
648
|
+
JSON.stringify({
|
|
649
|
+
"migrate-chat-3": {
|
|
650
|
+
sessionId: undefined,
|
|
651
|
+
turns: 2,
|
|
652
|
+
lastActive: 1000,
|
|
653
|
+
createdAt: 1000,
|
|
654
|
+
usage: {
|
|
655
|
+
totalInputTokens: 0,
|
|
656
|
+
totalOutputTokens: 0,
|
|
657
|
+
totalCacheRead: 0,
|
|
658
|
+
totalCacheWrite: 0,
|
|
659
|
+
lastPromptTokens: 0,
|
|
660
|
+
estimatedCostUsd: 0,
|
|
661
|
+
totalResponseMs: 0,
|
|
662
|
+
lastResponseMs: 0,
|
|
663
|
+
fastestResponseMs: 0,
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
668
|
+
loadSessions();
|
|
669
|
+
const session = getSession("migrate-chat-3");
|
|
670
|
+
expect(session.usage.fastestResponseMs).toBe(Infinity);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe("sessions — loadSessions backup recovery", () => {
|
|
675
|
+
it("loads from backup when primary is corrupt", () => {
|
|
676
|
+
vi.mocked(existsSync)
|
|
677
|
+
.mockReturnValueOnce(true) // primary exists
|
|
678
|
+
.mockReturnValueOnce(true); // backup exists
|
|
679
|
+
vi.mocked(readFileSync)
|
|
680
|
+
.mockReturnValueOnce("{not valid json}") // primary corrupt
|
|
681
|
+
.mockReturnValueOnce(
|
|
682
|
+
JSON.stringify({
|
|
683
|
+
"backup-chat": {
|
|
684
|
+
sessionId: "bak-sid",
|
|
685
|
+
turns: 7,
|
|
686
|
+
lastActive: 1,
|
|
687
|
+
createdAt: 1,
|
|
688
|
+
usage: {
|
|
689
|
+
totalInputTokens: 0,
|
|
690
|
+
totalOutputTokens: 0,
|
|
691
|
+
totalCacheRead: 0,
|
|
692
|
+
totalCacheWrite: 0,
|
|
693
|
+
lastPromptTokens: 0,
|
|
694
|
+
estimatedCostUsd: 0,
|
|
695
|
+
totalResponseMs: 0,
|
|
696
|
+
lastResponseMs: 0,
|
|
697
|
+
fastestResponseMs: Infinity,
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
}),
|
|
701
|
+
);
|
|
702
|
+
loadSessions();
|
|
703
|
+
const s = getSession("backup-chat");
|
|
704
|
+
expect(s.turns).toBe(7);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("starts fresh when both primary and backup are corrupt", () => {
|
|
708
|
+
vi.mocked(existsSync)
|
|
709
|
+
.mockReturnValueOnce(true) // primary exists
|
|
710
|
+
.mockReturnValueOnce(true); // backup exists
|
|
711
|
+
vi.mocked(readFileSync)
|
|
712
|
+
.mockReturnValueOnce("BAD PRIMARY")
|
|
713
|
+
.mockReturnValueOnce("BAD BACKUP");
|
|
714
|
+
// Should not throw
|
|
715
|
+
expect(() => loadSessions()).not.toThrow();
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
describe("sessions — edge cases for branch coverage", () => {
|
|
720
|
+
it("resetSession includes session name in log when name is set", () => {
|
|
721
|
+
const chatId = "reset-named-session";
|
|
722
|
+
getSession(chatId);
|
|
723
|
+
setSessionName(chatId, "Work Chat");
|
|
724
|
+
// Does not throw; covers the `name ? ` "${name}"` branch in resetSession
|
|
725
|
+
expect(() => resetSession(chatId)).not.toThrow();
|
|
726
|
+
// Session should be gone
|
|
727
|
+
const fresh = getSession(chatId);
|
|
728
|
+
expect(fresh.turns).toBe(0);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("getAllSessions falls back to empty usage for sessions without usage field", () => {
|
|
732
|
+
// Directly inject a session without usage to cover the ?? branch (line 304)
|
|
733
|
+
// The store is accessed via loadSessions with a raw mock
|
|
734
|
+
vi.mocked(existsSync).mockReturnValueOnce(true);
|
|
735
|
+
vi.mocked(readFileSync).mockReturnValueOnce(
|
|
736
|
+
JSON.stringify({
|
|
737
|
+
"no-usage-chat": {
|
|
738
|
+
sessionId: undefined,
|
|
739
|
+
turns: 3,
|
|
740
|
+
lastActive: 1_000_000,
|
|
741
|
+
createdAt: 1_000_000,
|
|
742
|
+
// usage deliberately omitted to trigger the ?? fallback
|
|
743
|
+
},
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
loadSessions();
|
|
747
|
+
const all = getAllSessions();
|
|
748
|
+
const entry = all.find((s) => s.chatId === "no-usage-chat");
|
|
749
|
+
expect(entry).toBeDefined();
|
|
750
|
+
expect(entry!.info.usage).toBeDefined();
|
|
751
|
+
expect(entry!.info.usage.totalInputTokens).toBe(0);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("resetSession on nonexistent chat defaults turns to 0", () => {
|
|
755
|
+
// session?.turns ?? 0 — the ?? 0 fallback (session is undefined)
|
|
756
|
+
expect(() => resetSession("never-created-session-xyz")).not.toThrow();
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("saveSessions logs error when atomic write throws", async () => {
|
|
760
|
+
const { logError } = await import("../util/log.js");
|
|
761
|
+
writeFileAtomicSync.mockImplementationOnce(() => {
|
|
762
|
+
throw new Error("disk full");
|
|
763
|
+
});
|
|
764
|
+
// resetSession sets dirty=true then calls saveSessions
|
|
765
|
+
getSession("throw-on-save-xyz");
|
|
766
|
+
expect(() => resetSession("throw-on-save-xyz")).not.toThrow();
|
|
767
|
+
expect(logError).toHaveBeenCalled();
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it("fastestResponseMs ?? 0 defaults to Infinity on first duration record", () => {
|
|
771
|
+
const chatId = "fastest-first-call-xyz";
|
|
772
|
+
// Fresh session has fastestResponseMs=Infinity (from emptyUsage)
|
|
773
|
+
// Calling recordUsage sets current = Infinity || Infinity... actually Infinity is truthy
|
|
774
|
+
// so we need to exercise the case where fastestResponseMs is already set > durationMs
|
|
775
|
+
recordUsage(chatId, {
|
|
776
|
+
inputTokens: 0,
|
|
777
|
+
outputTokens: 0,
|
|
778
|
+
cacheRead: 0,
|
|
779
|
+
cacheWrite: 0,
|
|
780
|
+
durationMs: 500,
|
|
781
|
+
});
|
|
782
|
+
const session = getSession(chatId);
|
|
783
|
+
expect(session.usage.fastestResponseMs).toBe(500);
|
|
784
|
+
// Second call with LONGER duration — fastestResponseMs stays at 500 (not updated)
|
|
785
|
+
recordUsage(chatId, {
|
|
786
|
+
inputTokens: 0,
|
|
787
|
+
outputTokens: 0,
|
|
788
|
+
cacheRead: 0,
|
|
789
|
+
cacheWrite: 0,
|
|
790
|
+
durationMs: 1000,
|
|
791
|
+
});
|
|
792
|
+
expect(session.usage.fastestResponseMs).toBe(500);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("placeholder test removed", () => {
|
|
796
|
+
// placeholder - actual test moved to end of file to avoid module isolation issues
|
|
797
|
+
expect(true).toBe(true);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("saveSessions logs error with non-Error object thrown by writeFileAtomic", async () => {
|
|
801
|
+
const { logError } = await import("../util/log.js");
|
|
802
|
+
// Throw a plain string instead of an Error to cover the `err instanceof Error ? ... : err` false branch
|
|
803
|
+
writeFileAtomicSync.mockImplementationOnce(() => {
|
|
804
|
+
throw "plain string error";
|
|
805
|
+
}); // eslint-disable-line @typescript-eslint/no-throw-literal
|
|
806
|
+
getSession("throw-string-on-save-xyz");
|
|
807
|
+
expect(() => resetSession("throw-string-on-save-xyz")).not.toThrow();
|
|
808
|
+
expect(logError).toHaveBeenCalled();
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// ── saveSessions dirty=false early return ─────────────────────────────────
|
|
813
|
+
|
|
814
|
+
describe("sessions — saveSessions dirty=false early return (line 98 TRUE branch)", () => {
|
|
815
|
+
it("does not write when auto-save fires with dirty=false", async () => {
|
|
816
|
+
vi.resetModules();
|
|
817
|
+
vi.useFakeTimers();
|
|
818
|
+
const wfaMock = vi.fn();
|
|
819
|
+
vi.doMock("../util/log.js", () => ({
|
|
820
|
+
log: vi.fn(),
|
|
821
|
+
logError: vi.fn(),
|
|
822
|
+
logWarn: vi.fn(),
|
|
823
|
+
}));
|
|
824
|
+
vi.doMock("../util/watchdog.js", () => ({ recordError: vi.fn() }));
|
|
825
|
+
vi.doMock("node:fs", () => ({
|
|
826
|
+
existsSync: vi.fn(() => false),
|
|
827
|
+
mkdirSync: vi.fn(),
|
|
828
|
+
readFileSync: vi.fn(() => "{}"),
|
|
829
|
+
}));
|
|
830
|
+
vi.doMock("write-file-atomic", () => ({ default: { sync: wfaMock } }));
|
|
831
|
+
vi.doMock("../util/paths.js", () => ({
|
|
832
|
+
files: { sessions: "/fake/sessions.json" },
|
|
833
|
+
dirs: { root: "/fake/.talon", data: "/fake/.talon/data" },
|
|
834
|
+
}));
|
|
835
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
836
|
+
registerCleanup: vi.fn(),
|
|
837
|
+
}));
|
|
838
|
+
|
|
839
|
+
// Fresh import: dirty=false (nothing modified yet)
|
|
840
|
+
await import("../storage/sessions.js");
|
|
841
|
+
|
|
842
|
+
// Advance 11 seconds → auto-save timer fires → saveSessions() with dirty=false → early return
|
|
843
|
+
await vi.advanceTimersByTimeAsync(11_000);
|
|
844
|
+
expect(wfaMock).not.toHaveBeenCalled();
|
|
845
|
+
|
|
846
|
+
vi.useRealTimers();
|
|
847
|
+
});
|
|
848
|
+
});
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that cover the logError + recordError paths triggered when
|
|
3
|
+
* write-file-atomic throws during save() in cron-store, chat-settings,
|
|
4
|
+
* and sessions.
|
|
5
|
+
*
|
|
6
|
+
* Each module must be re-imported in isolation (vi.resetModules) so the
|
|
7
|
+
* mocks apply to the fresh module instance.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
11
|
+
|
|
12
|
+
// ── cron-store save failure ───────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe("cron-store — save failure logs error", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("calls logError and recordError when writeFileAtomic throws", async () => {
|
|
20
|
+
const logErrorMock = vi.fn();
|
|
21
|
+
const recordErrorMock = vi.fn();
|
|
22
|
+
|
|
23
|
+
vi.doMock("../util/log.js", () => ({
|
|
24
|
+
log: vi.fn(),
|
|
25
|
+
logError: logErrorMock,
|
|
26
|
+
logWarn: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
vi.doMock("../util/watchdog.js", () => ({
|
|
29
|
+
recordError: recordErrorMock,
|
|
30
|
+
}));
|
|
31
|
+
vi.doMock("node:fs", () => ({
|
|
32
|
+
existsSync: vi.fn(() => true),
|
|
33
|
+
readFileSync: vi.fn(() => "{}"),
|
|
34
|
+
mkdirSync: vi.fn(),
|
|
35
|
+
readdirSync: vi.fn(() => []),
|
|
36
|
+
}));
|
|
37
|
+
vi.doMock("write-file-atomic", () => ({
|
|
38
|
+
default: {
|
|
39
|
+
sync: vi.fn(() => {
|
|
40
|
+
throw new Error("disk full");
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
vi.doMock("../util/paths.js", () => ({
|
|
45
|
+
files: { cron: "/fake/cron.json" },
|
|
46
|
+
dirs: { root: "/fake/.talon" },
|
|
47
|
+
}));
|
|
48
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
49
|
+
registerCleanup: vi.fn(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
const { addCronJob, generateCronId, flushCronJobs } =
|
|
53
|
+
await import("../storage/cron-store.js");
|
|
54
|
+
|
|
55
|
+
const job = {
|
|
56
|
+
id: generateCronId(),
|
|
57
|
+
chatId: "chat1",
|
|
58
|
+
schedule: "0 * * * *",
|
|
59
|
+
type: "message" as const,
|
|
60
|
+
content: "hello",
|
|
61
|
+
name: "test",
|
|
62
|
+
enabled: true,
|
|
63
|
+
createdAt: Date.now(),
|
|
64
|
+
runCount: 0,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// addCronJob marks dirty and calls save() which will throw
|
|
68
|
+
addCronJob(job);
|
|
69
|
+
|
|
70
|
+
expect(logErrorMock).toHaveBeenCalledWith(
|
|
71
|
+
"cron",
|
|
72
|
+
expect.stringContaining("Failed to persist"),
|
|
73
|
+
expect.any(Error),
|
|
74
|
+
);
|
|
75
|
+
expect(recordErrorMock).toHaveBeenCalledWith(
|
|
76
|
+
expect.stringContaining("Cron save failed"),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
clearInterval(((await import("../storage/cron-store.js")) as any)._timer);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── chat-settings save failure ────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe("chat-settings — save failure logs error", () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.resetModules();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("calls logError and recordError when writeFileAtomic throws", async () => {
|
|
91
|
+
const logErrorMock = vi.fn();
|
|
92
|
+
const recordErrorMock = vi.fn();
|
|
93
|
+
|
|
94
|
+
vi.doMock("../util/log.js", () => ({
|
|
95
|
+
log: vi.fn(),
|
|
96
|
+
logError: logErrorMock,
|
|
97
|
+
logWarn: vi.fn(),
|
|
98
|
+
}));
|
|
99
|
+
vi.doMock("../util/watchdog.js", () => ({
|
|
100
|
+
recordError: recordErrorMock,
|
|
101
|
+
}));
|
|
102
|
+
vi.doMock("node:fs", () => ({
|
|
103
|
+
existsSync: vi.fn(() => true),
|
|
104
|
+
readFileSync: vi.fn(() => "{}"),
|
|
105
|
+
mkdirSync: vi.fn(),
|
|
106
|
+
}));
|
|
107
|
+
vi.doMock("write-file-atomic", () => ({
|
|
108
|
+
default: {
|
|
109
|
+
sync: vi.fn(() => {
|
|
110
|
+
throw new Error("readonly fs");
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
vi.doMock("../util/paths.js", () => ({
|
|
115
|
+
files: { chatSettings: "/fake/chat-settings.json" },
|
|
116
|
+
dirs: { root: "/fake/.talon" },
|
|
117
|
+
}));
|
|
118
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
119
|
+
registerCleanup: vi.fn(),
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const { setChatModel } = await import("../storage/chat-settings.js");
|
|
123
|
+
|
|
124
|
+
// setChatModel marks dirty and calls save()
|
|
125
|
+
setChatModel("chat99", "claude-opus-4-6");
|
|
126
|
+
|
|
127
|
+
expect(logErrorMock).toHaveBeenCalledWith(
|
|
128
|
+
"settings",
|
|
129
|
+
expect.stringContaining("Failed to persist"),
|
|
130
|
+
expect.any(Error),
|
|
131
|
+
);
|
|
132
|
+
expect(recordErrorMock).toHaveBeenCalledWith(
|
|
133
|
+
expect.stringContaining("Settings save failed"),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("chat-settings — non-Error thrown in save (line 96 FALSE branch)", () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
vi.resetModules();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("records error with String(err) when non-Error is thrown", async () => {
|
|
144
|
+
const recordErrorMock = vi.fn();
|
|
145
|
+
|
|
146
|
+
vi.doMock("../util/log.js", () => ({
|
|
147
|
+
log: vi.fn(),
|
|
148
|
+
logError: vi.fn(),
|
|
149
|
+
logWarn: vi.fn(),
|
|
150
|
+
}));
|
|
151
|
+
vi.doMock("../util/watchdog.js", () => ({
|
|
152
|
+
recordError: recordErrorMock,
|
|
153
|
+
}));
|
|
154
|
+
vi.doMock("node:fs", () => ({
|
|
155
|
+
existsSync: vi.fn(() => true),
|
|
156
|
+
readFileSync: vi.fn(() => "{}"),
|
|
157
|
+
mkdirSync: vi.fn(),
|
|
158
|
+
}));
|
|
159
|
+
vi.doMock("write-file-atomic", () => ({
|
|
160
|
+
default: {
|
|
161
|
+
sync: vi.fn(() => {
|
|
162
|
+
throw "plain string chat-settings error";
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
vi.doMock("../util/paths.js", () => ({
|
|
167
|
+
files: { chatSettings: "/fake/chat-settings.json" },
|
|
168
|
+
dirs: { root: "/fake/.talon" },
|
|
169
|
+
}));
|
|
170
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
171
|
+
registerCleanup: vi.fn(),
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
const { setChatModel } = await import("../storage/chat-settings.js");
|
|
175
|
+
setChatModel("chat-nonError", "claude-opus-4-6");
|
|
176
|
+
|
|
177
|
+
expect(recordErrorMock).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining("plain string chat-settings error"),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── sessions save failure ─────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe("sessions — save failure logs error", () => {
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
vi.resetModules();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("calls logError and recordError when writeFileAtomic throws", async () => {
|
|
191
|
+
const logErrorMock = vi.fn();
|
|
192
|
+
const recordErrorMock = vi.fn();
|
|
193
|
+
|
|
194
|
+
vi.doMock("../util/log.js", () => ({
|
|
195
|
+
log: vi.fn(),
|
|
196
|
+
logError: logErrorMock,
|
|
197
|
+
logWarn: vi.fn(),
|
|
198
|
+
}));
|
|
199
|
+
vi.doMock("../util/watchdog.js", () => ({
|
|
200
|
+
recordError: recordErrorMock,
|
|
201
|
+
}));
|
|
202
|
+
vi.doMock("node:fs", () => ({
|
|
203
|
+
existsSync: vi.fn(() => true),
|
|
204
|
+
readFileSync: vi.fn(() => "{}"),
|
|
205
|
+
mkdirSync: vi.fn(),
|
|
206
|
+
}));
|
|
207
|
+
vi.doMock("write-file-atomic", () => ({
|
|
208
|
+
default: {
|
|
209
|
+
sync: vi.fn(() => {
|
|
210
|
+
throw new Error("ENOSPC: no space left");
|
|
211
|
+
}),
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
vi.doMock("../util/paths.js", () => ({
|
|
215
|
+
files: { sessions: "/fake/sessions.json" },
|
|
216
|
+
dirs: { root: "/fake/.talon", data: "/fake/.talon/data" },
|
|
217
|
+
}));
|
|
218
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
219
|
+
registerCleanup: vi.fn(),
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
const { getSession, flushSessions } =
|
|
223
|
+
await import("../storage/sessions.js");
|
|
224
|
+
|
|
225
|
+
// getSession creates a new session (marks dirty) then flushSessions forces save
|
|
226
|
+
getSession("chat-save-fail");
|
|
227
|
+
flushSessions();
|
|
228
|
+
|
|
229
|
+
expect(logErrorMock).toHaveBeenCalledWith(
|
|
230
|
+
"sessions",
|
|
231
|
+
expect.stringContaining("Failed to persist"),
|
|
232
|
+
expect.any(Error),
|
|
233
|
+
);
|
|
234
|
+
expect(recordErrorMock).toHaveBeenCalledWith(
|
|
235
|
+
expect.stringContaining("Session save failed"),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ── sessions — migration of totalResponseMs / lastResponseMs ─────────────
|
|
241
|
+
|
|
242
|
+
describe("sessions — migration paths for usage fields", () => {
|
|
243
|
+
beforeEach(() => {
|
|
244
|
+
vi.resetModules();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("sets totalResponseMs to 0 when undefined in stored session", async () => {
|
|
248
|
+
const partialUsage = {
|
|
249
|
+
totalInputTokens: 10,
|
|
250
|
+
totalOutputTokens: 5,
|
|
251
|
+
totalCacheRead: 0,
|
|
252
|
+
totalCacheWrite: 0,
|
|
253
|
+
lastPromptTokens: 0,
|
|
254
|
+
estimatedCostUsd: 0,
|
|
255
|
+
// totalResponseMs intentionally omitted
|
|
256
|
+
lastResponseMs: 0,
|
|
257
|
+
fastestResponseMs: Infinity,
|
|
258
|
+
};
|
|
259
|
+
const stored = {
|
|
260
|
+
"mig-chat": {
|
|
261
|
+
sessionId: undefined,
|
|
262
|
+
turns: 1,
|
|
263
|
+
lastActive: Date.now(),
|
|
264
|
+
createdAt: Date.now(),
|
|
265
|
+
usage: partialUsage,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
vi.doMock("../util/log.js", () => ({
|
|
270
|
+
log: vi.fn(),
|
|
271
|
+
logError: vi.fn(),
|
|
272
|
+
logWarn: vi.fn(),
|
|
273
|
+
}));
|
|
274
|
+
vi.doMock("../util/watchdog.js", () => ({ recordError: vi.fn() }));
|
|
275
|
+
vi.doMock("node:fs", () => ({
|
|
276
|
+
existsSync: vi.fn(() => true),
|
|
277
|
+
readFileSync: vi.fn(() => JSON.stringify(stored)),
|
|
278
|
+
mkdirSync: vi.fn(),
|
|
279
|
+
}));
|
|
280
|
+
vi.doMock("write-file-atomic", () => ({ default: { sync: vi.fn() } }));
|
|
281
|
+
vi.doMock("../util/paths.js", () => ({
|
|
282
|
+
files: { sessions: "/fake/sessions.json" },
|
|
283
|
+
dirs: { root: "/fake/.talon", data: "/fake/.talon/data" },
|
|
284
|
+
}));
|
|
285
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
286
|
+
registerCleanup: vi.fn(),
|
|
287
|
+
}));
|
|
288
|
+
|
|
289
|
+
const { loadSessions, getSession } = await import("../storage/sessions.js");
|
|
290
|
+
loadSessions();
|
|
291
|
+
const session = getSession("mig-chat");
|
|
292
|
+
expect(session.usage.totalResponseMs).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("sets lastResponseMs to 0 when undefined in stored session", async () => {
|
|
296
|
+
const partialUsage = {
|
|
297
|
+
totalInputTokens: 10,
|
|
298
|
+
totalOutputTokens: 5,
|
|
299
|
+
totalCacheRead: 0,
|
|
300
|
+
totalCacheWrite: 0,
|
|
301
|
+
lastPromptTokens: 0,
|
|
302
|
+
estimatedCostUsd: 0,
|
|
303
|
+
totalResponseMs: 100,
|
|
304
|
+
// lastResponseMs intentionally omitted
|
|
305
|
+
fastestResponseMs: Infinity,
|
|
306
|
+
};
|
|
307
|
+
const stored = {
|
|
308
|
+
"mig-chat-2": {
|
|
309
|
+
sessionId: undefined,
|
|
310
|
+
turns: 1,
|
|
311
|
+
lastActive: Date.now(),
|
|
312
|
+
createdAt: Date.now(),
|
|
313
|
+
usage: partialUsage,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
vi.doMock("../util/log.js", () => ({
|
|
318
|
+
log: vi.fn(),
|
|
319
|
+
logError: vi.fn(),
|
|
320
|
+
logWarn: vi.fn(),
|
|
321
|
+
}));
|
|
322
|
+
vi.doMock("../util/watchdog.js", () => ({ recordError: vi.fn() }));
|
|
323
|
+
vi.doMock("node:fs", () => ({
|
|
324
|
+
existsSync: vi.fn(() => true),
|
|
325
|
+
readFileSync: vi.fn(() => JSON.stringify(stored)),
|
|
326
|
+
mkdirSync: vi.fn(),
|
|
327
|
+
}));
|
|
328
|
+
vi.doMock("write-file-atomic", () => ({ default: { sync: vi.fn() } }));
|
|
329
|
+
vi.doMock("../util/paths.js", () => ({
|
|
330
|
+
files: { sessions: "/fake/sessions.json" },
|
|
331
|
+
dirs: { root: "/fake/.talon", data: "/fake/.talon/data" },
|
|
332
|
+
}));
|
|
333
|
+
vi.doMock("../util/cleanup-registry.js", () => ({
|
|
334
|
+
registerCleanup: vi.fn(),
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
const { loadSessions, getSession } = await import("../storage/sessions.js");
|
|
338
|
+
loadSessions();
|
|
339
|
+
const session = getSession("mig-chat-2");
|
|
340
|
+
expect(session.usage.lastResponseMs).toBe(0);
|
|
341
|
+
});
|
|
342
|
+
});
|