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
@@ -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 } = await import(
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
- { msgId: 1, senderId: 42, senderName: "Alice", text: "hello", timestamp: Date.now() },
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
- { msgId: 1, senderId: 42, senderName: "Alice", text: "first message", timestamp: new Date("2025-01-01T10:00:00Z").getTime() },
51
- { msgId: 2, senderId: 42, senderName: "Alice", text: "second message", timestamp: new Date("2025-01-01T10:01:00Z").getTime() },
52
- { msgId: 3, senderId: 42, senderName: "Alice", text: "current", timestamp: new Date("2025-01-01T10:02:00Z").getTime() },
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
- { msgId: 1, senderId: 42, senderName: "Bob", text: longText, timestamp: Date.now() },
65
- { msgId: 2, senderId: 42, senderName: "Bob", text: "current", timestamp: Date.now() },
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, // 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
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
+ });