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,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ markdownToTelegramHtml,
4
+ splitMessage,
5
+ escapeHtml,
6
+ } from "../frontend/telegram/formatting.js";
7
+
8
+ describe("markdownToTelegramHtml", () => {
9
+ it("converts bold markdown to HTML", () => {
10
+ expect(markdownToTelegramHtml("**hello**")).toContain("<b>hello</b>");
11
+ });
12
+
13
+ it("converts italic markdown to HTML", () => {
14
+ expect(markdownToTelegramHtml("_italic_")).toContain("<i>italic</i>");
15
+ });
16
+
17
+ it("converts safe https links to anchor tags", () => {
18
+ const result = markdownToTelegramHtml("[click](https://example.com)");
19
+ expect(result).toContain('<a href="https://example.com">click</a>');
20
+ });
21
+
22
+ it("strips unsafe non-https links (covers false branch of line 85)", () => {
23
+ // javascript: URL is not safe — should output just the text, not an anchor tag
24
+ const result = markdownToTelegramHtml("[click](javascript:alert('xss'))");
25
+ expect(result).not.toContain("<a href");
26
+ expect(result).toContain("click");
27
+ });
28
+
29
+ it("strips unsafe file:// links", () => {
30
+ const result = markdownToTelegramHtml("[file](file:///etc/passwd)");
31
+ expect(result).not.toContain("<a href");
32
+ expect(result).toContain("file");
33
+ });
34
+
35
+ it("converts inline code to <code>", () => {
36
+ expect(markdownToTelegramHtml("`code`")).toContain("<code>code</code>");
37
+ });
38
+
39
+ it("converts fenced code blocks to pre/code", () => {
40
+ const result = markdownToTelegramHtml("```\nconsole.log('hi')\n```");
41
+ expect(result).toContain("<pre><code>");
42
+ expect(result).toContain("console.log");
43
+ });
44
+
45
+ it("converts strikethrough to <s>", () => {
46
+ expect(markdownToTelegramHtml("~~deleted~~")).toContain("<s>deleted</s>");
47
+ });
48
+
49
+ it("escapes HTML special chars in text", () => {
50
+ const result = markdownToTelegramHtml("a & b < c > d");
51
+ expect(result).toContain("&amp;");
52
+ expect(result).toContain("&lt;");
53
+ expect(result).toContain("&gt;");
54
+ });
55
+ });
56
+
57
+ describe("splitMessage", () => {
58
+ it("returns single chunk for short messages", () => {
59
+ const chunks = splitMessage("Hello", 100);
60
+ expect(chunks).toHaveLength(1);
61
+ expect(chunks[0]).toBe("Hello");
62
+ });
63
+
64
+ it("splits long messages at word boundaries", () => {
65
+ const long = "word ".repeat(200);
66
+ const chunks = splitMessage(long, 100);
67
+ expect(chunks.length).toBeGreaterThan(1);
68
+ for (const chunk of chunks) {
69
+ expect(chunk.length).toBeLessThanOrEqual(100);
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("escapeHtml", () => {
75
+ it("escapes angle brackets and ampersand", () => {
76
+ expect(escapeHtml("<>&")).toBe("&lt;&gt;&amp;");
77
+ });
78
+
79
+ it("passes through plain text unchanged", () => {
80
+ expect(escapeHtml("hello world")).toBe("hello world");
81
+ });
82
+ });
@@ -77,8 +77,17 @@ vi.mock("../storage/sessions.js", () => ({
77
77
  getAllSessions: () => mockGetAllSessions(),
78
78
  }));
79
79
 
80
+ const mockGetLoadedPlugins = vi.fn(
81
+ () =>
82
+ [] as Array<{
83
+ plugin: Record<string, unknown>;
84
+ config: Record<string, unknown>;
85
+ envVars: Record<string, string>;
86
+ path: string;
87
+ }>,
88
+ );
80
89
  vi.mock("../core/plugin.js", () => ({
81
- getLoadedPlugins: () => [],
90
+ getLoadedPlugins: () => mockGetLoadedPlugins(),
82
91
  }));
83
92
 
84
93
  import {
@@ -363,5 +372,203 @@ describe("built-in commands", () => {
363
372
  expect(ctx.initNewChat).not.toHaveBeenCalled();
364
373
  expect(ctx.renderer.writeSystem).toHaveBeenCalledWith("Cancelled.");
365
374
  });
375
+
376
+ it("shows turn count when session has no name (false ternary branch)", async () => {
377
+ // Selected session has no sessionName → shows "(N turns)" instead of '"name"'
378
+ mockGetAllSessions.mockReturnValueOnce([
379
+ {
380
+ chatId: "t_unnamed_session",
381
+ info: { turns: 3, lastActive: Date.now(), sessionName: undefined },
382
+ },
383
+ ]);
384
+ const ctx = makeMockContext({
385
+ waitForInput: vi.fn().mockResolvedValue("1"),
386
+ });
387
+ await tryRunCommand("/resume", ctx);
388
+ expect(ctx.initNewChat).toHaveBeenCalledWith("t_unnamed_session");
389
+ expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
390
+ expect.stringContaining("3 turns"),
391
+ );
392
+ });
393
+ });
394
+
395
+ describe("/model — ?? fallback to config.model", () => {
396
+ it("shows config model when getChatSettings has no model set", async () => {
397
+ // Default mockGetChatSettings returns {} (no model) → triggers ?? ctx.config.model
398
+ mockGetChatSettings.mockReturnValueOnce({});
399
+ const ctx = makeMockContext();
400
+ await tryRunCommand("/model", ctx);
401
+ expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
402
+ expect.stringContaining("claude-sonnet-4-6"),
403
+ );
404
+ });
405
+ });
406
+ });
407
+
408
+ describe("/status command", () => {
409
+ beforeEach(() => {
410
+ clearCommands();
411
+ registerBuiltinCommands();
412
+ });
413
+
414
+ it("displays session stats without plugins", async () => {
415
+ mockGetSessionInfo.mockReturnValueOnce({
416
+ turns: 5,
417
+ sessionName: undefined,
418
+ usage: {
419
+ totalInputTokens: 1000,
420
+ totalOutputTokens: 500,
421
+ totalCacheRead: 200,
422
+ totalCacheWrite: 0,
423
+ lastPromptTokens: 100,
424
+ estimatedCostUsd: 0.01,
425
+ totalResponseMs: 5000,
426
+ lastResponseMs: 1000,
427
+ fastestResponseMs: 500,
428
+ },
429
+ });
430
+ const ctx = makeMockContext();
431
+ await tryRunCommand("/status", ctx);
432
+ expect(ctx.renderer.writeln).toHaveBeenCalled();
433
+ expect(ctx.reprompt).toHaveBeenCalled();
434
+ });
435
+
436
+ it("displays session name when set", async () => {
437
+ mockGetSessionInfo.mockReturnValueOnce({
438
+ turns: 3,
439
+ sessionName: "My Work Session",
440
+ usage: {
441
+ totalInputTokens: 500,
442
+ totalOutputTokens: 200,
443
+ totalCacheRead: 100,
444
+ totalCacheWrite: 0,
445
+ lastPromptTokens: 50,
446
+ estimatedCostUsd: 0.005,
447
+ totalResponseMs: 2000,
448
+ lastResponseMs: 500,
449
+ fastestResponseMs: 300,
450
+ },
451
+ });
452
+ const ctx = makeMockContext();
453
+ await tryRunCommand("/status", ctx);
454
+ // Should mention the session name
455
+ const calls = (
456
+ ctx.renderer.writeln as ReturnType<typeof vi.fn>
457
+ ).mock.calls.flat();
458
+ const output = calls.join(" ");
459
+ expect(output).toContain("My Work Session");
460
+ });
461
+
462
+ it("/status shows plugins section when plugins are loaded", async () => {
463
+ mockGetLoadedPlugins.mockReturnValueOnce([
464
+ {
465
+ plugin: {
466
+ name: "my-plugin",
467
+ version: "1.0",
468
+ description: "A test plugin",
469
+ mcpServerPath: "/path/tools.ts",
470
+ },
471
+ config: {},
472
+ envVars: {},
473
+ path: "/fake/my-plugin",
474
+ },
475
+ ]);
476
+ const ctx = makeMockContext();
477
+ await tryRunCommand("/status", ctx);
478
+ const calls = (
479
+ ctx.renderer.writeln as ReturnType<typeof vi.fn>
480
+ ).mock.calls.flat();
481
+ const out = calls.join(" ");
482
+ expect(out).toContain("my-plugin");
483
+ expect(out).toContain("Plugins");
484
+ expect(ctx.reprompt).toHaveBeenCalled();
485
+ });
486
+
487
+ it("/status shows 'actions only' for plugin without mcpServerPath", async () => {
488
+ mockGetLoadedPlugins.mockReturnValueOnce([
489
+ {
490
+ plugin: { name: "actions-only-plugin" },
491
+ config: {},
492
+ envVars: {},
493
+ path: "/fake/ao-plugin",
494
+ },
495
+ ]);
496
+ const ctx = makeMockContext();
497
+ await tryRunCommand("/status", ctx);
498
+ const calls = (
499
+ ctx.renderer.writeln as ReturnType<typeof vi.fn>
500
+ ).mock.calls.flat();
501
+ expect(calls.join(" ")).toContain("actions only");
502
+ });
503
+ });
504
+
505
+ describe("/effort command", () => {
506
+ beforeEach(() => {
507
+ clearCommands();
508
+ registerBuiltinCommands();
509
+ vi.clearAllMocks();
510
+ });
511
+
512
+ it("shows current effort when no arg given", async () => {
513
+ mockGetChatSettings.mockReturnValueOnce({ effort: "high" });
514
+ const ctx = makeMockContext();
515
+ await tryRunCommand("/effort", ctx);
516
+ expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
517
+ expect.stringContaining("high"),
518
+ );
519
+ expect(ctx.reprompt).toHaveBeenCalled();
520
+ });
521
+
522
+ it("shows adaptive when effort is not set", async () => {
523
+ mockGetChatSettings.mockReturnValueOnce({});
524
+ const ctx = makeMockContext();
525
+ await tryRunCommand("/effort", ctx);
526
+ expect(ctx.renderer.writeSystem).toHaveBeenCalledWith(
527
+ expect.stringContaining("adaptive"),
528
+ );
529
+ expect(ctx.reprompt).toHaveBeenCalled();
530
+ });
531
+
532
+ it("sets effort and reprompts when arg given", async () => {
533
+ const ctx = makeMockContext();
534
+ await tryRunCommand("/effort max", ctx);
535
+ expect(mockSetChatEffort).toHaveBeenCalledWith("t_test_123", "max");
536
+ expect(ctx.renderer.writeSystem).toHaveBeenCalledWith("Effort → max");
537
+ expect(ctx.reprompt).toHaveBeenCalled();
538
+ });
539
+
540
+ it("sets effort to undefined for 'adaptive' arg", async () => {
541
+ const ctx = makeMockContext();
542
+ await tryRunCommand("/effort adaptive", ctx);
543
+ expect(mockSetChatEffort).toHaveBeenCalledWith("t_test_123", undefined);
544
+ expect(ctx.reprompt).toHaveBeenCalled();
545
+ });
546
+ });
547
+
548
+ describe("/resume sort order", () => {
549
+ beforeEach(() => {
550
+ clearCommands();
551
+ registerBuiltinCommands();
552
+ vi.clearAllMocks();
553
+ });
554
+
555
+ it("sorts sessions by lastActive descending", async () => {
556
+ const now = Date.now();
557
+ mockGetAllSessions.mockReturnValueOnce([
558
+ {
559
+ chatId: "t_older",
560
+ info: { turns: 2, lastActive: now - 7200_000, sessionName: "older" },
561
+ },
562
+ {
563
+ chatId: "t_newer",
564
+ info: { turns: 3, lastActive: now - 3600_000, sessionName: "newer" },
565
+ },
566
+ ]);
567
+ const ctx = makeMockContext({
568
+ waitForInput: vi.fn().mockResolvedValue("1"),
569
+ });
570
+ await tryRunCommand("/resume", ctx);
571
+ // Selection "1" should pick the first listed session (most recent = t_newer)
572
+ expect(ctx.initNewChat).toHaveBeenCalledWith("t_newer");
366
573
  });
367
574
  });
