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
@@ -0,0 +1,775 @@
1
+ /**
2
+ * Extended tests for src/storage/history.ts
3
+ *
4
+ * Covers persistence paths (loadHistory, saveHistory backup creation),
5
+ * MAX_CHAT_COUNT eviction, and a thorough re-test of every public function
6
+ * with an emphasis on edge cases not covered by history.test.ts.
7
+ *
8
+ * The module uses write-file-atomic and node:fs — both are mocked so no
9
+ * real disk I/O occurs.
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from "vitest";
13
+
14
+ // ── Mocks (must come before any dynamic import) ───────────────────────────
15
+
16
+ vi.mock("../util/log.js", () => ({
17
+ log: vi.fn(),
18
+ logError: vi.fn(),
19
+ logWarn: vi.fn(),
20
+ }));
21
+
22
+ const existsSyncMock = vi.fn(() => false);
23
+ const readFileSyncMock = vi.fn(() => "{}");
24
+ const mkdirSyncMock = vi.fn();
25
+
26
+ vi.mock("node:fs", () => ({
27
+ existsSync: existsSyncMock,
28
+ readFileSync: readFileSyncMock,
29
+ writeFileSync: vi.fn(),
30
+ mkdirSync: mkdirSyncMock,
31
+ }));
32
+
33
+ const writeFileAtomicSyncMock = vi.fn();
34
+ vi.mock("write-file-atomic", () => ({
35
+ default: { sync: writeFileAtomicSyncMock },
36
+ }));
37
+
38
+ // Mock cleanup-registry so we don't register real process.on listeners
39
+ vi.mock("../util/cleanup-registry.js", () => ({
40
+ registerCleanup: vi.fn(),
41
+ }));
42
+
43
+ // Mock paths so we get a stable path string in assertions
44
+ vi.mock("../util/paths.js", () => ({
45
+ files: { history: "/mock/data/history.json" },
46
+ dirs: {},
47
+ }));
48
+
49
+ // ── Dynamic import after mocks ────────────────────────────────────────────
50
+
51
+ import type { HistoryMessage } from "../storage/history.js";
52
+
53
+ const {
54
+ pushMessage,
55
+ getRecentHistory,
56
+ getHistoryStats,
57
+ getKnownUsers,
58
+ getMessageById,
59
+ getRecentBySenderId,
60
+ getMessagesByUser,
61
+ searchHistory,
62
+ clearHistory,
63
+ setMessageFilePath,
64
+ getLatestMessageId,
65
+ loadHistory,
66
+ flushHistory,
67
+ } = await import("../storage/history.js");
68
+
69
+ // ── Helpers ───────────────────────────────────────────────────────────────
70
+
71
+ let _chatSeq = 0;
72
+ function uniqueChat(): string {
73
+ return `ext-hist-${++_chatSeq}-${Math.random().toString(36).slice(2, 7)}`;
74
+ }
75
+
76
+ function makeMsg(
77
+ overrides: Partial<HistoryMessage> & { msgId: number },
78
+ ): HistoryMessage {
79
+ return {
80
+ senderId: 1,
81
+ senderName: "User",
82
+ text: `Message ${overrides.msgId}`,
83
+ timestamp: Date.now(),
84
+ ...overrides,
85
+ };
86
+ }
87
+
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ });
91
+
92
+ // ── pushMessage — eviction when MAX_CHAT_COUNT (1000) is reached ──────────
93
+
94
+ describe("pushMessage — MAX_CHAT_COUNT eviction", () => {
95
+ it("evicts ~10% of oldest chats when the 1001st unique chat is added", () => {
96
+ // Use a fresh set of unique chat IDs to avoid contamination from other tests.
97
+ // We only push 20 chats to verify the eviction logic fires; the real threshold
98
+ // is 1000 but we can't afford to create that many in a test.
99
+ // Instead, test the observable side-effect: after a run that crosses the limit,
100
+ // some early chats are gone and the new chat is present.
101
+ //
102
+ // Because the module-level Map is shared we need to rely on the same
103
+ // unique IDs the module does — just verify the existing test contract.
104
+
105
+ const prefix = `evict-ext-${Date.now()}-`;
106
+ for (let i = 0; i < 1001; i++) {
107
+ pushMessage(`${prefix}${i}`, makeMsg({ msgId: 1 }));
108
+ }
109
+
110
+ // The 1001st chat should definitely be present
111
+ expect(getRecentHistory(`${prefix}1000`)).toHaveLength(1);
112
+
113
+ // At least one of the earliest chats should have been evicted
114
+ let evicted = 0;
115
+ for (let i = 0; i < 200; i++) {
116
+ if (getRecentHistory(`${prefix}${i}`).length === 0) evicted++;
117
+ }
118
+ expect(evicted).toBeGreaterThan(0);
119
+ // Approximately 100 chats should have been evicted (10% of 1000)
120
+ expect(evicted).toBeGreaterThanOrEqual(50);
121
+ });
122
+
123
+ it("marks dirty when eviction occurs", () => {
124
+ // We check that after adding 1001 chats and then flushing, writeFileAtomicSync
125
+ // is called (which only happens when dirty === true).
126
+ const prefix = `dirty-evict-${Date.now()}-`;
127
+ for (let i = 0; i < 1001; i++) {
128
+ pushMessage(`${prefix}${i}`, makeMsg({ msgId: 1 }));
129
+ }
130
+
131
+ writeFileAtomicSyncMock.mockClear();
132
+ existsSyncMock.mockReturnValue(false);
133
+ flushHistory();
134
+ expect(writeFileAtomicSyncMock).toHaveBeenCalled();
135
+ });
136
+ });
137
+
138
+ // ── getHistoryStats ────────────────────────────────────────────────────────
139
+
140
+ describe("getHistoryStats", () => {
141
+ it("returns correct stats for a single message", () => {
142
+ const id = uniqueChat();
143
+ const ts = Date.now();
144
+ pushMessage(id, makeMsg({ msgId: 1, senderId: 42, timestamp: ts }));
145
+
146
+ const stats = getHistoryStats(id);
147
+ expect(stats.totalMessages).toBe(1);
148
+ expect(stats.uniqueUsers).toBe(1);
149
+ expect(stats.oldestTimestamp).toBe(ts);
150
+ expect(stats.newestTimestamp).toBe(ts);
151
+ });
152
+
153
+ it("counts unique users correctly with repeated sender", () => {
154
+ const id = uniqueChat();
155
+ const ts = Date.now();
156
+ pushMessage(id, makeMsg({ msgId: 1, senderId: 10, timestamp: ts - 2000 }));
157
+ pushMessage(id, makeMsg({ msgId: 2, senderId: 10, timestamp: ts - 1000 }));
158
+ pushMessage(id, makeMsg({ msgId: 3, senderId: 20, timestamp: ts }));
159
+
160
+ const stats = getHistoryStats(id);
161
+ expect(stats.totalMessages).toBe(3);
162
+ expect(stats.uniqueUsers).toBe(2);
163
+ expect(stats.oldestTimestamp).toBe(ts - 2000);
164
+ expect(stats.newestTimestamp).toBe(ts);
165
+ });
166
+
167
+ it("returns zeroes for non-existent chat", () => {
168
+ const stats = getHistoryStats("does-not-exist-stats-xyz");
169
+ expect(stats.totalMessages).toBe(0);
170
+ expect(stats.uniqueUsers).toBe(0);
171
+ expect(stats.oldestTimestamp).toBe(0);
172
+ expect(stats.newestTimestamp).toBe(0);
173
+ });
174
+
175
+ it("returns zeroes after clearHistory", () => {
176
+ const id = uniqueChat();
177
+ pushMessage(id, makeMsg({ msgId: 1, senderId: 1 }));
178
+ clearHistory(id);
179
+
180
+ const stats = getHistoryStats(id);
181
+ expect(stats.totalMessages).toBe(0);
182
+ expect(stats.uniqueUsers).toBe(0);
183
+ expect(stats.oldestTimestamp).toBe(0);
184
+ expect(stats.newestTimestamp).toBe(0);
185
+ });
186
+ });
187
+
188
+ // ── getKnownUsers ─────────────────────────────────────────────────────────
189
+
190
+ describe("getKnownUsers", () => {
191
+ it("returns sorted list by last seen (most recent first)", () => {
192
+ const id = uniqueChat();
193
+ const now = Date.now();
194
+ pushMessage(
195
+ id,
196
+ makeMsg({
197
+ msgId: 1,
198
+ senderId: 100,
199
+ senderName: "Alpha",
200
+ timestamp: now - 3600_000,
201
+ }),
202
+ );
203
+ pushMessage(
204
+ id,
205
+ makeMsg({
206
+ msgId: 2,
207
+ senderId: 200,
208
+ senderName: "Beta",
209
+ timestamp: now - 60_000,
210
+ }),
211
+ );
212
+ pushMessage(
213
+ id,
214
+ makeMsg({
215
+ msgId: 3,
216
+ senderId: 300,
217
+ senderName: "Gamma",
218
+ timestamp: now - 1_000,
219
+ }),
220
+ );
221
+
222
+ const result = getKnownUsers(id);
223
+ const gammaIdx = result.indexOf("Gamma");
224
+ const betaIdx = result.indexOf("Beta");
225
+ const alphaIdx = result.indexOf("Alpha");
226
+
227
+ expect(gammaIdx).toBeLessThan(betaIdx);
228
+ expect(betaIdx).toBeLessThan(alphaIdx);
229
+ });
230
+
231
+ it("includes message counts for each user", () => {
232
+ const id = uniqueChat();
233
+ for (let i = 0; i < 4; i++) {
234
+ pushMessage(
235
+ id,
236
+ makeMsg({
237
+ msgId: i,
238
+ senderId: 10,
239
+ senderName: "Active",
240
+ timestamp: Date.now() + i,
241
+ }),
242
+ );
243
+ }
244
+ pushMessage(
245
+ id,
246
+ makeMsg({
247
+ msgId: 99,
248
+ senderId: 20,
249
+ senderName: "Lurker",
250
+ timestamp: Date.now(),
251
+ }),
252
+ );
253
+
254
+ const result = getKnownUsers(id);
255
+ expect(result).toContain("4 msgs"); // Active
256
+ expect(result).toContain("1 msgs"); // Lurker
257
+ });
258
+
259
+ it("includes user_id in output", () => {
260
+ const id = uniqueChat();
261
+ pushMessage(
262
+ id,
263
+ makeMsg({ msgId: 1, senderId: 55555, senderName: "Identified" }),
264
+ );
265
+ expect(getKnownUsers(id)).toContain("user_id: 55555");
266
+ });
267
+
268
+ it("returns 'No users seen yet.' for cleared chat", () => {
269
+ const id = uniqueChat();
270
+ pushMessage(id, makeMsg({ msgId: 1, senderId: 1 }));
271
+ clearHistory(id);
272
+ expect(getKnownUsers(id)).toBe("No users seen yet.");
273
+ });
274
+ });
275
+
276
+ // ── getMessageById ────────────────────────────────────────────────────────
277
+
278
+ describe("getMessageById", () => {
279
+ it("finds and formats the correct message", () => {
280
+ const id = uniqueChat();
281
+ pushMessage(
282
+ id,
283
+ makeMsg({ msgId: 77, senderName: "Alice", text: "find me" }),
284
+ );
285
+ pushMessage(id, makeMsg({ msgId: 78, senderName: "Bob", text: "other" }));
286
+
287
+ const result = getMessageById(id, 77);
288
+ expect(result).toContain("msg:77");
289
+ expect(result).toContain("Alice");
290
+ expect(result).toContain("find me");
291
+ });
292
+
293
+ it("returns 'not found' message for missing msgId", () => {
294
+ const id = uniqueChat();
295
+ pushMessage(id, makeMsg({ msgId: 1 }));
296
+
297
+ const result = getMessageById(id, 9999);
298
+ expect(result).toContain("9999");
299
+ expect(result).toContain("not found");
300
+ });
301
+
302
+ it("returns 'No messages in history.' for empty chat", () => {
303
+ const result = getMessageById("empty-byid", 1);
304
+ expect(result).toContain("No messages in history");
305
+ });
306
+
307
+ it("finds message after setMessageFilePath update", () => {
308
+ const id = uniqueChat();
309
+ pushMessage(id, makeMsg({ msgId: 5, text: "with file" }));
310
+ setMessageFilePath(id, 5, "/tmp/some-file.pdf");
311
+
312
+ const result = getMessageById(id, 5);
313
+ expect(result).toContain("(file: /tmp/some-file.pdf)");
314
+ });
315
+ });
316
+
317
+ // ── getRecentBySenderId ───────────────────────────────────────────────────
318
+
319
+ describe("getRecentBySenderId", () => {
320
+ it("filters messages to the given sender", () => {
321
+ const id = uniqueChat();
322
+ pushMessage(id, makeMsg({ msgId: 1, senderId: 10, senderName: "A" }));
323
+ pushMessage(id, makeMsg({ msgId: 2, senderId: 20, senderName: "B" }));
324
+ pushMessage(id, makeMsg({ msgId: 3, senderId: 10, senderName: "A" }));
325
+
326
+ const result = getRecentBySenderId(id, 10);
327
+ expect(result).toHaveLength(2);
328
+ expect(result.every((m) => m.senderId === 10)).toBe(true);
329
+ });
330
+
331
+ it("returns empty array when sender has no messages", () => {
332
+ const id = uniqueChat();
333
+ pushMessage(id, makeMsg({ msgId: 1, senderId: 10 }));
334
+ expect(getRecentBySenderId(id, 99)).toEqual([]);
335
+ });
336
+
337
+ it("returns empty array for non-existent chat", () => {
338
+ expect(getRecentBySenderId("ghost-chat-sender", 1)).toEqual([]);
339
+ });
340
+
341
+ it("respects the limit parameter (returns most recent N)", () => {
342
+ const id = uniqueChat();
343
+ for (let i = 1; i <= 10; i++) {
344
+ pushMessage(id, makeMsg({ msgId: i, senderId: 5 }));
345
+ }
346
+ const result = getRecentBySenderId(id, 5, 3);
347
+ expect(result).toHaveLength(3);
348
+ // Most recent 3 are msgId 8, 9, 10
349
+ expect(result[0].msgId).toBe(8);
350
+ expect(result[2].msgId).toBe(10);
351
+ });
352
+ });
353
+
354
+ // ── getMessagesByUser ─────────────────────────────────────────────────────
355
+
356
+ describe("getMessagesByUser", () => {
357
+ it("filters by case-insensitive substring match on senderName", () => {
358
+ const id = uniqueChat();
359
+ pushMessage(
360
+ id,
361
+ makeMsg({ msgId: 1, senderName: "Charlie Brown", text: "peanuts" }),
362
+ );
363
+ pushMessage(
364
+ id,
365
+ makeMsg({ msgId: 2, senderName: "Lucy Van Pelt", text: "football" }),
366
+ );
367
+
368
+ const result = getMessagesByUser(id, "charlie");
369
+ expect(result).toContain("peanuts");
370
+ expect(result).not.toContain("football");
371
+ });
372
+
373
+ it("returns 'No messages from' for unmatched user", () => {
374
+ const id = uniqueChat();
375
+ pushMessage(id, makeMsg({ msgId: 1, senderName: "Alice" }));
376
+ expect(getMessagesByUser(id, "Zorro")).toContain(
377
+ 'No messages from "Zorro"',
378
+ );
379
+ });
380
+
381
+ it("returns 'No messages in history' for empty chat", () => {
382
+ expect(getMessagesByUser("empty-usr-chat", "anyone")).toContain(
383
+ "No messages in history",
384
+ );
385
+ });
386
+
387
+ it("partial name match returns all matching messages", () => {
388
+ const id = uniqueChat();
389
+ pushMessage(id, makeMsg({ msgId: 1, senderName: "Bob Smith" }));
390
+ pushMessage(id, makeMsg({ msgId: 2, senderName: "Bob Jones" }));
391
+ pushMessage(id, makeMsg({ msgId: 3, senderName: "Alice" }));
392
+
393
+ const result = getMessagesByUser(id, "bob");
394
+ expect(result).toContain("Bob Smith");
395
+ expect(result).toContain("Bob Jones");
396
+ expect(result).not.toContain("Alice");
397
+ });
398
+ });
399
+
400
+ // ── searchHistory ─────────────────────────────────────────────────────────
401
+
402
+ describe("searchHistory", () => {
403
+ it("returns proper 'no matches' message when nothing matches", () => {
404
+ const id = uniqueChat();
405
+ pushMessage(id, makeMsg({ msgId: 1, text: "hello world" }));
406
+
407
+ const result = searchHistory(id, "xyzzy_no_match");
408
+ expect(result).toContain("No messages matching");
409
+ expect(result).toContain("xyzzy_no_match");
410
+ });
411
+
412
+ it("matches by sender name (case-insensitive)", () => {
413
+ const id = uniqueChat();
414
+ pushMessage(
415
+ id,
416
+ makeMsg({ msgId: 1, senderName: "Sherlock Holmes", text: "elementary" }),
417
+ );
418
+ pushMessage(
419
+ id,
420
+ makeMsg({ msgId: 2, senderName: "Dr Watson", text: "quite so" }),
421
+ );
422
+
423
+ const result = searchHistory(id, "sherlock");
424
+ expect(result).toContain("elementary");
425
+ expect(result).not.toContain("quite so");
426
+ });
427
+
428
+ it("returns 'No messages in history' for chat with no messages", () => {
429
+ expect(searchHistory("no-msgs-srch", "anything")).toContain(
430
+ "No messages in history",
431
+ );
432
+ });
433
+
434
+ it("matches are returned in chronological order (last N)", () => {
435
+ const id = uniqueChat();
436
+ for (let i = 1; i <= 5; i++) {
437
+ pushMessage(id, makeMsg({ msgId: i, text: `match ${i}` }));
438
+ }
439
+ const result = searchHistory(id, "match", 3);
440
+ const lines = result.split("\n");
441
+ // Last 3 matches: msgIds 3, 4, 5
442
+ expect(lines).toHaveLength(3);
443
+ expect(lines[2]).toContain("match 5");
444
+ });
445
+ });
446
+
447
+ // ── clearHistory ──────────────────────────────────────────────────────────
448
+
449
+ describe("clearHistory", () => {
450
+ it("removes messages and subsequent getRecentHistory returns []", () => {
451
+ const id = uniqueChat();
452
+ pushMessage(id, makeMsg({ msgId: 1 }));
453
+ pushMessage(id, makeMsg({ msgId: 2 }));
454
+ clearHistory(id);
455
+ expect(getRecentHistory(id)).toEqual([]);
456
+ });
457
+
458
+ it("marks dirty so next flush writes to disk", () => {
459
+ const id = uniqueChat();
460
+ pushMessage(id, makeMsg({ msgId: 1 }));
461
+ clearHistory(id);
462
+
463
+ writeFileAtomicSyncMock.mockClear();
464
+ existsSyncMock.mockReturnValue(false);
465
+ flushHistory();
466
+ expect(writeFileAtomicSyncMock).toHaveBeenCalled();
467
+ });
468
+
469
+ it("does not affect other chats", () => {
470
+ const id1 = uniqueChat();
471
+ const id2 = uniqueChat();
472
+ pushMessage(id1, makeMsg({ msgId: 1 }));
473
+ pushMessage(id2, makeMsg({ msgId: 2 }));
474
+ clearHistory(id1);
475
+
476
+ expect(getRecentHistory(id1)).toEqual([]);
477
+ expect(getRecentHistory(id2)).toHaveLength(1);
478
+ });
479
+ });
480
+
481
+ // ── setMessageFilePath ────────────────────────────────────────────────────
482
+
483
+ describe("setMessageFilePath", () => {
484
+ it("updates filePath on an existing message and marks dirty", () => {
485
+ const id = uniqueChat();
486
+ pushMessage(id, makeMsg({ msgId: 10, text: "photo msg" }));
487
+ setMessageFilePath(id, 10, "/home/user/photo.jpg");
488
+
489
+ const history = getRecentHistory(id);
490
+ expect(history[0].filePath).toBe("/home/user/photo.jpg");
491
+ });
492
+
493
+ it("is a no-op for a chat that does not exist", () => {
494
+ expect(() =>
495
+ setMessageFilePath("ghost-chat", 1, "/tmp/x.jpg"),
496
+ ).not.toThrow();
497
+ });
498
+
499
+ it("is a no-op when msgId is not found", () => {
500
+ const id = uniqueChat();
501
+ pushMessage(id, makeMsg({ msgId: 1 }));
502
+ setMessageFilePath(id, 999, "/tmp/nope.jpg");
503
+
504
+ const history = getRecentHistory(id);
505
+ expect(history[0].filePath).toBeUndefined();
506
+ });
507
+
508
+ it("overwrites an existing filePath", () => {
509
+ const id = uniqueChat();
510
+ pushMessage(id, makeMsg({ msgId: 1, filePath: "/old/path.png" }));
511
+ setMessageFilePath(id, 1, "/new/path.png");
512
+
513
+ expect(getRecentHistory(id)[0].filePath).toBe("/new/path.png");
514
+ });
515
+ });
516
+
517
+ // ── getLatestMessageId ────────────────────────────────────────────────────
518
+
519
+ describe("getLatestMessageId", () => {
520
+ it("returns the msgId of the last pushed message", () => {
521
+ const id = uniqueChat();
522
+ pushMessage(id, makeMsg({ msgId: 100 }));
523
+ pushMessage(id, makeMsg({ msgId: 200 }));
524
+ pushMessage(id, makeMsg({ msgId: 150 })); // out of order is fine
525
+
526
+ expect(getLatestMessageId(id)).toBe(150);
527
+ });
528
+
529
+ it("returns undefined for a chat with no messages", () => {
530
+ expect(getLatestMessageId("empty-latest-chat")).toBeUndefined();
531
+ });
532
+
533
+ it("returns undefined after clearHistory", () => {
534
+ const id = uniqueChat();
535
+ pushMessage(id, makeMsg({ msgId: 1 }));
536
+ clearHistory(id);
537
+ expect(getLatestMessageId(id)).toBeUndefined();
538
+ });
539
+
540
+ it("reflects the most recently added message", () => {
541
+ const id = uniqueChat();
542
+ pushMessage(id, makeMsg({ msgId: 1 }));
543
+ expect(getLatestMessageId(id)).toBe(1);
544
+ pushMessage(id, makeMsg({ msgId: 2 }));
545
+ expect(getLatestMessageId(id)).toBe(2);
546
+ });
547
+ });
548
+
549
+ // ── loadHistory — persistence paths ──────────────────────────────────────
550
+
551
+ describe("loadHistory — persistence", () => {
552
+ it("does not throw when the store file does not exist", () => {
553
+ existsSyncMock.mockReturnValue(false);
554
+ expect(() => loadHistory()).not.toThrow();
555
+ });
556
+
557
+ it("loads messages from a valid JSON file", () => {
558
+ const stored = {
559
+ "persist-chat-1": [
560
+ {
561
+ msgId: 1,
562
+ senderId: 1,
563
+ senderName: "Test",
564
+ text: "hello",
565
+ timestamp: 1000,
566
+ },
567
+ ],
568
+ };
569
+ existsSyncMock.mockReturnValueOnce(true);
570
+ readFileSyncMock.mockReturnValueOnce(JSON.stringify(stored));
571
+
572
+ loadHistory();
573
+
574
+ const history = getRecentHistory("persist-chat-1");
575
+ expect(history).toHaveLength(1);
576
+ expect(history[0].text).toBe("hello");
577
+ });
578
+
579
+ it("falls back to backup file when primary is corrupt", () => {
580
+ const backup = {
581
+ "backup-chat": [
582
+ {
583
+ msgId: 99,
584
+ senderId: 1,
585
+ senderName: "Backup",
586
+ text: "from backup",
587
+ timestamp: 2000,
588
+ },
589
+ ],
590
+ };
591
+ // First existsSync call: primary exists; readFileSync returns corrupt data.
592
+ // Second existsSync call: backup exists; readFileSync returns valid data.
593
+ existsSyncMock
594
+ .mockReturnValueOnce(true) // primary file exists
595
+ .mockReturnValueOnce(true); // backup file exists
596
+ readFileSyncMock
597
+ .mockReturnValueOnce("{{corrupt json}}") // primary read fails
598
+ .mockReturnValueOnce(JSON.stringify(backup)); // backup read succeeds
599
+
600
+ expect(() => loadHistory()).not.toThrow();
601
+ });
602
+
603
+ it("handles corrupt JSON in primary without throwing", () => {
604
+ existsSyncMock.mockReturnValueOnce(true).mockReturnValue(false);
605
+ readFileSyncMock.mockReturnValueOnce("not json at all!!!");
606
+
607
+ expect(() => loadHistory()).not.toThrow();
608
+ });
609
+
610
+ it("caps loaded messages to MAX_HISTORY_PER_CHAT (500)", () => {
611
+ const msgs = Array.from({ length: 600 }, (_, i) => ({
612
+ msgId: i,
613
+ senderId: 1,
614
+ senderName: "User",
615
+ text: `msg ${i}`,
616
+ timestamp: Date.now() + i,
617
+ }));
618
+ existsSyncMock.mockReturnValueOnce(true);
619
+ readFileSyncMock.mockReturnValueOnce(
620
+ JSON.stringify({ "cap-test-chat": msgs }),
621
+ );
622
+
623
+ loadHistory();
624
+
625
+ const history = getRecentHistory("cap-test-chat", 1000);
626
+ expect(history.length).toBeLessThanOrEqual(500);
627
+ });
628
+ });
629
+
630
+ // ── flushHistory ──────────────────────────────────────────────────────────
631
+
632
+ describe("flushHistory", () => {
633
+ it("calls writeFileAtomic.sync at least once", () => {
634
+ const id = uniqueChat();
635
+ pushMessage(id, makeMsg({ msgId: 1 }));
636
+
637
+ writeFileAtomicSyncMock.mockClear();
638
+ existsSyncMock.mockReturnValue(false);
639
+ flushHistory();
640
+ expect(writeFileAtomicSyncMock).toHaveBeenCalled();
641
+ });
642
+
643
+ it("creates the directory when it does not exist", () => {
644
+ const id = uniqueChat();
645
+ pushMessage(id, makeMsg({ msgId: 1 }));
646
+
647
+ mkdirSyncMock.mockClear();
648
+ existsSyncMock.mockReturnValue(false);
649
+ flushHistory();
650
+ expect(mkdirSyncMock).toHaveBeenCalled();
651
+ });
652
+
653
+ it("writes a JSON blob containing the chat data", () => {
654
+ const id = `flush-check-${Date.now()}`;
655
+ pushMessage(
656
+ id,
657
+ makeMsg({ msgId: 42, senderName: "FlushUser", text: "flush me" }),
658
+ );
659
+
660
+ writeFileAtomicSyncMock.mockClear();
661
+ existsSyncMock.mockReturnValue(false);
662
+ flushHistory();
663
+
664
+ const calls = writeFileAtomicSyncMock.mock.calls;
665
+ // At least one call should be the actual data write (not .bak)
666
+ const dataCall = calls.find((c) => !String(c[0]).endsWith(".bak"));
667
+ expect(dataCall).toBeDefined();
668
+ const written = JSON.parse(dataCall![1] as string);
669
+ expect(written[id]).toBeDefined();
670
+ expect(written[id][0].msgId).toBe(42);
671
+ });
672
+
673
+ it("writes a backup of the current file before overwriting when file exists", () => {
674
+ const id = uniqueChat();
675
+ pushMessage(id, makeMsg({ msgId: 1 }));
676
+
677
+ writeFileAtomicSyncMock.mockClear();
678
+ // Simulate existing file
679
+ existsSyncMock.mockReturnValue(true);
680
+ readFileSyncMock.mockReturnValue('{"old": "data"}');
681
+ flushHistory();
682
+
683
+ // One of the writeFileAtomic calls should be for the .bak file
684
+ const bakCall = writeFileAtomicSyncMock.mock.calls.find((c) =>
685
+ String(c[0]).endsWith(".bak"),
686
+ );
687
+ expect(bakCall).toBeDefined();
688
+ });
689
+ });
690
+
691
+ // ── saveHistory dirty=false early return ─────────────────────────────────
692
+
693
+ describe("history — saveHistory dirty=false early return (line 79 TRUE branch)", () => {
694
+ it("does not write when auto-save fires with dirty=false", async () => {
695
+ vi.resetModules();
696
+ vi.useFakeTimers();
697
+ const wfaMock = vi.fn();
698
+ vi.doMock("../util/log.js", () => ({
699
+ log: vi.fn(),
700
+ logError: vi.fn(),
701
+ logWarn: vi.fn(),
702
+ }));
703
+ vi.doMock("../util/watchdog.js", () => ({ recordError: vi.fn() }));
704
+ vi.doMock("node:fs", () => ({
705
+ existsSync: vi.fn(() => false),
706
+ mkdirSync: vi.fn(),
707
+ readFileSync: vi.fn(() => "{}"),
708
+ }));
709
+ vi.doMock("write-file-atomic", () => ({ default: { sync: wfaMock } }));
710
+ vi.doMock("../util/paths.js", () => ({
711
+ files: { history: "/fake/history.json" },
712
+ dirs: {},
713
+ }));
714
+ vi.doMock("../util/cleanup-registry.js", () => ({
715
+ registerCleanup: vi.fn(),
716
+ }));
717
+
718
+ // Fresh import: dirty=false (nothing modified yet)
719
+ await import("../storage/history.js");
720
+
721
+ // Advance 31 seconds → auto-save timer fires → saveHistory() with dirty=false → early return
722
+ await vi.advanceTimersByTimeAsync(31_000);
723
+ expect(wfaMock).not.toHaveBeenCalled();
724
+
725
+ vi.useRealTimers();
726
+ });
727
+ });
728
+
729
+ // ── saveHistory non-Error thrown ──────────────────────────────────────────
730
+
731
+ describe("history — non-Error thrown in saveHistory (line 96 FALSE branch)", () => {
732
+ it("records error with String(err) when non-Error is thrown", async () => {
733
+ vi.resetModules();
734
+ const recordErrorMock = vi.fn();
735
+ vi.doMock("../util/log.js", () => ({
736
+ log: vi.fn(),
737
+ logError: vi.fn(),
738
+ logWarn: vi.fn(),
739
+ }));
740
+ vi.doMock("../util/watchdog.js", () => ({ recordError: recordErrorMock }));
741
+ vi.doMock("node:fs", () => ({
742
+ existsSync: vi.fn(() => false),
743
+ mkdirSync: vi.fn(),
744
+ readFileSync: vi.fn(() => "{}"),
745
+ }));
746
+ vi.doMock("write-file-atomic", () => ({
747
+ default: {
748
+ sync: vi.fn(() => {
749
+ throw "plain string history error";
750
+ }),
751
+ },
752
+ }));
753
+ vi.doMock("../util/paths.js", () => ({
754
+ files: { history: "/fake/history.json" },
755
+ dirs: {},
756
+ }));
757
+ vi.doMock("../util/cleanup-registry.js", () => ({
758
+ registerCleanup: vi.fn(),
759
+ }));
760
+
761
+ const { pushMessage, flushHistory } = await import("../storage/history.js");
762
+ pushMessage("chat-err", {
763
+ msgId: 1,
764
+ senderId: 1,
765
+ senderName: "Bob",
766
+ text: "test",
767
+ timestamp: Date.now(),
768
+ });
769
+ expect(() => flushHistory()).not.toThrow();
770
+
771
+ expect(recordErrorMock).toHaveBeenCalledWith(
772
+ expect.stringContaining("plain string history error"),
773
+ );
774
+ });
775
+ });