talon-agent 1.0.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 (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,659 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ pushMessage,
4
+ getRecentHistory,
5
+ searchHistory,
6
+ getMessagesByUser,
7
+ getKnownUsers,
8
+ getRecentBySenderId,
9
+ getLatestMessageId,
10
+ getRecentFormatted,
11
+ getMessageById,
12
+ getHistoryStats,
13
+ clearHistory,
14
+ setMessageFilePath,
15
+ type HistoryMessage,
16
+ } from "../storage/history.js";
17
+
18
+ function makeMsg(
19
+ overrides: Partial<HistoryMessage> & { msgId: number },
20
+ ): HistoryMessage {
21
+ return {
22
+ senderId: 1,
23
+ senderName: "TestUser",
24
+ text: `Message ${overrides.msgId}`,
25
+ timestamp: Date.now(),
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("history", () => {
31
+ const chatId = () => `test-${Math.random().toString(36).slice(2)}`;
32
+
33
+ describe("pushMessage", () => {
34
+ it("adds a message to the history buffer", () => {
35
+ const id = chatId();
36
+ pushMessage(id, makeMsg({ msgId: 1 }));
37
+ const history = getRecentHistory(id);
38
+ expect(history).toHaveLength(1);
39
+ expect(history[0].msgId).toBe(1);
40
+ });
41
+
42
+ it("adds multiple messages in order", () => {
43
+ const id = chatId();
44
+ pushMessage(id, makeMsg({ msgId: 1 }));
45
+ pushMessage(id, makeMsg({ msgId: 2 }));
46
+ pushMessage(id, makeMsg({ msgId: 3 }));
47
+ const history = getRecentHistory(id, 100);
48
+ expect(history).toHaveLength(3);
49
+ expect(history[0].msgId).toBe(1);
50
+ expect(history[2].msgId).toBe(3);
51
+ });
52
+
53
+ it("caps buffer at MAX_HISTORY_PER_CHAT (500)", () => {
54
+ const id = chatId();
55
+ for (let i = 0; i < 550; i++) {
56
+ pushMessage(id, makeMsg({ msgId: i }));
57
+ }
58
+ const history = getRecentHistory(id, 1000);
59
+ expect(history).toHaveLength(500);
60
+ // First message should be 50 (the oldest surviving after trim)
61
+ expect(history[0].msgId).toBe(50);
62
+ expect(history[499].msgId).toBe(549);
63
+ });
64
+
65
+ it("preserves all optional fields on the message", () => {
66
+ const id = chatId();
67
+ pushMessage(id, makeMsg({
68
+ msgId: 1,
69
+ senderName: "Alice",
70
+ text: "photo msg",
71
+ replyToMsgId: 99,
72
+ mediaType: "photo",
73
+ stickerFileId: "stk123",
74
+ filePath: "/tmp/photo.jpg",
75
+ }));
76
+ const history = getRecentHistory(id);
77
+ expect(history[0].replyToMsgId).toBe(99);
78
+ expect(history[0].mediaType).toBe("photo");
79
+ expect(history[0].stickerFileId).toBe("stk123");
80
+ expect(history[0].filePath).toBe("/tmp/photo.jpg");
81
+ });
82
+ });
83
+
84
+ describe("getRecentHistory", () => {
85
+ it("returns empty array for unknown chat", () => {
86
+ expect(getRecentHistory("nonexistent-chat")).toEqual([]);
87
+ });
88
+
89
+ it("respects the limit parameter", () => {
90
+ const id = chatId();
91
+ for (let i = 0; i < 20; i++) {
92
+ pushMessage(id, makeMsg({ msgId: i }));
93
+ }
94
+ const history = getRecentHistory(id, 5);
95
+ expect(history).toHaveLength(5);
96
+ // Should return the 5 most recent
97
+ expect(history[0].msgId).toBe(15);
98
+ expect(history[4].msgId).toBe(19);
99
+ });
100
+
101
+ it("defaults to 50 messages", () => {
102
+ const id = chatId();
103
+ for (let i = 0; i < 100; i++) {
104
+ pushMessage(id, makeMsg({ msgId: i }));
105
+ }
106
+ const history = getRecentHistory(id);
107
+ expect(history).toHaveLength(50);
108
+ });
109
+ });
110
+
111
+ describe("setMessageFilePath", () => {
112
+ it("updates filePath on an existing message", () => {
113
+ const id = chatId();
114
+ pushMessage(id, makeMsg({ msgId: 10, text: "photo" }));
115
+ setMessageFilePath(id, 10, "/tmp/downloaded.jpg");
116
+ const history = getRecentHistory(id);
117
+ expect(history[0].filePath).toBe("/tmp/downloaded.jpg");
118
+ });
119
+
120
+ it("is a no-op for nonexistent chat", () => {
121
+ // Should not throw
122
+ expect(() => setMessageFilePath("no-such-chat", 1, "/tmp/x.jpg")).not.toThrow();
123
+ });
124
+
125
+ it("is a no-op for nonexistent message", () => {
126
+ const id = chatId();
127
+ pushMessage(id, makeMsg({ msgId: 1 }));
128
+ setMessageFilePath(id, 999, "/tmp/x.jpg");
129
+ const history = getRecentHistory(id);
130
+ expect(history[0].filePath).toBeUndefined();
131
+ });
132
+ });
133
+
134
+ describe("searchHistory", () => {
135
+ it("finds messages matching text (case-insensitive)", () => {
136
+ const id = chatId();
137
+ pushMessage(id, makeMsg({ msgId: 1, text: "Hello world" }));
138
+ pushMessage(id, makeMsg({ msgId: 2, text: "Goodbye world" }));
139
+ pushMessage(id, makeMsg({ msgId: 3, text: "Something else" }));
140
+
141
+ const result = searchHistory(id, "world");
142
+ expect(result).toContain("Hello world");
143
+ expect(result).toContain("Goodbye world");
144
+ expect(result).not.toContain("Something else");
145
+ });
146
+
147
+ it("finds messages matching sender name", () => {
148
+ const id = chatId();
149
+ pushMessage(id, makeMsg({ msgId: 1, senderName: "Alice", text: "hi" }));
150
+ pushMessage(id, makeMsg({ msgId: 2, senderName: "Bob", text: "hello" }));
151
+
152
+ const result = searchHistory(id, "alice");
153
+ expect(result).toContain("Alice");
154
+ expect(result).not.toContain("Bob");
155
+ });
156
+
157
+ it("returns 'No messages' for empty history", () => {
158
+ const result = searchHistory("empty-chat", "test");
159
+ expect(result).toContain("No messages in history");
160
+ });
161
+
162
+ it("returns 'No messages matching' when no results", () => {
163
+ const id = chatId();
164
+ pushMessage(id, makeMsg({ msgId: 1, text: "hello" }));
165
+ const result = searchHistory(id, "xyzzy");
166
+ expect(result).toContain("No messages matching");
167
+ });
168
+
169
+ it("respects the limit parameter", () => {
170
+ const id = chatId();
171
+ for (let i = 0; i < 10; i++) {
172
+ pushMessage(id, makeMsg({ msgId: i, text: `match ${i}` }));
173
+ }
174
+ // All 10 messages match "match", but limit to 3
175
+ const result = searchHistory(id, "match", 3);
176
+ // Should contain only the 3 most recent matches
177
+ const lines = result.split("\n");
178
+ expect(lines).toHaveLength(3);
179
+ expect(result).toContain("match 9");
180
+ expect(result).toContain("match 7");
181
+ });
182
+ });
183
+
184
+ describe("getMessagesByUser", () => {
185
+ it("filters messages by user name (case-insensitive)", () => {
186
+ const id = chatId();
187
+ pushMessage(
188
+ id,
189
+ makeMsg({ msgId: 1, senderName: "Alice", text: "msg from alice" }),
190
+ );
191
+ pushMessage(
192
+ id,
193
+ makeMsg({ msgId: 2, senderName: "Bob", text: "msg from bob" }),
194
+ );
195
+ pushMessage(
196
+ id,
197
+ makeMsg({ msgId: 3, senderName: "Alice", text: "another from alice" }),
198
+ );
199
+
200
+ const result = getMessagesByUser(id, "alice");
201
+ expect(result).toContain("msg from alice");
202
+ expect(result).toContain("another from alice");
203
+ expect(result).not.toContain("msg from bob");
204
+ });
205
+
206
+ it("returns 'No messages from' when user not found", () => {
207
+ const id = chatId();
208
+ pushMessage(id, makeMsg({ msgId: 1, senderName: "Alice", text: "hi" }));
209
+ const result = getMessagesByUser(id, "Charlie");
210
+ expect(result).toContain('No messages from "Charlie"');
211
+ });
212
+
213
+ it("returns 'No messages in history' for empty chat", () => {
214
+ const result = getMessagesByUser("empty-chat-users", "anyone");
215
+ expect(result).toContain("No messages in history");
216
+ });
217
+
218
+ it("respects the limit parameter", () => {
219
+ const id = chatId();
220
+ for (let i = 0; i < 10; i++) {
221
+ pushMessage(id, makeMsg({ msgId: i, senderName: "Alice", text: `msg ${i}` }));
222
+ }
223
+ const result = getMessagesByUser(id, "Alice", 3);
224
+ const lines = result.split("\n");
225
+ expect(lines).toHaveLength(3);
226
+ });
227
+ });
228
+
229
+ describe("clearHistory", () => {
230
+ it("empties the history buffer for a chat", () => {
231
+ const id = chatId();
232
+ pushMessage(id, makeMsg({ msgId: 1 }));
233
+ pushMessage(id, makeMsg({ msgId: 2 }));
234
+ expect(getRecentHistory(id)).toHaveLength(2);
235
+
236
+ clearHistory(id);
237
+ expect(getRecentHistory(id)).toEqual([]);
238
+ });
239
+
240
+ it("does not affect other chats", () => {
241
+ const id1 = chatId();
242
+ const id2 = chatId();
243
+ pushMessage(id1, makeMsg({ msgId: 1 }));
244
+ pushMessage(id2, makeMsg({ msgId: 2 }));
245
+
246
+ clearHistory(id1);
247
+ expect(getRecentHistory(id1)).toEqual([]);
248
+ expect(getRecentHistory(id2)).toHaveLength(1);
249
+ });
250
+
251
+ it("subsequent operations on cleared chat work correctly", () => {
252
+ const id = chatId();
253
+ pushMessage(id, makeMsg({ msgId: 1 }));
254
+ clearHistory(id);
255
+
256
+ // Search returns "No messages"
257
+ expect(searchHistory(id, "anything")).toContain("No messages in history");
258
+ // getKnownUsers returns "No users"
259
+ expect(getKnownUsers(id)).toContain("No users seen yet.");
260
+ // Can push new messages after clearing
261
+ pushMessage(id, makeMsg({ msgId: 99 }));
262
+ expect(getRecentHistory(id)).toHaveLength(1);
263
+ expect(getRecentHistory(id)[0].msgId).toBe(99);
264
+ });
265
+ });
266
+
267
+ describe("getKnownUsers", () => {
268
+ it("returns formatted user list with message counts", () => {
269
+ const id = chatId();
270
+ pushMessage(
271
+ id,
272
+ makeMsg({ msgId: 1, senderId: 100, senderName: "Alice", text: "hi" }),
273
+ );
274
+ pushMessage(
275
+ id,
276
+ makeMsg({ msgId: 2, senderId: 200, senderName: "Bob", text: "hey" }),
277
+ );
278
+ pushMessage(
279
+ id,
280
+ makeMsg({
281
+ msgId: 3,
282
+ senderId: 100,
283
+ senderName: "Alice",
284
+ text: "how are you",
285
+ }),
286
+ );
287
+
288
+ const result = getKnownUsers(id);
289
+ expect(result).toContain("Alice");
290
+ expect(result).toContain("Bob");
291
+ expect(result).toContain("user_id: 100");
292
+ expect(result).toContain("user_id: 200");
293
+ expect(result).toContain("2 msgs"); // Alice has 2 messages
294
+ expect(result).toContain("1 msgs"); // Bob has 1 message
295
+ });
296
+
297
+ it("returns 'No users seen yet.' for empty chat", () => {
298
+ const result = getKnownUsers("empty-users-chat");
299
+ expect(result).toContain("No users seen yet.");
300
+ });
301
+
302
+ it("returns 'No users seen yet.' for nonexistent chat", () => {
303
+ const result = getKnownUsers("nonexistent-chat-xyz");
304
+ expect(result).toContain("No users seen yet.");
305
+ });
306
+
307
+ it("shows time ago for users seen minutes ago", () => {
308
+ const id = chatId();
309
+ pushMessage(id, makeMsg({
310
+ msgId: 1,
311
+ senderId: 100,
312
+ senderName: "Alice",
313
+ timestamp: Date.now() - 5 * 60_000, // 5 minutes ago
314
+ }));
315
+ const result = getKnownUsers(id);
316
+ expect(result).toContain("5m ago");
317
+ });
318
+
319
+ it("shows time ago for users seen hours ago", () => {
320
+ const id = chatId();
321
+ pushMessage(id, makeMsg({
322
+ msgId: 1,
323
+ senderId: 100,
324
+ senderName: "Alice",
325
+ timestamp: Date.now() - 3 * 3_600_000, // 3 hours ago
326
+ }));
327
+ const result = getKnownUsers(id);
328
+ expect(result).toContain("3h ago");
329
+ });
330
+
331
+ it("shows time ago for users seen days ago", () => {
332
+ const id = chatId();
333
+ pushMessage(id, makeMsg({
334
+ msgId: 1,
335
+ senderId: 100,
336
+ senderName: "Alice",
337
+ timestamp: Date.now() - 2 * 86_400_000, // 2 days ago
338
+ }));
339
+ const result = getKnownUsers(id);
340
+ expect(result).toContain("2d ago");
341
+ });
342
+
343
+ it("shows 'just now' for users seen less than a minute ago", () => {
344
+ const id = chatId();
345
+ pushMessage(id, makeMsg({
346
+ msgId: 1,
347
+ senderId: 100,
348
+ senderName: "Alice",
349
+ timestamp: Date.now() - 10_000, // 10 seconds ago
350
+ }));
351
+ const result = getKnownUsers(id);
352
+ expect(result).toContain("just now");
353
+ });
354
+
355
+ it("sorts users by last seen (most recent first)", () => {
356
+ const id = chatId();
357
+ pushMessage(id, makeMsg({
358
+ msgId: 1,
359
+ senderId: 100,
360
+ senderName: "OldUser",
361
+ timestamp: Date.now() - 86_400_000,
362
+ }));
363
+ pushMessage(id, makeMsg({
364
+ msgId: 2,
365
+ senderId: 200,
366
+ senderName: "NewUser",
367
+ timestamp: Date.now() - 60_000,
368
+ }));
369
+ const result = getKnownUsers(id);
370
+ const newIdx = result.indexOf("NewUser");
371
+ const oldIdx = result.indexOf("OldUser");
372
+ expect(newIdx).toBeLessThan(oldIdx);
373
+ });
374
+
375
+ it("updates user name to the latest seen name", () => {
376
+ const id = chatId();
377
+ pushMessage(id, makeMsg({
378
+ msgId: 1,
379
+ senderId: 100,
380
+ senderName: "OldName",
381
+ timestamp: Date.now() - 60_000,
382
+ }));
383
+ pushMessage(id, makeMsg({
384
+ msgId: 2,
385
+ senderId: 100,
386
+ senderName: "NewName",
387
+ timestamp: Date.now(),
388
+ }));
389
+ const result = getKnownUsers(id);
390
+ expect(result).toContain("NewName");
391
+ expect(result).not.toContain("OldName");
392
+ });
393
+ });
394
+
395
+ describe("getRecentBySenderId", () => {
396
+ it("returns messages from a specific sender", () => {
397
+ const id = chatId();
398
+ pushMessage(
399
+ id,
400
+ makeMsg({ msgId: 1, senderId: 100, senderName: "Alice" }),
401
+ );
402
+ pushMessage(
403
+ id,
404
+ makeMsg({ msgId: 2, senderId: 200, senderName: "Bob" }),
405
+ );
406
+ pushMessage(
407
+ id,
408
+ makeMsg({ msgId: 3, senderId: 100, senderName: "Alice" }),
409
+ );
410
+ pushMessage(
411
+ id,
412
+ makeMsg({ msgId: 4, senderId: 200, senderName: "Bob" }),
413
+ );
414
+
415
+ const result = getRecentBySenderId(id, 100);
416
+ expect(result).toHaveLength(2);
417
+ expect(result[0].msgId).toBe(1);
418
+ expect(result[1].msgId).toBe(3);
419
+ });
420
+
421
+ it("returns empty array for unknown sender", () => {
422
+ const id = chatId();
423
+ pushMessage(
424
+ id,
425
+ makeMsg({ msgId: 1, senderId: 100, senderName: "Alice" }),
426
+ );
427
+ const result = getRecentBySenderId(id, 999);
428
+ expect(result).toEqual([]);
429
+ });
430
+
431
+ it("respects limit parameter", () => {
432
+ const id = chatId();
433
+ for (let i = 0; i < 10; i++) {
434
+ pushMessage(
435
+ id,
436
+ makeMsg({ msgId: i, senderId: 100, senderName: "Alice" }),
437
+ );
438
+ }
439
+ const result = getRecentBySenderId(id, 100, 3);
440
+ expect(result).toHaveLength(3);
441
+ // Should be the 3 most recent
442
+ expect(result[0].msgId).toBe(7);
443
+ expect(result[2].msgId).toBe(9);
444
+ });
445
+
446
+ it("returns empty array for nonexistent chat", () => {
447
+ const result = getRecentBySenderId("nonexistent-sender-chat", 100);
448
+ expect(result).toEqual([]);
449
+ });
450
+ });
451
+
452
+ describe("getLatestMessageId", () => {
453
+ it("returns the ID of the most recent message", () => {
454
+ const id = chatId();
455
+ pushMessage(id, makeMsg({ msgId: 10 }));
456
+ pushMessage(id, makeMsg({ msgId: 20 }));
457
+ pushMessage(id, makeMsg({ msgId: 30 }));
458
+
459
+ expect(getLatestMessageId(id)).toBe(30);
460
+ });
461
+
462
+ it("returns undefined for empty/nonexistent chat", () => {
463
+ expect(getLatestMessageId("nonexistent-latest")).toBeUndefined();
464
+ });
465
+ });
466
+
467
+ describe("getRecentFormatted", () => {
468
+ it("returns formatted message strings", () => {
469
+ const id = chatId();
470
+ pushMessage(
471
+ id,
472
+ makeMsg({
473
+ msgId: 1,
474
+ senderName: "Alice",
475
+ text: "Hello there!",
476
+ timestamp: new Date("2025-01-15T10:30:00Z").getTime(),
477
+ }),
478
+ );
479
+
480
+ const result = getRecentFormatted(id, 5);
481
+ expect(result).toContain("Alice");
482
+ expect(result).toContain("Hello there!");
483
+ expect(result).toContain("msg:1");
484
+ expect(result).toContain("10:30");
485
+ });
486
+
487
+ it("returns 'No messages in history.' for empty chat", () => {
488
+ const result = getRecentFormatted("empty-formatted-chat");
489
+ expect(result).toBe("No messages in history.");
490
+ });
491
+
492
+ it("includes media type tags", () => {
493
+ const id = chatId();
494
+ pushMessage(
495
+ id,
496
+ makeMsg({
497
+ msgId: 1,
498
+ senderName: "Bob",
499
+ text: "a photo",
500
+ mediaType: "photo",
501
+ }),
502
+ );
503
+
504
+ const result = getRecentFormatted(id, 5);
505
+ expect(result).toContain("[photo]");
506
+ });
507
+
508
+ it("includes sticker file_id", () => {
509
+ const id = chatId();
510
+ pushMessage(
511
+ id,
512
+ makeMsg({
513
+ msgId: 1,
514
+ senderName: "Bob",
515
+ text: "sticker",
516
+ mediaType: "sticker",
517
+ stickerFileId: "CAACAgIAAxk",
518
+ }),
519
+ );
520
+
521
+ const result = getRecentFormatted(id, 5);
522
+ expect(result).toContain("sticker_file_id: CAACAgIAAxk");
523
+ });
524
+
525
+ it("includes reply tag", () => {
526
+ const id = chatId();
527
+ pushMessage(
528
+ id,
529
+ makeMsg({
530
+ msgId: 2,
531
+ senderName: "Alice",
532
+ text: "replying",
533
+ replyToMsgId: 1,
534
+ }),
535
+ );
536
+
537
+ const result = getRecentFormatted(id, 5);
538
+ expect(result).toContain("replying to msg:1");
539
+ });
540
+
541
+ it("includes file path tag", () => {
542
+ const id = chatId();
543
+ pushMessage(
544
+ id,
545
+ makeMsg({
546
+ msgId: 1,
547
+ senderName: "Bob",
548
+ text: "a file",
549
+ filePath: "/tmp/downloaded.pdf",
550
+ }),
551
+ );
552
+
553
+ const result = getRecentFormatted(id, 5);
554
+ expect(result).toContain("(file: /tmp/downloaded.pdf)");
555
+ });
556
+
557
+ it("formats multiple messages joined by newlines", () => {
558
+ const id = chatId();
559
+ pushMessage(id, makeMsg({ msgId: 1, text: "first", timestamp: 1000000000000 }));
560
+ pushMessage(id, makeMsg({ msgId: 2, text: "second", timestamp: 1000000060000 }));
561
+ const result = getRecentFormatted(id, 5);
562
+ const lines = result.split("\n");
563
+ expect(lines).toHaveLength(2);
564
+ expect(lines[0]).toContain("first");
565
+ expect(lines[1]).toContain("second");
566
+ });
567
+
568
+ it("includes all media type variants in tags", () => {
569
+ const id = chatId();
570
+ const types: Array<HistoryMessage["mediaType"]> = ["document", "voice", "video", "animation"];
571
+ types.forEach((type, i) => {
572
+ pushMessage(id, makeMsg({ msgId: i + 1, text: `media ${type}`, mediaType: type }));
573
+ });
574
+ const result = getRecentFormatted(id, 10);
575
+ expect(result).toContain("[document]");
576
+ expect(result).toContain("[voice]");
577
+ expect(result).toContain("[video]");
578
+ expect(result).toContain("[animation]");
579
+ });
580
+ });
581
+
582
+ describe("getMessageById", () => {
583
+ it("returns formatted message when found", () => {
584
+ const id = chatId();
585
+ pushMessage(
586
+ id,
587
+ makeMsg({ msgId: 42, senderName: "Alice", text: "specific message" }),
588
+ );
589
+ pushMessage(id, makeMsg({ msgId: 43, senderName: "Bob", text: "other" }));
590
+
591
+ const result = getMessageById(id, 42);
592
+ expect(result).toContain("Alice");
593
+ expect(result).toContain("specific message");
594
+ expect(result).toContain("msg:42");
595
+ });
596
+
597
+ it("returns 'not found' for missing message", () => {
598
+ const id = chatId();
599
+ pushMessage(id, makeMsg({ msgId: 1 }));
600
+ const result = getMessageById(id, 999);
601
+ expect(result).toContain("Message 999 not found");
602
+ });
603
+
604
+ it("returns 'No messages' for empty chat", () => {
605
+ const result = getMessageById("empty-by-id-chat", 1);
606
+ expect(result).toContain("No messages in history");
607
+ });
608
+ });
609
+
610
+ describe("getHistoryStats", () => {
611
+ it("returns correct stats", () => {
612
+ const id = chatId();
613
+ const ts1 = Date.now() - 10000;
614
+ const ts2 = Date.now() - 5000;
615
+ pushMessage(
616
+ id,
617
+ makeMsg({ msgId: 1, senderId: 100, timestamp: ts1 }),
618
+ );
619
+ pushMessage(
620
+ id,
621
+ makeMsg({ msgId: 2, senderId: 200, timestamp: ts2 }),
622
+ );
623
+
624
+ const stats = getHistoryStats(id);
625
+ expect(stats.totalMessages).toBe(2);
626
+ expect(stats.uniqueUsers).toBe(2);
627
+ expect(stats.oldestTimestamp).toBe(ts1);
628
+ expect(stats.newestTimestamp).toBe(ts2);
629
+ });
630
+
631
+ it("returns zeroes for empty chat", () => {
632
+ const stats = getHistoryStats("nonexistent-stats-chat");
633
+ expect(stats.totalMessages).toBe(0);
634
+ expect(stats.uniqueUsers).toBe(0);
635
+ expect(stats.oldestTimestamp).toBe(0);
636
+ expect(stats.newestTimestamp).toBe(0);
637
+ });
638
+ });
639
+
640
+ describe("chat eviction", () => {
641
+ it("evicts oldest chats when exceeding MAX_CHAT_COUNT (1000)", () => {
642
+ // Push messages for 1001 unique chats
643
+ for (let i = 0; i < 1001; i++) {
644
+ pushMessage(`evict-chat-${i}`, makeMsg({ msgId: 1 }));
645
+ }
646
+ // The first ~100 chats (10%) should have been evicted
647
+ // The last chat should still exist
648
+ expect(getRecentHistory("evict-chat-1000")).toHaveLength(1);
649
+ // Some early chats should have been evicted
650
+ let evictedCount = 0;
651
+ for (let i = 0; i < 100; i++) {
652
+ if (getRecentHistory(`evict-chat-${i}`).length === 0) {
653
+ evictedCount++;
654
+ }
655
+ }
656
+ expect(evictedCount).toBeGreaterThan(0);
657
+ });
658
+ });
659
+ });