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.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- 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("&");
|
|
52
|
+
expect(result).toContain("<");
|
|
53
|
+
expect(result).toContain(">");
|
|
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("<>&");
|
|
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
|
+
});
|