@@ -45,6 +45,14 @@ describe("wrap", () => {
45
45
  it("returns text as-is if width too narrow", () => {
46
46
  expect(wrap("test", 70, 80)).toBe("test");
47
47
  });
48
+
49
+ it("skips final cur push when cur is empty (line 66 FALSE branch)", () => {
50
+ // A line of all spaces: length > width, but all split words are empty strings
51
+ // → cur never accumulates → `if (cur)` at end is false → no trailing push
52
+ const result = wrap(" ".repeat(26), 0, 25);
53
+ // Should produce an empty string (no content words to push)
54
+ expect(result).toBe("");
55
+ });
48
56
  });
49
57
 
50
58
  // ── formatTimeAgo ────────────────────────────────────────────────────────────
@@ -128,9 +136,66 @@ describe("extractToolDetail", () => {
128
136
  expect(extractToolDetail({ _chatId: "1", foo: "bar" }, 80)).toBe("foo=bar");
129
137
  });
130
138
 
139
+ it("truncates string values longer than 30 chars in fallback (line 112 TRUE branch)", () => {
140
+ // v.length > 30 → `v.slice(0, 30) + "..."` branch
141
+ const longVal = "a".repeat(35);
142
+ const result = extractToolDetail({ custom: longVal }, 80);
143
+ expect(result).toBe(`custom=${"a".repeat(30)}...`);
144
+ });
145
+
131
146
  it("returns empty string for empty input", () => {
132
147
  expect(extractToolDetail({}, 80)).toBe("");
133
148
  });
