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
@@ -194,6 +194,30 @@ describe("plugin system", () => {
194
194
  const servers = getPluginMcpServers("http://localhost:19876", "chat1");
195
195
  expect(Object.keys(servers)).toHaveLength(0);
196
196
  });
197
+
198
+ it("uses npx command and tsx args on win32 platform (line 418/420 TRUE branch)", async () => {
199
+ const plugin = createMockPlugin({ mcpServerPath: "/fake/tools.ts" });
200
+ const { loadPlugins, getPluginMcpServers } = await setup(plugin);
201
+ await loadPlugins([{ path: "/fake/plugin" }]);
202
+
203
+ // Temporarily override process.platform to simulate Windows
204
+ const originalPlatform = process.platform;
205
+ Object.defineProperty(process, "platform", {
206
+ value: "win32",
207
+ configurable: true,
208
+ });
209
+ try {
210
+ const servers = getPluginMcpServers("http://localhost:19876", "chat1");
211
+ expect(servers["test-plugin-tools"].command).toBe("npx");
212
+ const args = servers["test-plugin-tools"].args as string[];
213
+ expect(args[0]).toBe("tsx");
214
+ } finally {
215
+ Object.defineProperty(process, "platform", {
216
+ value: originalPlatform,
217
+ configurable: true,
218
+ });
219
+ }
220
+ });
197
221
  });
198
222
 
199
223
  describe("system prompt additions", () => {
@@ -262,6 +286,24 @@ describe("plugin system", () => {
262
286
 
263
287
  await expect(destroyPlugins()).resolves.toBeUndefined();
264
288
  });
289
+
290
+ it("covers init timeout arrow fn — inner setTimeout reject callback fires after 30s", async () => {
291
+ vi.useFakeTimers();
292
+ // Plugin with init that never resolves — forces the 30s timeout to win the race
293
+ const plugin = createMockPlugin({ init: () => new Promise(() => {}) });
294
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
295
+ const mod = await import("../core/plugin.js");
296
+ mod._deps.importModule = async () => ({ default: plugin });
297
+
298
+ const loadPromise = mod.loadPlugins([{ path: "/fake/plugin" }]);
299
+ // Advance past INIT_TIMEOUT (30_000ms) so the setTimeout reject callback fires
300
+ await vi.advanceTimersByTimeAsync(30_001);
301
+ await loadPromise;
302
+
303
+ vi.useRealTimers();
304
+ // Plugin still registers despite timeout (error is caught)
305
+ expect(mod.getPluginCount()).toBeGreaterThan(0);
306
+ });
265
307
  });
266
308
 
267
309
  describe("frontend whitelist", () => {
@@ -315,3 +357,275 @@ describe("plugin system", () => {
315
357
  });
316
358
  });
317
359
  });
