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
@@ -1,7 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
3
  vi.mock("../util/log.js", () => ({
4
- log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
4
+ log: vi.fn(),
5
+ logError: vi.fn(),
6
+ logWarn: vi.fn(),
7
+ logDebug: vi.fn(),
5
8
  }));
6
9
 
7
10
  const existsSyncMock = vi.fn(() => false);
@@ -22,7 +25,14 @@ vi.mock("write-file-atomic", () => ({
22
25
  default: { sync: (...args: unknown[]) => writeFileSyncMock(...args) },
23
26
  }));
24
27
 
25
- const { addMedia, getRecentMedia, getMediaByType, formatMediaIndex, loadMediaIndex, flushMediaIndex } = await import("../storage/media-index.js");
28
+ const {
29
+ addMedia,
30
+ getRecentMedia,
31
+ getMediaByType,
32
+ formatMediaIndex,
33
+ loadMediaIndex,
34
+ flushMediaIndex,
35
+ } = await import("../storage/media-index.js");
26
36
 
27
37
  describe("media-index", () => {
28
38
  beforeEach(() => {
@@ -35,8 +45,12 @@ describe("media-index", () => {
35
45
  it("adds and retrieves media", () => {
36
46
  const cid = `add-${Date.now()}`;
37
47
  addMedia({
38
- chatId: cid, msgId: 1, senderName: "Alice", type: "photo",
39
- filePath: "/tmp/photo.jpg", timestamp: Date.now(),
48
+ chatId: cid,
49
+ msgId: 1,
50
+ senderName: "Alice",
51
+ type: "photo",
52
+ filePath: "/tmp/photo.jpg",
53
+ timestamp: Date.now(),
40
54
  });
41
55
  const media = getRecentMedia(cid);
42
56
  expect(media).toHaveLength(1);
@@ -50,9 +64,30 @@ describe("media-index", () => {
50
64
 
51
65
  it("filters by type", () => {
52
66
  const cid = `type-${Date.now()}`;
53
- addMedia({ chatId: cid, msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
54
- addMedia({ chatId: cid, msgId: 2, senderName: "A", type: "document", filePath: "/b.pdf", timestamp: Date.now() });
55
- addMedia({ chatId: cid, msgId: 3, senderName: "A", type: "photo", filePath: "/c.jpg", timestamp: Date.now() });
67
+ addMedia({
68
+ chatId: cid,
69
+ msgId: 1,
70
+ senderName: "A",
71
+ type: "photo",
72
+ filePath: "/a.jpg",
73
+ timestamp: Date.now(),
74
+ });
75
+ addMedia({
76
+ chatId: cid,
77
+ msgId: 2,
78
+ senderName: "A",
79
+ type: "document",
80
+ filePath: "/b.pdf",
81
+ timestamp: Date.now(),
82
+ });
83
+ addMedia({
84
+ chatId: cid,
85
+ msgId: 3,
86
+ senderName: "A",
87
+ type: "photo",
88
+ filePath: "/c.jpg",
89
+ timestamp: Date.now(),
90
+ });
56
91
 
57
92
  expect(getMediaByType(cid, "photo")).toHaveLength(2);
58
93
  expect(getMediaByType(cid, "document")).toHaveLength(1);
@@ -60,8 +95,22 @@ describe("media-index", () => {
60
95
 
61
96
  it("deduplicates by chatId:msgId", () => {
62
97
  const chatId = `dedup-${Date.now()}`;
63
- addMedia({ chatId, msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: 1000 });
64
- addMedia({ chatId, msgId: 1, senderName: "A", type: "photo", filePath: "/b.jpg", timestamp: 2000 });
98
+ addMedia({
99
+ chatId,
100
+ msgId: 1,
101
+ senderName: "A",
102
+ type: "photo",
103
+ filePath: "/a.jpg",
104
+ timestamp: 1000,
105
+ });
106
+ addMedia({
107
+ chatId,
108
+ msgId: 1,
109
+ senderName: "A",
110
+ type: "photo",
111
+ filePath: "/b.jpg",
112
+ timestamp: 2000,
113
+ });
65
114
 
66
115
  const media = getRecentMedia(chatId);
67
116
  expect(media).toHaveLength(1);
@@ -69,7 +118,15 @@ describe("media-index", () => {
69
118
  });
70
119
 
71
120
  it("formats index as text", () => {
72
- addMedia({ chatId: "456", msgId: 10, senderName: "Bob", type: "photo", filePath: "/photo.jpg", caption: "sunset", timestamp: Date.now() });
121
+ addMedia({
122
+ chatId: "456",
123
+ msgId: 10,
124
+ senderName: "Bob",
125
+ type: "photo",
126
+ filePath: "/photo.jpg",
127
+ caption: "sunset",
128
+ timestamp: Date.now(),
129
+ });
73
130
  const text = formatMediaIndex("456");
74
131
  expect(text).toContain("photo");
75
132
  expect(text).toContain("Bob");
@@ -83,14 +140,35 @@ describe("media-index", () => {
83
140
 
84
141
  it("limits results", () => {
85
142
  for (let i = 0; i < 15; i++) {
86
- addMedia({ chatId: "789", msgId: i, senderName: "C", type: "photo", filePath: `/p${i}.jpg`, timestamp: Date.now() + i });
143
+ addMedia({
144
+ chatId: "789",
145
+ msgId: i,
146
+ senderName: "C",
147
+ type: "photo",
148
+ filePath: `/p${i}.jpg`,
149
+ timestamp: Date.now() + i,
150
+ });
87
151
  }
88
152
  expect(getRecentMedia("789", 5)).toHaveLength(5);
89
153
  });
90
154
 
91
155
  it("returns newest first", () => {
92
- addMedia({ chatId: "100", msgId: 1, senderName: "A", type: "photo", filePath: "/old.jpg", timestamp: 1000 });
93
- addMedia({ chatId: "100", msgId: 2, senderName: "A", type: "photo", filePath: "/new.jpg", timestamp: 2000 });
156
+ addMedia({
157
+ chatId: "100",
158
+ msgId: 1,
159
+ senderName: "A",
160
+ type: "photo",
161
+ filePath: "/old.jpg",
162
+ timestamp: 1000,
163
+ });
164
+ addMedia({
165
+ chatId: "100",
166
+ msgId: 2,
167
+ senderName: "A",
168
+ type: "photo",
169
+ filePath: "/new.jpg",
170
+ timestamp: 2000,
171
+ });
94
172
 
95
173
  const media = getRecentMedia("100");
96
174
  expect(media[0].filePath).toBe("/new.jpg");
@@ -99,9 +177,24 @@ describe("media-index", () => {
99
177
  describe("addMedia with all media types", () => {
100
178
  it("supports all media type variants", () => {
101
179
  const cid = `types-${Date.now()}`;
102
- const types = ["photo", "document", "voice", "video", "animation", "audio", "sticker"] as const;
180
+ const types = [
181
+ "photo",
182
+ "document",
183
+ "voice",
184
+ "video",
185
+ "animation",
186
+ "audio",
187
+ "sticker",
188
+ ] as const;
103
189
  types.forEach((type, i) => {
104
- addMedia({ chatId: cid, msgId: i + 1, senderName: "User", type, filePath: `/tmp/${type}.bin`, timestamp: Date.now() + i });
190
+ addMedia({
191
+ chatId: cid,
192
+ msgId: i + 1,
193
+ senderName: "User",
194
+ type,
195
+ filePath: `/tmp/${type}.bin`,
196
+ timestamp: Date.now() + i,
197
+ });
105
198
  });
106
199
  const media = getRecentMedia(cid, 20);
107
200
  expect(media).toHaveLength(7);
@@ -111,14 +204,29 @@ describe("media-index", () => {
111
204
 
112
205
  it("supports caption field", () => {
113
206
  const cid = `cap-${Date.now()}`;
114
- addMedia({ chatId: cid, msgId: 1, senderName: "User", type: "photo", filePath: "/a.jpg", caption: "My caption", timestamp: Date.now() });
207
+ addMedia({
208
+ chatId: cid,
209
+ msgId: 1,
210
+ senderName: "User",
211
+ type: "photo",
212
+ filePath: "/a.jpg",
213
+ caption: "My caption",
214
+ timestamp: Date.now(),
215
+ });
115
216
  const media = getRecentMedia(cid);
116
217
  expect(media[0].caption).toBe("My caption");
117
218
  });
118
219
 
119
220
  it("generates correct id from chatId:msgId", () => {
120
221
  const cid = `id-${Date.now()}`;
121
- addMedia({ chatId: cid, msgId: 42, senderName: "User", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
222
+ addMedia({
223
+ chatId: cid,
224
+ msgId: 42,
225
+ senderName: "User",
226
+ type: "photo",
227
+ filePath: "/a.jpg",
228
+ timestamp: Date.now(),
229
+ });
122
230
  const media = getRecentMedia(cid);
123
231
  expect(media[0].id).toBe(`${cid}:42`);
124
232
  });
@@ -127,7 +235,14 @@ describe("media-index", () => {
127
235
  describe("formatMediaIndex output format", () => {
128
236
  it("includes timestamp in readable format", () => {
129
237
  const ts = new Date("2025-03-15T14:30:00Z").getTime();
130
- addMedia({ chatId: "fmt-1", msgId: 1, senderName: "Alice", type: "document", filePath: "/doc.pdf", timestamp: ts });
238
+ addMedia({
239
+ chatId: "fmt-1",
240
+ msgId: 1,
241
+ senderName: "Alice",
242
+ type: "document",
243
+ filePath: "/doc.pdf",
244
+ timestamp: ts,
245
+ });
131
246
  const text = formatMediaIndex("fmt-1");
132
247
  expect(text).toContain("2025-03-15 14:30");
133
248
  expect(text).toContain("[document]");
@@ -138,7 +253,15 @@ describe("media-index", () => {
138
253
 
139
254
  it("truncates long captions at 50 characters", () => {
140
255
  const longCaption = "A".repeat(100);
141
- addMedia({ chatId: "fmt-2", msgId: 1, senderName: "Bob", type: "photo", filePath: "/p.jpg", caption: longCaption, timestamp: Date.now() });
256
+ addMedia({
257
+ chatId: "fmt-2",
258
+ msgId: 1,
259
+ senderName: "Bob",
260
+ type: "photo",
261
+ filePath: "/p.jpg",
262
+ caption: longCaption,
263
+ timestamp: Date.now(),
264
+ });
142
265
  const text = formatMediaIndex("fmt-2");
143
266
  // Caption should be truncated to 50 chars
144
267
  expect(text).toContain(`"${"A".repeat(50)}"`);
@@ -146,7 +269,14 @@ describe("media-index", () => {
146
269
  });
147
270
 
148
271
  it("omits caption when not provided", () => {
149
- addMedia({ chatId: "fmt-3", msgId: 1, senderName: "Bob", type: "photo", filePath: "/p.jpg", timestamp: Date.now() });
272
+ addMedia({
273
+ chatId: "fmt-3",
274
+ msgId: 1,
275
+ senderName: "Bob",
276
+ type: "photo",
277
+ filePath: "/p.jpg",
278
+ timestamp: Date.now(),
279
+ });
150
280
  const text = formatMediaIndex("fmt-3");
151
281
  // Should not contain empty quotes
152
282
  expect(text).not.toContain('""');
@@ -154,7 +284,14 @@ describe("media-index", () => {
154
284
 
155
285
  it("respects limit parameter", () => {
156
286
  for (let i = 0; i < 20; i++) {
157
- addMedia({ chatId: "fmt-4", msgId: i, senderName: "C", type: "photo", filePath: `/p${i}.jpg`, timestamp: Date.now() + i });
287
+ addMedia({
288
+ chatId: "fmt-4",
289
+ msgId: i,
290
+ senderName: "C",
291
+ type: "photo",
292
+ filePath: `/p${i}.jpg`,
293
+ timestamp: Date.now() + i,
294
+ });
158
295
  }
159
296
  const text = formatMediaIndex("fmt-4", 3);
160
297
  // Each entry has 2 lines (info + file path), so 3 entries
@@ -166,22 +303,50 @@ describe("media-index", () => {
166
303
  describe("getMediaByType", () => {
167
304
  it("returns empty array when no entries match type", () => {
168
305
  const cid = `type-none-${Date.now()}`;
169
- addMedia({ chatId: cid, msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
306
+ addMedia({
307
+ chatId: cid,
308
+ msgId: 1,
309
+ senderName: "A",
310
+ type: "photo",
311
+ filePath: "/a.jpg",
312
+ timestamp: Date.now(),
313
+ });
170
314
  expect(getMediaByType(cid, "voice")).toHaveLength(0);
171
315
  });
172
316
 
173
317
  it("respects limit parameter", () => {
174
318
  const cid = `type-limit-${Date.now()}`;
175
319
  for (let i = 0; i < 15; i++) {
176
- addMedia({ chatId: cid, msgId: i, senderName: "A", type: "photo", filePath: `/p${i}.jpg`, timestamp: Date.now() + i });
320
+ addMedia({
321
+ chatId: cid,
322
+ msgId: i,
323
+ senderName: "A",
324
+ type: "photo",
325
+ filePath: `/p${i}.jpg`,
326
+ timestamp: Date.now() + i,
327
+ });
177
328
  }
178
329
  expect(getMediaByType(cid, "photo", 5)).toHaveLength(5);
179
330
  });
180
331
 
181
332
  it("returns newest first", () => {
182
333
  const cid = `type-order-${Date.now()}`;
183
- addMedia({ chatId: cid, msgId: 1, senderName: "A", type: "voice", filePath: "/old.ogg", timestamp: 1000 });
184
- addMedia({ chatId: cid, msgId: 2, senderName: "A", type: "voice", filePath: "/new.ogg", timestamp: 2000 });
334
+ addMedia({
335
+ chatId: cid,
336
+ msgId: 1,
337
+ senderName: "A",
338
+ type: "voice",
339
+ filePath: "/old.ogg",
340
+ timestamp: 1000,
341
+ });
342
+ addMedia({
343
+ chatId: cid,
344
+ msgId: 2,
345
+ senderName: "A",
346
+ type: "voice",
347
+ filePath: "/new.ogg",
348
+ timestamp: 2000,
349
+ });
185
350
  const result = getMediaByType(cid, "voice");
186
351
  expect(result[0].filePath).toBe("/new.ogg");
187
352
  });
@@ -190,8 +355,24 @@ describe("media-index", () => {
190
355
  describe("loadMediaIndex", () => {
191
356
  it("loads entries from existing file", () => {
192
357
  const stored = [
193
- { id: "load-1:1", chatId: "load-1", msgId: 1, senderName: "Alice", type: "photo", filePath: "/a.jpg", timestamp: Date.now() },
194
- { id: "load-1:2", chatId: "load-1", msgId: 2, senderName: "Bob", type: "document", filePath: "/b.pdf", timestamp: Date.now() },
358
+ {
359
+ id: "load-1:1",
360
+ chatId: "load-1",
361
+ msgId: 1,
362
+ senderName: "Alice",
363
+ type: "photo",
364
+ filePath: "/a.jpg",
365
+ timestamp: Date.now(),
366
+ },
367
+ {
368
+ id: "load-1:2",
369
+ chatId: "load-1",
370
+ msgId: 2,
371
+ senderName: "Bob",
372
+ type: "document",
373
+ filePath: "/b.pdf",
374
+ timestamp: Date.now(),
375
+ },
195
376
  ];
196
377
  existsSyncMock.mockReturnValue(true);
197
378
  readFileSyncMock.mockReturnValue(JSON.stringify(stored));
@@ -214,8 +395,24 @@ describe("media-index", () => {
214
395
  const oldTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago (expired)
215
396
  const recentTimestamp = Date.now() - 1000; // 1 second ago (fresh)
216
397
  const stored = [
217
- { id: "purge:1", chatId: "purge", msgId: 1, senderName: "A", type: "photo", filePath: "/old.jpg", timestamp: oldTimestamp },
218
- { id: "purge:2", chatId: "purge", msgId: 2, senderName: "A", type: "photo", filePath: "/new.jpg", timestamp: recentTimestamp },
398
+ {
399
+ id: "purge:1",
400
+ chatId: "purge",
401
+ msgId: 1,
402
+ senderName: "A",
403
+ type: "photo",
404
+ filePath: "/old.jpg",
405
+ timestamp: oldTimestamp,
406
+ },
407
+ {
408
+ id: "purge:2",
409
+ chatId: "purge",
410
+ msgId: 2,
411
+ senderName: "A",
412
+ type: "photo",
413
+ filePath: "/new.jpg",
414
+ timestamp: recentTimestamp,
415
+ },
219
416
  ];
220
417
  existsSyncMock.mockReturnValue(true);
221
418
  readFileSyncMock.mockReturnValue(JSON.stringify(stored));
@@ -230,7 +427,15 @@ describe("media-index", () => {
230
427
  it("deletes expired media files from disk during purge", () => {
231
428
  const oldTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000;
232
429
  const stored = [
233
- { id: "del:1", chatId: "del", msgId: 1, senderName: "A", type: "photo", filePath: "/expired.jpg", timestamp: oldTimestamp },
430
+ {
431
+ id: "del:1",
432
+ chatId: "del",
433
+ msgId: 1,
434
+ senderName: "A",
435
+ type: "photo",
436
+ filePath: "/expired.jpg",
437
+ timestamp: oldTimestamp,
438
+ },
234
439
  ];
235
440
  // existsSync: first call for STORE_FILE=true, then for filePath during purge=true
236
441
  existsSyncMock.mockReturnValue(true);
@@ -244,7 +449,14 @@ describe("media-index", () => {
244
449
 
245
450
  describe("flushMediaIndex", () => {
246
451
  it("writes entries to disk", () => {
247
- addMedia({ chatId: "flush-1", msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
452
+ addMedia({
453
+ chatId: "flush-1",
454
+ msgId: 1,
455
+ senderName: "A",
456
+ type: "photo",
457
+ filePath: "/a.jpg",
458
+ timestamp: Date.now(),
459
+ });
248
460
 
249
461
  existsSyncMock.mockReturnValue(true);
250
462
  flushMediaIndex();
@@ -257,21 +469,91 @@ describe("media-index", () => {
257
469
  });
258
470
 
259
471
  it("creates workspace directory if it does not exist", () => {
260
- addMedia({ chatId: "flush-2", msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
472
+ addMedia({
473
+ chatId: "flush-2",
474
+ msgId: 1,
475
+ senderName: "A",
476
+ type: "photo",
477
+ filePath: "/a.jpg",
478
+ timestamp: Date.now(),
479
+ });
261
480
 
262
481
  existsSyncMock.mockReturnValue(false);
263
482
  flushMediaIndex();
264
483
 
265
- expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), { recursive: true });
484
+ expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), {
485
+ recursive: true,
486
+ });
266
487
  });
267
488
 
268
489
  it("handles write errors gracefully", () => {
269
- addMedia({ chatId: "flush-3", msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
490
+ addMedia({
491
+ chatId: "flush-3",
492
+ msgId: 1,
493
+ senderName: "A",
494
+ type: "photo",
495
+ filePath: "/a.jpg",
496
+ timestamp: Date.now(),
497
+ });
270
498
 
271
499
  existsSyncMock.mockReturnValue(true);
272
- writeFileSyncMock.mockImplementationOnce(() => { throw new Error("disk full"); });
500
+ writeFileSyncMock.mockImplementationOnce(() => {
501
+ throw new Error("disk full");
502
+ });
273
503
 
274
504
  expect(() => flushMediaIndex()).not.toThrow();
275
505
  });
506
+
507
+ it("autoSave timer skips write when nothing has changed (dirty=false)", async () => {
508
+ vi.useFakeTimers();
509
+ existsSyncMock.mockReturnValue(true);
510
+ // Don't add any media — dirty starts false after module load
511
+ // Advance past the 30s autoSave interval to fire save() without dirty being set
512
+ await vi.advanceTimersByTimeAsync(31_000);
513
+ // save() should have been called by the interval but returned early (dirty=false)
514
+ // The key assertion: no write was performed
515
+ expect(writeFileSyncMock).not.toHaveBeenCalled();
516
+ vi.useRealTimers();
517
+ });
518
+ });
519
+ });
520
+
521
+ // ── save dirty=false early return ─────────────────────────────────────────
522
+
523
+ describe("media-index — save dirty=false early return (line 46 TRUE branch)", () => {
524
+ it("does not write when auto-save fires with dirty=false", async () => {
525
+ vi.resetModules();
526
+ vi.useFakeTimers();
527
+ const wfaMock = vi.fn();
528
+ vi.doMock("../util/log.js", () => ({
529
+ log: vi.fn(),
530
+ logError: vi.fn(),
531
+ logWarn: vi.fn(),
532
+ logDebug: vi.fn(),
533
+ }));
534
+ vi.doMock("node:fs", () => ({
535
+ existsSync: vi.fn(() => false),
536
+ mkdirSync: vi.fn(),
537
+ readFileSync: vi.fn(() => "[]"),
538
+ unlinkSync: vi.fn(),
539
+ }));
540
+ vi.doMock("write-file-atomic", () => ({ default: { sync: wfaMock } }));
541
+ vi.doMock("../util/paths.js", () => ({
542
+ files: { media: "/fake/media.json" },
543
+ dirs: {},
544
+ }));
545
+ vi.doMock("../util/cleanup-registry.js", () => ({
546
+ registerCleanup: vi.fn(),
547
+ }));
548
+ vi.doMock("../util/watchdog.js", () => ({ recordError: vi.fn() }));
549
+
550
+ // Fresh import: dirty=false (nothing modified yet)
551
+ await import("../storage/media-index.js");
552
+
553
+ // Advance 31 seconds → auto-save timer fires → save() with dirty=false → early return
554
+ await vi.advanceTimersByTimeAsync(31_000);
555
+ expect(wfaMock).not.toHaveBeenCalled();
556
+
557
+ vi.useRealTimers();
276
558
  });
277
559
  });