149
+
150
+ it("extracts package_url", () => {
151
+ expect(
152
+ extractToolDetail({ package_url: "https://pkg.example.com/lib" }, 80),
153
+ ).toBe("https://pkg.example.com/lib");
154
+ });
155
+
156
+ it("extracts packages array", () => {
157
+ expect(extractToolDetail({ packages: ["lodash", "express"] }, 80)).toBe(
158
+ "lodash, express",
159
+ );
160
+ });
161
+
162
+ it("skips empty string values in fallback", () => {
163
+ // Empty string value (v.length === 0) is skipped by the `v.length > 0` check
164
+ expect(extractToolDetail({ empty: "", valid: "value" }, 80)).toBe(
165
+ "valid=value",
166
+ );
167
+ });
168
+
169
+ it("skips object/null values in fallback (neither string nor number/boolean)", () => {
170
+ // Object values don't match either string or number/boolean conditions → skipped
171
+ expect(extractToolDetail({ obj: { nested: "val" }, num: 42 }, 80)).toBe(
172
+ "num=42",
173
+ );
174
+ });
175
+
176
+ it("extracts query field", () => {
177
+ expect(extractToolDetail({ query: "search term" }, 80)).toBe("search term");
178
+ });
179
+
180
+ it("extracts url field", () => {
181
+ expect(extractToolDetail({ url: "https://example.com" }, 80)).toBe(
182
+ "https://example.com",
183
+ );
184
+ });
185
+
186
+ it("extracts type field", () => {
187
+ expect(extractToolDetail({ type: "message" }, 80)).toBe("message");
188
+ });
189
+
190
+ it("extracts name field", () => {
191
+ expect(extractToolDetail({ name: "my-tool" }, 80)).toBe("my-tool");
192
+ });
193
+
194
+ it("extracts model field", () => {
195
+ expect(extractToolDetail({ model: "claude-sonnet-4-6" }, 80)).toBe(
196
+ "claude-sonnet-4-6",
197
+ );
198
+ });
134
199
  });