360
+
361
+ describe("extractPlugin — invalid optional field types", () => {
362
+ // All tests use _deps.importModule injection (same pattern as the main describe block)
363
+ beforeEach(() => {
364
+ vi.resetModules();
365
+ });
366
+
367
+ it("rejects plugin when init is not a function", async () => {
368
+ const plugin = { name: "bad-init", init: "not-a-function" };
369
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
370
+ const mod = await import("../core/plugin.js");
371
+ mod._deps.importModule = async () => ({ default: plugin });
372
+ await mod.loadPlugins([{ path: "/fake/bad-init" }]);
373
+ expect(mod.getPluginCount()).toBe(0);
374
+ });
375
+
376
+ it("rejects plugin when getSystemPromptAddition is not a function", async () => {
377
+ const plugin = { name: "bad-gsp", getSystemPromptAddition: 42 };
378
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
379
+ const mod = await import("../core/plugin.js");
380
+ mod._deps.importModule = async () => ({ default: plugin });
381
+ await mod.loadPlugins([{ path: "/fake/bad-gsp" }]);
382
+ expect(mod.getPluginCount()).toBe(0);
383
+ });
384
+
385
+ it("rejects plugin when mcpServerPath is not a string", async () => {
386
+ const plugin = { name: "bad-mcp", mcpServerPath: 99 };
387
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
388
+ const mod = await import("../core/plugin.js");
389
+ mod._deps.importModule = async () => ({ default: plugin });
390
+ await mod.loadPlugins([{ path: "/fake/bad-mcp" }]);
391
+ expect(mod.getPluginCount()).toBe(0);
392
+ });
393
+
394
+ it("rejects plugin when frontends is not an array", async () => {
395
+ const plugin = { name: "bad-frontends", frontends: "telegram" };
396
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
397
+ const mod = await import("../core/plugin.js");
398
+ mod._deps.importModule = async () => ({ default: plugin });
399
+ await mod.loadPlugins([{ path: "/fake/bad-frontends" }]);
400
+ expect(mod.getPluginCount()).toBe(0);
401
+ });
402
+
403
+ it("rejects plugin when handleAction is defined but not a function", async () => {
404
+ const plugin = { name: "bad-handle", handleAction: "not-a-function" };
405
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
406
+ const mod = await import("../core/plugin.js");
407
+ mod._deps.importModule = async () => ({ default: plugin });
408
+ await mod.loadPlugins([{ path: "/fake/bad-handle" }]);
409
+ expect(mod.getPluginCount()).toBe(0);
410
+ });
411
+
412
+ it("catches and logs error when importModule throws", async () => {
413
+ vi.resetModules();
414
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
415
+ vi.doMock("../util/log.js", () => ({
416
+ log: vi.fn(),
417
+ logError: vi.fn(),
418
+ logWarn: vi.fn(),
419
+ logDebug: vi.fn(),
420
+ }));
421
+ const mod = await import("../core/plugin.js");
422
+ const { logError } = (await import("../util/log.js")) as unknown as {
423
+ logError: ReturnType<typeof vi.fn>;
424
+ };
425
+ mod._deps.importModule = async () => {
426
+ throw new Error("module load failed");
427
+ };
428
+ await mod.loadPlugins([{ path: "/fake/throw-plugin" }]);
429
+ expect(mod.getPluginCount()).toBe(0);
430
+ expect(logError).toHaveBeenCalledWith(
431
+ "plugin",
432
+ expect.stringContaining("Failed to load plugin"),
433
+ );
434
+ });
435
+
436
+ it("default _deps.importModule can import real modules", async () => {
437
+ vi.resetModules();
438
+ const mod = await import("../core/plugin.js");
439
+ // Exercise the default importModule implementation (line 159) by importing a built-in
440
+ const result = await mod._deps.importModule("node:path");
441
+ expect(result).toBeDefined();
442
+ expect(typeof (result as Record<string, unknown>).join).toBe("function");
443
+ });
444
+
445
+ it("catches importModule non-Error throw with String(err) (line 188 FALSE branch)", async () => {
446
+ vi.resetModules();
447
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
448
+ vi.doMock("../util/log.js", () => ({
449
+ log: vi.fn(),
450
+ logError: vi.fn(),
451
+ logWarn: vi.fn(),
452
+ logDebug: vi.fn(),
453
+ }));
454
+ const mod = await import("../core/plugin.js");
455
+ const { logError } = (await import("../util/log.js")) as unknown as {
456
+ logError: ReturnType<typeof vi.fn>;
457
+ };
458
+ // Throw a plain string (non-Error) — covers String(err) branch
459
+ mod._deps.importModule = async () => {
460
+ throw "plain string load error";
461
+ };
462
+ await mod.loadPlugins([{ path: "/fake/non-error-throw" }]);
463
+ expect(logError).toHaveBeenCalledWith(
464
+ "plugin",
465
+ expect.stringContaining("plain string load error"),
466
+ );
467
+ });
468
+
469
+ it("init non-Error throw uses String(err) (line 269 FALSE branch)", async () => {
470
+ vi.resetModules();
471
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
472
+ vi.doMock("../util/log.js", () => ({
473
+ log: vi.fn(),
474
+ logError: vi.fn(),
475
+ logWarn: vi.fn(),
476
+ logDebug: vi.fn(),
477
+ }));
478
+ const mod = await import("../core/plugin.js");
479
+ const { logError } = (await import("../util/log.js")) as unknown as {
480
+ logError: ReturnType<typeof vi.fn>;
481
+ };
482
+ const plugin = {
483
+ name: "init-non-error",
484
+ init: () => {
485
+ throw "plain string init error";
486
+ },
487
+ };
488
+ mod._deps.importModule = async () => ({ default: plugin });
489
+ await mod.loadPlugins([{ path: "/fake/init-non-error" }]);
490
+ expect(logError).toHaveBeenCalledWith(
491
+ "plugin",
492
+ expect.stringContaining("plain string init error"),
493
+ );
494
+ });
495
+
496
+ it("extractPlugin uses mod directly when default is absent (line 289 FALSE??branch)", async () => {
497
+ vi.resetModules();
498
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
499
+ vi.doMock("../util/log.js", () => ({
500
+ log: vi.fn(),
501
+ logError: vi.fn(),
502
+ logWarn: vi.fn(),
503
+ logDebug: vi.fn(),
504
+ }));
505
+ const mod = await import("../core/plugin.js");
506
+ // No `default` export — `mod.default ?? mod` falls back to mod itself
507
+ const pluginMod = { name: "mod-as-plugin", handleAction: async () => null };
508
+ mod._deps.importModule = async () =>
509
+ pluginMod as unknown as Record<string, unknown>;
510
+ await mod.loadPlugins([{ path: "/fake/no-default" }]);
511
+ expect(mod.getPlugin("mod-as-plugin")).toBeDefined();
512
+ });
513
+
514
+ it("extractPlugin returns null when candidate is not an object (line 290 TRUE branch)", async () => {
515
+ vi.resetModules();
516
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
517
+ vi.doMock("../util/log.js", () => ({
518
+ log: vi.fn(),
519
+ logError: vi.fn(),
520
+ logWarn: vi.fn(),
521
+ logDebug: vi.fn(),
522
+ }));
523
+ const mod = await import("../core/plugin.js");
524
+ // default export is a number — candidate = 42, typeof 42 !== "object" → return null
525
+ // Note: { default: null } would NOT work because null ?? mod evaluates to mod (not null)
526
+ mod._deps.importModule = async () =>
527
+ ({ default: 42 }) as unknown as Record<string, unknown>;
528
+ await mod.loadPlugins([{ path: "/fake/non-object-default" }]);
529
+ expect(mod.getPluginCount()).toBe(0);
530
+ });
531
+
532
+ it("destroy non-Error throw uses String(err) (line 145 FALSE branch)", async () => {
533
+ vi.resetModules();
534
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
535
+ vi.doMock("../util/log.js", () => ({
536
+ log: vi.fn(),
537
+ logError: vi.fn(),
538
+ logWarn: vi.fn(),
539
+ logDebug: vi.fn(),
540
+ }));
541
+ const mod = await import("../core/plugin.js");
542
+ const { logError } = (await import("../util/log.js")) as unknown as {
543
+ logError: ReturnType<typeof vi.fn>;
544
+ };
545
+ const plugin = {
546
+ name: "destroy-non-error",
547
+ destroy: () => {
548
+ throw "plain string destroy error";
549
+ },
550
+ };
551
+ mod._deps.importModule = async () => ({ default: plugin });
552
+ await mod.loadPlugins([{ path: "/fake/destroy-non-error" }]);
553
+ await mod.destroyPlugins();
554
+ expect(logError).toHaveBeenCalledWith(
555
+ "plugin",
556
+ expect.stringContaining("plain string destroy error"),
557
+ );
558
+ });
559
+
560
+ it("getSystemPromptAddition non-Error throw uses String(err) (line 352 FALSE branch)", async () => {
561
+ vi.resetModules();
562
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
563
+ vi.doMock("../util/log.js", () => ({
564
+ log: vi.fn(),
565
+ logError: vi.fn(),
566
+ logWarn: vi.fn(),
567
+ logDebug: vi.fn(),
568
+ }));
569
+ const mod = await import("../core/plugin.js");
570
+ const { logError } = (await import("../util/log.js")) as unknown as {
571
+ logError: ReturnType<typeof vi.fn>;
572
+ };
573
+ const plugin = {
574
+ name: "prompt-non-error",
575
+ getSystemPromptAddition: () => {
576
+ throw "plain string prompt error";
577
+ },
578
+ };
579
+ mod._deps.importModule = async () => ({ default: plugin });
580
+ await mod.loadPlugins([{ path: "/fake/prompt-non-error" }]);
581
+ mod.getPluginPromptAdditions();
582
+ expect(logError).toHaveBeenCalledWith(
583
+ "plugin",
584
+ expect.stringContaining("plain string prompt error"),
585
+ );
586
+ });
587
+
588
+ it("handlePluginAction non-Error throw uses String(err) (lines 378+382 FALSE branches)", async () => {
589
+ vi.resetModules();
590
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
591
+ vi.doMock("../util/log.js", () => ({
592
+ log: vi.fn(),
593
+ logError: vi.fn(),
594
+ logWarn: vi.fn(),
595
+ logDebug: vi.fn(),
596
+ }));
597
+ const mod = await import("../core/plugin.js");
598
+ const { logError } = (await import("../util/log.js")) as unknown as {
599
+ logError: ReturnType<typeof vi.fn>;
600
+ };
601
+ const plugin = {
602
+ name: "action-non-error",
603
+ handleAction: async () => {
604
+ throw "plain string action error";
605
+ },
606
+ };
607
+ mod._deps.importModule = async () => ({ default: plugin });
608
+ await mod.loadPlugins([{ path: "/fake/action-non-error" }]);
609
+ const result = await mod.handlePluginAction({ action: "test" }, "123");
610
+ expect(result?.ok).toBe(false);
611
+ expect(String(result?.error)).toContain("plain string action error");
612
+ expect(logError).toHaveBeenCalledWith(
613
+ "plugin",
614
+ expect.stringContaining("plain string action error"),
615
+ );
616
+ });
617
+
618
+ it("getLoadedPlugins returns all loaded plugins", async () => {
619
+ const plugin = {
620
+ name: "good-plugin",
621
+ handleAction: async () => null,
622
+ };
623
+ vi.doMock("node:fs", () => ({ existsSync: vi.fn(() => true) }));
624
+ const mod = await import("../core/plugin.js");
625
+ mod._deps.importModule = async () => ({ default: plugin });
626
+ await mod.loadPlugins([{ path: "/fake/good-p" }]);
627
+ const loaded = mod.getLoadedPlugins();
628
+ expect(loaded.length).toBeGreaterThanOrEqual(1);
629
+ expect(loaded.some((l) => l.plugin.name === "good-plugin")).toBe(true);
630
+ });
631
+ });
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Extended prompt-builder tests — covers branches and edge cases not exercised
3
+ * by the existing prompt-builder.test.ts.
4
+ *
5
+ * enrichDMPrompt edge cases:
6
+ * - Empty-string username (falsy → no @tag appended)
7
+ * - Whitespace-only name
8
+ * - Multi-line prompt body preserved exactly
9
+ * - Special characters in name / username
10
+ *
11
+ * enrichGroupPrompt branches:
12
+ * - Exactly 2 messages (boundary: 1 prior — triggers the formatting path)
13
+ * - 6 messages (5 prior + 1 current — tests full-width context window)
14
+ * - Line 33 defensive branch: priorMsgs.length === 0 cannot be reached via
15
+ * recentMsgs.length > 1 guard, but the behaviour is confirmed by the
16
+ * single-message case already in base tests; we verify the slice is correct.
17
+ * - senderName is taken from priorMsgs[0], not the current message
18
+ * - Timestamp formatting via formatSmartTimestamp is invoked (output contains
19
+ * a formatted timestamp string within the context block)
20
+ * - Text truncation at exactly 200 characters (boundary check)
21
+ * - Text shorter than 200 characters is not padded
22
+ * - getRecentBySenderId is called with the correct chatId and senderId
23
+ * - Output structure: context block precedes the prompt, separated by blank line
24
+ */
25
+
26
+ import { describe, it, expect, vi, beforeEach } from "vitest";
27
+
28
+ // ── Mocks ─────────────────────────────────────────────────────────────────────
29
+
30
+ vi.mock("../storage/history.js", () => ({
31
+ getRecentBySenderId: vi.fn(() => []),
32
+ }));
33
+
34
+ // We do NOT mock ../util/time.js — formatSmartTimestamp is a pure function
35
+ // that we want exercised for real so that timestamp output is visible in results.
36
+
37
+ // ── Dynamic imports (after mocks) ────────────────────────────────────────────
38
+
39
+ const { getRecentBySenderId } = await import("../storage/history.js");
40
+ const { enrichDMPrompt, enrichGroupPrompt } =
41
+ await import("../core/prompt-builder.js");
42
+
43
+ // ── Helpers ──────────────────────────────────────────────────────────────────
44
+
45
+ /** Build a minimal history message object. */
46
+ function msg(
47
+ msgId: number,
48
+ senderId: number,
49
+ senderName: string,
50
+ text: string,
51
+ timestamp = Date.now(),
52
+ ) {
53
+ return { msgId, senderId, senderName, text, timestamp };
54
+ }
55
+
56
+ // ── enrichDMPrompt ────────────────────────────────────────────────────────────
57
+
58
+ describe("enrichDMPrompt — extended edge cases", () => {
59
+ it("omits the @tag when username is an empty string (falsy branch)", () => {
60
+ // senderUsername = "" → `${senderUsername ? ` (@${senderUsername})` : ""}` → ""
61
+ const result = enrichDMPrompt("hello", "Alice", "");
62
+ expect(result).toBe("[DM from Alice]\nhello");
63
+ expect(result).not.toContain("@");
64
+ });
65
+
66
+ it("preserves a multi-line prompt body verbatim", () => {
67
+ const multiLine = "line one\nline two\nline three";
68
+ const result = enrichDMPrompt(multiLine, "Bob");
69
+ expect(result).toBe(`[DM from Bob]\n${multiLine}`);
70
+ });
71
+
72
+ it("handles special characters in sender name", () => {
73
+ const result = enrichDMPrompt("hi", "O'Brien & Co.", "obrien");
74
+ expect(result).toBe("[DM from O'Brien & Co. (@obrien)]\nhi");
75
+ });
76
+
77
+ it("handles special characters in username", () => {
78
+ const result = enrichDMPrompt("hi", "User", "user.name_123");
79
+ expect(result).toBe("[DM from User (@user.name_123)]\nhi");
80
+ });
81
+
82
+ it("handles an empty prompt string", () => {
83
+ const result = enrichDMPrompt("", "Carol");
84
+ expect(result).toBe("[DM from Carol]\n");
85
+ });
86
+
87
+ it("handles a whitespace-only sender name", () => {
88
+ // No special handling expected — just passed through
89
+ const result = enrichDMPrompt("msg", " ");
90
+ expect(result).toBe("[DM from ]\nmsg");
91
+ });
92
+
93
+ it("format is always [DM from NAME] newline PROMPT", () => {
94
+ const name = "TestUser";
95
+ const username = "tuser";
96
+ const prompt = "test prompt";
97
+ const result = enrichDMPrompt(prompt, name, username);
98
+ expect(result.startsWith("[DM from TestUser (@tuser)]\n")).toBe(true);
99
+ expect(result.endsWith(prompt)).toBe(true);
100
+ });
101
+ });
102
+
103
+ // ── enrichGroupPrompt ─────────────────────────────────────────────────────────
104
+
105
+ describe("enrichGroupPrompt — extended branch coverage", () => {
106
+ beforeEach(() => {
107
+ vi.mocked(getRecentBySenderId).mockReset();
108
+ });
109
+
110
+ // ── boundary: exactly 2 messages (1 prior + 1 current) ──────────────────
111
+
112
+ it("includes context when there are exactly 2 messages (minimum enrichment case)", () => {
113
+ vi.mocked(getRecentBySenderId).mockReturnValue([
114
+ msg(
115
+ 1,
116
+ 10,
117
+ "Alice",
118
+ "prior message",
119
+ new Date("2025-06-01T10:00:00Z").getTime(),
120
+ ),
121
+ msg(
122
+ 2,
123
+ 10,
124
+ "Alice",
125
+ "current message",
126
+ new Date("2025-06-01T10:01:00Z").getTime(),
127
+ ),
128
+ ]);
129
+ const result = enrichGroupPrompt("current message", "chat-x", 10);
130
+ expect(result).toContain("Alice's recent messages in this group:");
131
+ expect(result).toContain("prior message");
132
+ expect(result).toContain("current message"); // original prompt preserved
133
+ // Structure: context block then blank line then prompt
134
+ expect(result).toMatch(/\]\n\ncurrent message$/);
135
+ });
136
+
137
+ // ── senderName from priorMsgs[0], not the current message ───────────────
138
+
139
+ it("uses the senderName from the first prior message (priorMsgs[0])", () => {
140
+ vi.mocked(getRecentBySenderId).mockReturnValue([
141
+ msg(1, 20, "FirstSender", "msg a", Date.now() - 5000),
142
+ msg(2, 20, "SecondSender", "msg b", Date.now() - 3000), // prior[1]
143
+ msg(3, 20, "ThirdSender", "current msg", Date.now()), // current (slice off)
144
+ ]);
145
+ const result = enrichGroupPrompt("current msg", "chat-y", 20);
146
+ // senderName header should be from index 0 of priorMsgs
147
+ expect(result).toContain("FirstSender's recent messages in this group:");
148
+ });
149
+
150
+ // ── 6 messages: 5 prior + 1 current ─────────────────────────────────────
151
+
152
+ it("includes all 5 prior messages when 6 total are returned", () => {
153
+ const now = Date.now();
154
+ vi.mocked(getRecentBySenderId).mockReturnValue([
155
+ msg(1, 30, "Dave", "msg 1", now - 50000),
156
+ msg(2, 30, "Dave", "msg 2", now - 40000),
157
+ msg(3, 30, "Dave", "msg 3", now - 30000),
158
+ msg(4, 30, "Dave", "msg 4", now - 20000),
159
+ msg(5, 30, "Dave", "msg 5", now - 10000),
160
+ msg(6, 30, "Dave", "current", now),
161
+ ]);
162
+ const result = enrichGroupPrompt("current", "chat-z", 30);
163
+ expect(result).toContain("Dave's recent messages in this group:");
164
+ for (let i = 1; i <= 5; i++) {
165
+ expect(result).toContain(`msg ${i}`);
166
+ }
167
+ expect(result).toContain("current"); // original prompt
168
+ // The current message's text should NOT appear inside the context block
169
+ const contextBlock = result.split("]\n\n")[0];
170
+ expect(contextBlock).not.toContain("current");
171
+ });
172
+
173
+ // ── text truncation at exactly 200 chars ─────────────────────────────────
174
+
175
+ it("truncates messages at exactly 200 characters (slice boundary)", () => {
176
+ const exactly200 = "a".repeat(200);
177
+ const longText = "a".repeat(250);
178
+ vi.mocked(getRecentBySenderId).mockReturnValue([
179
+ msg(1, 40, "Eve", longText, Date.now() - 2000),
180
+ msg(2, 40, "Eve", "current", Date.now()),
181
+ ]);
182
+ const result = enrichGroupPrompt("current", "chat-trunc", 40);
183
+ expect(result).toContain(exactly200);
184
+ expect(result).not.toContain("a".repeat(201));
185
+ });
186
+
187
+ it("short messages (< 200 chars) are not truncated or padded", () => {
188
+ const shortText = "short message";
189
+ vi.mocked(getRecentBySenderId).mockReturnValue([
190
+ msg(1, 41, "Frank", shortText, Date.now() - 1000),
191
+ msg(2, 41, "Frank", "current", Date.now()),
192
+ ]);
193
+ const result = enrichGroupPrompt("current", "chat-short", 41);
194
+ expect(result).toContain(shortText);
195
+ // The full text appears — no additional characters appended to the message body.
196
+ // Note: the outer context bracket `]` is appended after the last context line
197
+ // in the format `[header:\n lines]`, so we check containment, not endsWith.
198
+ const lines = result.split("\n");
199
+ const priorLine = lines.find((l) => l.includes(shortText));
200
+ expect(priorLine).toBeDefined();
201
+ // Line format: ` [timestamp] short message` optionally followed by `]` (closing bracket)
202
+ // Verify the message text appears after the timestamp bracket and is not padded
203
+ expect(priorLine).toMatch(new RegExp(`\\] ${shortText}\\]?$`));
204
+ });
205
+
206
+ // ── getRecentBySenderId called with correct args ──────────────────────────
207
+
208
+ it("calls getRecentBySenderId with the provided chatId and senderId", () => {
209
+ vi.mocked(getRecentBySenderId).mockReturnValue([]);
210
+ enrichGroupPrompt("hi", "specific-chat-id", 99);
211
+ expect(getRecentBySenderId).toHaveBeenCalledWith("specific-chat-id", 99, 5);
212
+ });
213
+
214
+ it("requests exactly 5 recent messages from history", () => {
215
+ vi.mocked(getRecentBySenderId).mockReturnValue([]);
216
+ enrichGroupPrompt("hi", "chat-count", 55);
217
+ const [, , limit] = vi.mocked(getRecentBySenderId).mock.calls[0];
218
+ expect(limit).toBe(5);
219
+ });
220
+
221
+ // ── output structure ──────────────────────────────────────────────────────
222
+
223
+ it("context block is wrapped with [ ... ] and separated from prompt by blank line", () => {
224
+ vi.mocked(getRecentBySenderId).mockReturnValue([
225
+ msg(1, 50, "Gina", "past msg", Date.now() - 3000),
226
+ msg(2, 50, "Gina", "now", Date.now()),
227
+ ]);
228
+ const result = enrichGroupPrompt("now", "chat-struct", 50);
229
+ // Should start with '[' (the context header)
230
+ expect(result.startsWith("[")).toBe(true);
231
+ // Blank line (\n\n) separates context block from the original prompt
232
+ expect(result).toContain("]\n\nnow");
233
+ });
234
+
235
+ it("each prior message line is indented with two spaces", () => {
236
+ vi.mocked(getRecentBySenderId).mockReturnValue([
237
+ msg(1, 60, "Hank", "indented message", Date.now() - 5000),
238
+ msg(2, 60, "Hank", "current", Date.now()),
239
+ ]);
240
+ const result = enrichGroupPrompt("current", "chat-indent", 60);
241
+ // The format is ` [timestamp] message text`
242
+ const lines = result.split("\n");
243
+ const priorLine = lines.find((l) => l.includes("indented message"));
244
+ expect(priorLine).toBeDefined();
245
+ expect(priorLine!.startsWith(" [")).toBe(true);
246
+ });
247
+
248
+ // ── timestamp is formatted and embedded ──────────────────────────────────
249
+
250
+ it("includes a formatted timestamp in each prior message line", () => {
251
+ const ts = new Date("2020-03-15T12:34:00Z").getTime(); // past year → full date format
252
+ vi.mocked(getRecentBySenderId).mockReturnValue([
253
+ msg(1, 70, "Iris", "timestamped msg", ts),
254
+ msg(2, 70, "Iris", "current", Date.now()),
255
+ ]);
256
+ const result = enrichGroupPrompt("current", "chat-ts", 70);
257
+ // The context block line should contain a bracket-wrapped timestamp
258
+ const lines = result.split("\n");
259
+ const priorLine = lines.find((l) => l.includes("timestamped msg"));
260
+ expect(priorLine).toBeDefined();
261
+ // Format: ` [<timestamp>] text` — there should be a timestamp inside brackets
262
+ expect(priorLine).toMatch(/\[.+\]/);
263
+ });
264
+
265
+ // ── returns prompt unchanged for 0 and 1 message (re-confirmed at boundary)
266
+
267
+ it("returns the prompt unchanged when getRecentBySenderId returns empty array", () => {
268
+ vi.mocked(getRecentBySenderId).mockReturnValue([]);
269
+ const result = enrichGroupPrompt("only message", "chat-empty", 1);
270
+ expect(result).toBe("only message");
271
+ });
272
+
273
+ it("returns the prompt unchanged when exactly 1 message is returned (the current)", () => {
274
+ vi.mocked(getRecentBySenderId).mockReturnValue([
275
+ msg(1, 80, "Jan", "the only message", Date.now()),
276
+ ]);
277
+ const result = enrichGroupPrompt("the only message", "chat-one", 80);
278
+ expect(result).toBe("the only message");
279
+ });
280
+
281
+ // ── multi-line prompt body is preserved ───────────────────────────────────
282
+
283
+ it("preserves a multi-line original prompt after the context block", () => {
284
+ vi.mocked(getRecentBySenderId).mockReturnValue([
285
+ msg(1, 90, "Karl", "prior", Date.now() - 1000),
286
+ msg(2, 90, "Karl", "current line 1\ncurrent line 2", Date.now()),
287
+ ]);
288
+ const result = enrichGroupPrompt(
289
+ "current line 1\ncurrent line 2",
290
+ "chat-ml",
291
+ 90,
292
+ );
293
+ expect(result).toContain("current line 1\ncurrent line 2");
294
+ expect(result.endsWith("current line 1\ncurrent line 2")).toBe(true);
295
+ });
296
+ });