135
200
 
136
201
  // ── cleanToolName ────────────────────────────────────────────────────────────
@@ -272,6 +337,164 @@ describe("createRenderer", () => {
272
337
  const r = createRenderer(40);
273
338
  expect(r.cols).toBe(40);
274
339
  });
340
+
341
+ it("formats million-range token counts with M suffix", () => {
342
+ const r = createRenderer(80);
343
+ output = [];
344
+ r.renderStatusLine(5000, 1, {
345
+ model: "Sonnet",
346
+ turns: 1,
347
+ inputTokens: 1_500_000,
348
+ outputTokens: 500_000,
349
+ cacheHitPct: 0,
350
+ costUsd: 0,
351
+ });
352
+ // 2,000,000 tokens → 2.0M
353
+ expect(output.join("")).toContain("2.0M tok");
354
+ });
355
+
356
+ it("formats thousand-range token counts with k suffix", () => {
357
+ const r = createRenderer(80);
358
+ output = [];
359
+ r.renderStatusLine(2000, 1, {
360
+ model: "Sonnet",
361
+ turns: 1,
362
+ inputTokens: 5_000,
363
+ outputTokens: 3_500,
364
+ cacheHitPct: 0,
365
+ costUsd: 0,
366
+ });
367
+ // 8,500 tokens → 8.5k
368
+ expect(output.join("")).toContain("8.5k tok");
369
+ });
370
+
371
+ it("startSpinner writes to stdout on each tick", () => {
372
+ vi.useFakeTimers();
373
+ const r = createRenderer(80);
374
+ output = [];
375
+ r.startSpinner("loading");
376
+ vi.advanceTimersByTime(100);
377
+ // At least one frame written
378
+ expect(output.join("")).toContain("\r");
379
+ r.stopSpinner();
380
+ vi.useRealTimers();
381
+ });
382
+
383
+ it("stopSpinner clears the line", () => {
384
+ vi.useFakeTimers();
385
+ const r = createRenderer(80);
386
+ output = [];
387
+ r.startSpinner("working");
388
+ r.stopSpinner();
389
+ expect(output.join("")).toContain("\x1b[2K\r");
390
+ vi.useRealTimers();
391
+ });
392
+
393
+ it("stopSpinner is safe to call when not running", () => {
394
+ const r = createRenderer(80);
395
+ output = [];
396
+ expect(() => r.stopSpinner()).not.toThrow();
397
+ });
398
+
399
+ it("updateSpinnerLabel changes the displayed label", () => {
400
+ vi.useFakeTimers();
401
+ const r = createRenderer(80);
402
+ output = [];
403
+ r.startSpinner("initial");
404
+ r.updateSpinnerLabel("updated");
405
+ vi.advanceTimersByTime(100);
406
+ expect(output.join("")).toContain("updated");
407
+ r.stopSpinner();
408
+ vi.useRealTimers();
409
+ });
410
+
411
+ it("startSpinner stops any previous spinner before starting", () => {
412
+ vi.useFakeTimers();
413
+ const r = createRenderer(80);
414
+ r.startSpinner("first");
415
+ output = [];
416
+ r.startSpinner("second"); // should stop first and start new one
417
+ vi.advanceTimersByTime(100);
418
+ expect(output.join("")).toContain("second");
419
+ r.stopSpinner();
420
+ vi.useRealTimers();
421
+ });
422
+
423
+ it("renderStatusLine includes session name when provided", () => {
424
+ const r = createRenderer(80);
425
+ output = [];
426
+ r.renderStatusLine(1000, 0, {
427
+ model: "Sonnet",
428
+ turns: 1,
429
+ inputTokens: 10,
430
+ outputTokens: 5,
431
+ cacheHitPct: 0,
432
+ costUsd: 0,
433
+ sessionName: "My Work",
434
+ });
435
+ expect(output.join("")).toContain("My Work");
436
+ });
437
+
438
+ it("renderToolCall called twice — hasToolOutput flag suppresses second blank line", () => {
439
+ const r = createRenderer(80);
440
+ output = [];
441
+ r.renderToolCall("Bash", { command: "ls" });
442
+ const firstCallLen = output.join("").length;
443
+ r.renderToolCall("Read", { file_path: "/tmp/x.txt" });
444
+ const secondCallLen = output.join("").length;
445
+ // Both calls should add output (second call doesn't add extra blank line)
446
+ expect(secondCallLen).toBeGreaterThan(firstCallLen);
447
+ expect(output.join("")).toContain("Read");
448
+ });
449
+
450
+ it("creates renderer with auto column width when no cols given", () => {
451
+ // Covers the `cols ?? Math.min(...)` right branch
452
+ const r = createRenderer(); // no cols argument
453
+ expect(r.cols).toBeGreaterThan(0);
454
+ });
455
+
456
+ it("renderToolCall with numeric/boolean input values", () => {
457
+ const r = createRenderer(80);
458
+ output = [];
459
+ r.renderToolCall("Bash", { timeout: 30, verbose: true });
460
+ const text = output.join("");
461
+ // numeric and boolean values should appear
462
+ expect(text).toContain("Bash");
463
+ });
464
+
465
+ it("renderToolCall with long string value (>30 chars) shows detail", () => {
466
+ const r = createRenderer(80);
467
+ output = [];
468
+ // A value > 30 chars covers the v.length > 30 branch in extractToolDetail
469
+ r.renderToolCall("Bash", { command: "A".repeat(40) });
470
+ const text = output.join("");
471
+ expect(text).toContain("Bash");
472
+ expect(text).toContain("A"); // detail was extracted
473
+ });
474
+
475
+ it("renderToolCall with empty input produces no detail", () => {
476
+ // input has only _chatId (skipped) → detail="" → `detail ? ... : ""` returns ""
477
+ const r = createRenderer(80);
478
+ output = [];
479
+ r.renderToolCall("SomeToolName", { _chatId: "ignored" });
480
+ const text = output.join("");
481
+ expect(text).toContain("SomeToolName");
482
+ });
483
+
484
+ it("spinner pads shorter label to match previous line length", () => {
485
+ vi.useFakeTimers();
486
+ const r = createRenderer(80);
487
+ output = [];
488
+ r.startSpinner("a very long spinner label here");
489
+ vi.advanceTimersByTime(90); // first tick
490
+ r.updateSpinnerLabel("short"); // shorter label
491
+ vi.advanceTimersByTime(90); // second tick — padding should be added
492
+ const text = output.join("");
493
+ // Should include a carriage return (spinner output)
494
+ expect(text).toContain("\r");
495
+ r.stopSpinner();
496
+ vi.useRealTimers();
497
+ });
275
498
  });
276
499
 
277
500
  // Need to import afterEach for cleanup
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ setTimezone,
4
+ getTimezone,
5
+ formatSmartTimestamp,
6
+ formatFullDatetime,
7
+ formatRelativeAge,
8
+ } from "../util/time.js";
9
+
10
+ describe("time utilities", () => {
11
+ describe("setTimezone / getTimezone", () => {
12
+ afterEach(() => setTimezone(undefined));
13
+
14
+ it("returns system timezone when none configured", () => {
15
+ setTimezone(undefined);
16
+ expect(getTimezone()).toBeTruthy();
17
+ expect(typeof getTimezone()).toBe("string");
18
+ });
19
+
20
+ it("returns configured timezone", () => {
21
+ setTimezone("America/New_York");
22
+ expect(getTimezone()).toBe("America/New_York");
23
+ });
24
+
25
+ it("resets to system timezone when set to undefined", () => {
26
+ setTimezone("Europe/Warsaw");
27
+ setTimezone(undefined);
28
+ expect(getTimezone()).not.toBe("Europe/Warsaw");
29
+ });
30
+ });
31
+
32
+ describe("formatSmartTimestamp", () => {
33
+ afterEach(() => setTimezone(undefined));
34
+
35
+ it("returns HH:MM for today", () => {
36
+ const now = Date.now();
37
+ const result = formatSmartTimestamp(now);
38
+ expect(result).toMatch(/^\d{2}:\d{2}$/);
39
+ });
40
+
41
+ it("returns 'Yesterday HH:MM' for yesterday", () => {
42
+ const yesterday = Date.now() - 86_400_000;
43
+ const result = formatSmartTimestamp(yesterday);
44
+ expect(result).toMatch(/^Yesterday \d{2}:\d{2}$/);
45
+ });
46
+
47
+ it("returns 'Month Day HH:MM' for a date earlier this year", () => {
48
+ // Use a date 60 days ago (safe to assume still same year in most cases)
49
+ const sixtyDaysAgo = Date.now() - 60 * 86_400_000;
50
+ const date = new Date(sixtyDaysAgo);
51
+ const currentYear = new Date().getFullYear();
52
+ // Only test if still in same year
53
+ if (date.getFullYear() === currentYear) {
54
+ const result = formatSmartTimestamp(sixtyDaysAgo);
55
+ // Format: "Apr 2 14:32"
56
+ expect(result).toMatch(/^[A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}$/);
57
+ }
58
+ });
59
+
60
+ it("returns 'YYYY-MM-DD HH:MM' for a date in a different year", () => {
61
+ // A timestamp from 2020 — safely in a past year
62
+ const oldDate = new Date("2020-06-15T12:00:00Z").getTime();
63
+ const result = formatSmartTimestamp(oldDate);
64
+ expect(result).toMatch(/^2020-\d{2}-\d{2} \d{2}:\d{2}$/);
65
+ });
66
+ });
67
+
68
+ describe("formatFullDatetime", () => {
69
+ afterEach(() => setTimezone(undefined));
70
+
71
+ it("returns a non-empty string", () => {
72
+ expect(formatFullDatetime().length).toBeGreaterThan(10);
73
+ });
74
+
75
+ it("includes timezone name", () => {
76
+ setTimezone("UTC");
77
+ expect(formatFullDatetime()).toContain("UTC");
78
+ });
79
+
80
+ it("includes a weekday abbreviation", () => {
81
+ const result = formatFullDatetime();
82
+ expect(result).toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
83
+ });
84
+ });
85
+
86
+ describe("formatRelativeAge", () => {
87
+ it("returns 'just now' for timestamps within the last minute", () => {
88
+ expect(formatRelativeAge(Date.now() - 30_000)).toBe("just now");
89
+ expect(formatRelativeAge(Date.now())).toBe("just now");
90
+ });
91
+
92
+ it("returns minutes ago for timestamps within the last hour", () => {
93
+ expect(formatRelativeAge(Date.now() - 5 * 60_000)).toBe("5m ago");
94
+ expect(formatRelativeAge(Date.now() - 59 * 60_000)).toBe("59m ago");
95
+ });
96
+
97
+ it("returns hours ago for timestamps within the last day", () => {
98
+ expect(formatRelativeAge(Date.now() - 3 * 3_600_000)).toBe("3h ago");
99
+ expect(formatRelativeAge(Date.now() - 23 * 3_600_000)).toBe("23h ago");
100
+ });
101
+
102
+ it("returns days ago for old timestamps", () => {
103
+ expect(formatRelativeAge(Date.now() - 2 * 86_400_000)).toBe("2d ago");
104
+ expect(formatRelativeAge(Date.now() - 30 * 86_400_000)).toBe("30d ago");
105
+ });
106
+ });
107
+ });