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.
- package/README.md +137 -0
- package/bin/talon.js +5 -0
- package/package.json +86 -0
- package/prompts/base.md +13 -0
- package/prompts/custom.md.example +22 -0
- package/prompts/dream.md +41 -0
- package/prompts/identity.md +45 -0
- package/prompts/teams.md +52 -0
- package/prompts/telegram.md +89 -0
- package/prompts/terminal.md +13 -0
- package/src/__tests__/chat-id.test.ts +91 -0
- package/src/__tests__/chat-settings.test.ts +337 -0
- package/src/__tests__/config.test.ts +546 -0
- package/src/__tests__/cron-store.test.ts +440 -0
- package/src/__tests__/daily-log.test.ts +146 -0
- package/src/__tests__/dispatcher.test.ts +383 -0
- package/src/__tests__/errors.test.ts +240 -0
- package/src/__tests__/fuzz.test.ts +302 -0
- package/src/__tests__/gateway-actions.test.ts +1453 -0
- package/src/__tests__/gateway-context.test.ts +102 -0
- package/src/__tests__/gateway-http.test.ts +245 -0
- package/src/__tests__/handlers.test.ts +351 -0
- package/src/__tests__/history-persistence.test.ts +172 -0
- package/src/__tests__/history.test.ts +659 -0
- package/src/__tests__/integration.test.ts +189 -0
- package/src/__tests__/log.test.ts +110 -0
- package/src/__tests__/media-index.test.ts +277 -0
- package/src/__tests__/plugin.test.ts +317 -0
- package/src/__tests__/prompt-builder.test.ts +71 -0
- package/src/__tests__/sessions.test.ts +594 -0
- package/src/__tests__/teams-frontend.test.ts +239 -0
- package/src/__tests__/telegram.test.ts +177 -0
- package/src/__tests__/terminal-commands.test.ts +367 -0
- package/src/__tests__/terminal-frontend.test.ts +141 -0
- package/src/__tests__/terminal-renderer.test.ts +278 -0
- package/src/__tests__/watchdog.test.ts +287 -0
- package/src/__tests__/workspace.test.ts +184 -0
- package/src/backend/claude-sdk/index.ts +438 -0
- package/src/backend/claude-sdk/tools.ts +605 -0
- package/src/backend/opencode/index.ts +252 -0
- package/src/bootstrap.ts +134 -0
- package/src/cli.ts +611 -0
- package/src/core/cron.ts +148 -0
- package/src/core/dispatcher.ts +126 -0
- package/src/core/dream.ts +295 -0
- package/src/core/errors.ts +206 -0
- package/src/core/gateway-actions.ts +267 -0
- package/src/core/gateway.ts +258 -0
- package/src/core/plugin.ts +432 -0
- package/src/core/prompt-builder.ts +43 -0
- package/src/core/pulse.ts +175 -0
- package/src/core/types.ts +85 -0
- package/src/frontend/teams/actions.ts +101 -0
- package/src/frontend/teams/formatting.ts +220 -0
- package/src/frontend/teams/graph.ts +297 -0
- package/src/frontend/teams/index.ts +308 -0
- package/src/frontend/teams/proxy-fetch.ts +28 -0
- package/src/frontend/teams/tools.ts +177 -0
- package/src/frontend/telegram/actions.ts +437 -0
- package/src/frontend/telegram/admin.ts +178 -0
- package/src/frontend/telegram/callbacks.ts +251 -0
- package/src/frontend/telegram/commands.ts +543 -0
- package/src/frontend/telegram/formatting.ts +101 -0
- package/src/frontend/telegram/handlers.ts +1008 -0
- package/src/frontend/telegram/helpers.ts +105 -0
- package/src/frontend/telegram/index.ts +130 -0
- package/src/frontend/telegram/middleware.ts +177 -0
- package/src/frontend/telegram/userbot.ts +546 -0
- package/src/frontend/terminal/commands.ts +303 -0
- package/src/frontend/terminal/index.ts +282 -0
- package/src/frontend/terminal/input.ts +297 -0
- package/src/frontend/terminal/renderer.ts +248 -0
- package/src/index.ts +144 -0
- package/src/login.ts +89 -0
- package/src/storage/chat-settings.ts +218 -0
- package/src/storage/cron-store.ts +165 -0
- package/src/storage/daily-log.ts +97 -0
- package/src/storage/history.ts +278 -0
- package/src/storage/media-index.ts +116 -0
- package/src/storage/sessions.ts +328 -0
- package/src/util/chat-id.ts +21 -0
- package/src/util/config.ts +244 -0
- package/src/util/log.ts +122 -0
- package/src/util/paths.ts +80 -0
- package/src/util/time.ts +86 -0
- package/src/util/trace.ts +35 -0
- package/src/util/watchdog.ts +108 -0
- package/src/util/workspace.ts +208 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock picocolors to return raw strings (no ANSI)
|
|
4
|
+
vi.mock("picocolors", () => ({
|
|
5
|
+
default: {
|
|
6
|
+
cyan: (s: string) => s,
|
|
7
|
+
green: (s: string) => s,
|
|
8
|
+
dim: (s: string) => s,
|
|
9
|
+
red: (s: string) => s,
|
|
10
|
+
bold: (s: string) => s,
|
|
11
|
+
yellow: (s: string) => s,
|
|
12
|
+
underline: (s: string) => s,
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
wrap,
|
|
18
|
+
formatTimeAgo,
|
|
19
|
+
extractToolDetail,
|
|
20
|
+
cleanToolName,
|
|
21
|
+
createRenderer,
|
|
22
|
+
} from "../frontend/terminal/renderer.js";
|
|
23
|
+
|
|
24
|
+
// ── wrap ─────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe("wrap", () => {
|
|
27
|
+
it("preserves short lines", () => {
|
|
28
|
+
expect(wrap("hello world", 2, 80)).toBe(" hello world");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("wraps long lines", () => {
|
|
32
|
+
const text = "the quick brown fox jumps over the lazy dog";
|
|
33
|
+
const result = wrap(text, 2, 30);
|
|
34
|
+
// Every line should be ≤ 30 chars
|
|
35
|
+
for (const line of result.split("\n")) {
|
|
36
|
+
expect(line.length).toBeLessThanOrEqual(30);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("preserves existing newlines", () => {
|
|
41
|
+
const result = wrap("line1\nline2", 2, 80);
|
|
42
|
+
expect(result).toBe(" line1\n line2");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns text as-is if width too narrow", () => {
|
|
46
|
+
expect(wrap("test", 70, 80)).toBe("test");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// ── formatTimeAgo ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("formatTimeAgo", () => {
|
|
53
|
+
it('returns "just now" for recent timestamps', () => {
|
|
54
|
+
expect(formatTimeAgo(Date.now())).toBe("just now");
|
|
55
|
+
expect(formatTimeAgo(Date.now() - 30_000)).toBe("just now");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns minutes for < 1 hour", () => {
|
|
59
|
+
expect(formatTimeAgo(Date.now() - 5 * 60_000)).toBe("5m ago");
|
|
60
|
+
expect(formatTimeAgo(Date.now() - 59 * 60_000)).toBe("59m ago");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns hours for < 1 day", () => {
|
|
64
|
+
expect(formatTimeAgo(Date.now() - 2 * 3_600_000)).toBe("2h ago");
|
|
65
|
+
expect(formatTimeAgo(Date.now() - 23 * 3_600_000)).toBe("23h ago");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns days for >= 1 day", () => {
|
|
69
|
+
expect(formatTimeAgo(Date.now() - 86_400_000)).toBe("1d ago");
|
|
70
|
+
expect(formatTimeAgo(Date.now() - 7 * 86_400_000)).toBe("7d ago");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── extractToolDetail ────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("extractToolDetail", () => {
|
|
77
|
+
it("prefers description over command", () => {
|
|
78
|
+
const detail = extractToolDetail(
|
|
79
|
+
{ command: "git status --long", description: "Show git status" },
|
|
80
|
+
80,
|
|
81
|
+
);
|
|
82
|
+
expect(detail).toBe("Show git status");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("falls back to command when no description", () => {
|
|
86
|
+
const detail = extractToolDetail({ command: "ls -la" }, 80);
|
|
87
|
+
expect(detail).toBe("ls -la");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("truncates long commands with ellipsis", () => {
|
|
91
|
+
const longCmd = "a".repeat(100);
|
|
92
|
+
const detail = extractToolDetail({ command: longCmd }, 50);
|
|
93
|
+
expect(detail.length).toBeLessThanOrEqual(50);
|
|
94
|
+
expect(detail).toMatch(/\.\.\.$/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("extracts file_path", () => {
|
|
98
|
+
expect(extractToolDetail({ file_path: "/src/index.ts" }, 80)).toBe(
|
|
99
|
+
"/src/index.ts",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("extracts pattern + path", () => {
|
|
104
|
+
expect(extractToolDetail({ pattern: "*.ts", path: "/src" }, 80)).toBe(
|
|
105
|
+
"*.ts in /src",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("extracts pattern alone", () => {
|
|
110
|
+
expect(extractToolDetail({ pattern: "*.ts" }, 80)).toBe("*.ts");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("extracts action", () => {
|
|
114
|
+
expect(extractToolDetail({ action: "deploy" }, 80)).toBe("deploy");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("extracts build_number with hash", () => {
|
|
118
|
+
expect(extractToolDetail({ build_number: 42 }, 80)).toBe("#42");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("falls back to key=value pairs", () => {
|
|
122
|
+
expect(extractToolDetail({ foo: "bar", num: 5 }, 80)).toBe(
|
|
123
|
+
"foo=bar, num=5",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("skips _chatId in fallback", () => {
|
|
128
|
+
expect(extractToolDetail({ _chatId: "1", foo: "bar" }, 80)).toBe("foo=bar");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns empty string for empty input", () => {
|
|
132
|
+
expect(extractToolDetail({}, 80)).toBe("");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── cleanToolName ────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("cleanToolName", () => {
|
|
139
|
+
it("strips MCP server prefix", () => {
|
|
140
|
+
expect(cleanToolName("mcp__npuw-tools__jenkins_list_builds")).toBe(
|
|
141
|
+
"jenkins_list_builds",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("handles trailing double-underscore by returning original (empty last segment)", () => {
|
|
146
|
+
// When last segment is empty, || fallback returns original name
|
|
147
|
+
expect(cleanToolName("mcp__server__")).toBe("mcp__server__");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("preserves non-MCP names", () => {
|
|
151
|
+
expect(cleanToolName("Bash")).toBe("Bash");
|
|
152
|
+
expect(cleanToolName("Read")).toBe("Read");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles single-segment MCP name", () => {
|
|
156
|
+
expect(cleanToolName("mcp__tool")).toBe("tool");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── createRenderer (output capture) ──────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe("createRenderer", () => {
|
|
163
|
+
let output: string[];
|
|
164
|
+
let originalWrite: typeof process.stdout.write;
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
output = [];
|
|
168
|
+
originalWrite = process.stdout.write;
|
|
169
|
+
process.stdout.write = vi.fn((chunk: unknown) => {
|
|
170
|
+
output.push(String(chunk));
|
|
171
|
+
return true;
|
|
172
|
+
}) as typeof process.stdout.write;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
afterEach(() => {
|
|
176
|
+
process.stdout.write = originalWrite;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("writeln outputs text with newline", () => {
|
|
180
|
+
const r = createRenderer(80);
|
|
181
|
+
r.writeln("hello");
|
|
182
|
+
expect(output.join("")).toContain("hello\n");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("writeSystem wraps in dim", () => {
|
|
186
|
+
const r = createRenderer(80);
|
|
187
|
+
r.writeSystem("test message");
|
|
188
|
+
expect(output.join("")).toContain("test message");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("writeError includes error marker", () => {
|
|
192
|
+
const r = createRenderer(80);
|
|
193
|
+
r.writeError("something failed");
|
|
194
|
+
const text = output.join("");
|
|
195
|
+
expect(text).toContain("something failed");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("renderAssistantMessage includes Talon header", () => {
|
|
199
|
+
const r = createRenderer(80);
|
|
200
|
+
r.renderAssistantMessage("Hello world");
|
|
201
|
+
const text = output.join("");
|
|
202
|
+
expect(text).toContain("Talon");
|
|
203
|
+
expect(text).toContain("Hello world");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("renderToolCall shows tool name and detail", () => {
|
|
207
|
+
const r = createRenderer(80);
|
|
208
|
+
r.renderToolCall("Bash", {
|
|
209
|
+
command: "git status",
|
|
210
|
+
description: "Show git status",
|
|
211
|
+
});
|
|
212
|
+
const text = output.join("");
|
|
213
|
+
expect(text).toContain("Bash");
|
|
214
|
+
expect(text).toContain("Show git status");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("renderToolCall hides TodoRead/TodoWrite", () => {
|
|
218
|
+
const r = createRenderer(80);
|
|
219
|
+
r.renderToolCall("TodoRead", { _chatId: "1" });
|
|
220
|
+
r.renderToolCall("TodoWrite", { _chatId: "1" });
|
|
221
|
+
const text = output.join("");
|
|
222
|
+
expect(text).not.toContain("TodoRead");
|
|
223
|
+
expect(text).not.toContain("TodoWrite");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("renderToolCall strips MCP prefix in display", () => {
|
|
227
|
+
const r = createRenderer(80);
|
|
228
|
+
r.renderToolCall("mcp__server__my_tool", { action: "test" });
|
|
229
|
+
const text = output.join("");
|
|
230
|
+
expect(text).toContain("my tool"); // underscores → spaces
|
|
231
|
+
expect(text).not.toContain("mcp__");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("renderStatusLine shows duration, model, turns, tokens, cache, and tools", () => {
|
|
235
|
+
const r = createRenderer(80);
|
|
236
|
+
r.renderStatusLine(1500, 3, {
|
|
237
|
+
model: "Sonnet 4.6",
|
|
238
|
+
turns: 2,
|
|
239
|
+
inputTokens: 100,
|
|
240
|
+
outputTokens: 50,
|
|
241
|
+
cacheHitPct: 44,
|
|
242
|
+
costUsd: 0,
|
|
243
|
+
});
|
|
244
|
+
const text = output.join("");
|
|
245
|
+
expect(text).toContain("1.5s");
|
|
246
|
+
expect(text).toContain("Sonnet 4.6");
|
|
247
|
+
expect(text).toContain("2 turns");
|
|
248
|
+
expect(text).toContain("150 tok");
|
|
249
|
+
expect(text).toContain("44% cache");
|
|
250
|
+
expect(text).toContain("3 tools");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("renderStatusLine pluralizes tool count correctly", () => {
|
|
254
|
+
const r = createRenderer(80);
|
|
255
|
+
|
|
256
|
+
output = [];
|
|
257
|
+
r.renderStatusLine(1000, 1, {
|
|
258
|
+
model: "Sonnet 4.6",
|
|
259
|
+
turns: 1,
|
|
260
|
+
inputTokens: 10,
|
|
261
|
+
outputTokens: 5,
|
|
262
|
+
cacheHitPct: 0,
|
|
263
|
+
costUsd: 0,
|
|
264
|
+
});
|
|
265
|
+
expect(output.join("")).toContain("1 tool");
|
|
266
|
+
expect(output.join("")).not.toContain("1 tools");
|
|
267
|
+
expect(output.join("")).toContain("1 turn");
|
|
268
|
+
expect(output.join("")).not.toContain("1 turns");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("uses the provided column width", () => {
|
|
272
|
+
const r = createRenderer(40);
|
|
273
|
+
expect(r.cols).toBe(40);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Need to import afterEach for cleanup
|
|
278
|
+
import { afterEach } from "vitest";
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
// Mock the log module
|
|
7
|
+
vi.mock("../util/log.js", () => ({
|
|
8
|
+
log: vi.fn(),
|
|
9
|
+
logError: vi.fn(),
|
|
10
|
+
logWarn: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
recordMessageProcessed,
|
|
15
|
+
getTotalMessagesProcessed,
|
|
16
|
+
recordError,
|
|
17
|
+
getRecentErrors,
|
|
18
|
+
getHealthStatus,
|
|
19
|
+
getUptimeMs,
|
|
20
|
+
startWatchdog,
|
|
21
|
+
stopWatchdog,
|
|
22
|
+
} = await import("../util/watchdog.js");
|
|
23
|
+
|
|
24
|
+
const WATCHDOG_TEST_DIR = join(tmpdir(), `talon-watchdog-test-${Date.now()}`);
|
|
25
|
+
|
|
26
|
+
describe("watchdog", () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
stopWatchdog();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("recordMessageProcessed", () => {
|
|
32
|
+
it("increments counter", () => {
|
|
33
|
+
const before = getTotalMessagesProcessed();
|
|
34
|
+
recordMessageProcessed();
|
|
35
|
+
recordMessageProcessed();
|
|
36
|
+
recordMessageProcessed();
|
|
37
|
+
expect(getTotalMessagesProcessed()).toBe(before + 3);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("updates lastProcessedAt timestamp", () => {
|
|
41
|
+
const beforeStatus = getHealthStatus();
|
|
42
|
+
const beforeMs = beforeStatus.msSinceLastMessage;
|
|
43
|
+
recordMessageProcessed();
|
|
44
|
+
const afterStatus = getHealthStatus();
|
|
45
|
+
// After recording, msSinceLastMessage should be very small (near 0)
|
|
46
|
+
expect(afterStatus.msSinceLastMessage).toBeLessThanOrEqual(50);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("recordError", () => {
|
|
51
|
+
it("stores error with timestamp", () => {
|
|
52
|
+
const beforeCount = getRecentErrors(100).length;
|
|
53
|
+
recordError("something went wrong");
|
|
54
|
+
const errors = getRecentErrors(100);
|
|
55
|
+
expect(errors.length).toBe(beforeCount + 1);
|
|
56
|
+
|
|
57
|
+
const lastError = errors[errors.length - 1];
|
|
58
|
+
expect(lastError.message).toBe("something went wrong");
|
|
59
|
+
expect(lastError.timestamp).toBeGreaterThan(0);
|
|
60
|
+
expect(lastError.timestamp).toBeLessThanOrEqual(Date.now());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("stores multiple errors in order", () => {
|
|
64
|
+
const before = getRecentErrors(100).length;
|
|
65
|
+
recordError("first-error");
|
|
66
|
+
recordError("second-error");
|
|
67
|
+
recordError("third-error");
|
|
68
|
+
const errors = getRecentErrors(100);
|
|
69
|
+
const newErrors = errors.slice(before);
|
|
70
|
+
expect(newErrors[0].message).toBe("first-error");
|
|
71
|
+
expect(newErrors[1].message).toBe("second-error");
|
|
72
|
+
expect(newErrors[2].message).toBe("third-error");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("getRecentErrors", () => {
|
|
77
|
+
it("returns last N errors", () => {
|
|
78
|
+
// Record several errors
|
|
79
|
+
for (let i = 0; i < 5; i++) {
|
|
80
|
+
recordError(`error-batch-${i}`);
|
|
81
|
+
}
|
|
82
|
+
const last2 = getRecentErrors(2);
|
|
83
|
+
expect(last2).toHaveLength(2);
|
|
84
|
+
// Should be the most recent 2
|
|
85
|
+
expect(last2[1].message).toBe("error-batch-4");
|
|
86
|
+
expect(last2[0].message).toBe("error-batch-3");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("defaults to 5 errors when no limit specified", () => {
|
|
90
|
+
// Fill with enough errors
|
|
91
|
+
for (let i = 0; i < 10; i++) {
|
|
92
|
+
recordError(`default-limit-${i}`);
|
|
93
|
+
}
|
|
94
|
+
const errors = getRecentErrors();
|
|
95
|
+
expect(errors).toHaveLength(5);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns fewer than limit when not enough errors exist", () => {
|
|
99
|
+
// The array already has errors from prior tests, but requesting a huge limit
|
|
100
|
+
// should return at most what's available (capped at 20)
|
|
101
|
+
const all = getRecentErrors(200);
|
|
102
|
+
expect(all.length).toBeLessThanOrEqual(20);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("error cap", () => {
|
|
107
|
+
it("does not grow infinitely (capped at MAX_ERRORS)", () => {
|
|
108
|
+
// Push more than 20 errors (MAX_ERRORS = 20)
|
|
109
|
+
for (let i = 0; i < 30; i++) {
|
|
110
|
+
recordError(`overflow-error-${i}`);
|
|
111
|
+
}
|
|
112
|
+
const all = getRecentErrors(100);
|
|
113
|
+
expect(all.length).toBeLessThanOrEqual(20);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps the most recent errors after cap", () => {
|
|
117
|
+
// Push enough to guarantee we hit the cap
|
|
118
|
+
for (let i = 0; i < 25; i++) {
|
|
119
|
+
recordError(`cap-test-${i}`);
|
|
120
|
+
}
|
|
121
|
+
const all = getRecentErrors(100);
|
|
122
|
+
// The last error should be the most recent one
|
|
123
|
+
expect(all[all.length - 1].message).toBe("cap-test-24");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("getHealthStatus", () => {
|
|
128
|
+
it("returns correct structure", () => {
|
|
129
|
+
const status = getHealthStatus();
|
|
130
|
+
expect(status).toHaveProperty("healthy");
|
|
131
|
+
expect(status).toHaveProperty("uptimeMs");
|
|
132
|
+
expect(status).toHaveProperty("totalMessagesProcessed");
|
|
133
|
+
expect(status).toHaveProperty("lastProcessedAt");
|
|
134
|
+
expect(status).toHaveProperty("msSinceLastMessage");
|
|
135
|
+
expect(status).toHaveProperty("recentErrorCount");
|
|
136
|
+
|
|
137
|
+
expect(status.healthy).toBe(true);
|
|
138
|
+
expect(status.uptimeMs).toBeGreaterThanOrEqual(0);
|
|
139
|
+
expect(status.totalMessagesProcessed).toBeGreaterThanOrEqual(0);
|
|
140
|
+
expect(status.lastProcessedAt).toBeGreaterThan(0);
|
|
141
|
+
expect(status.msSinceLastMessage).toBeGreaterThanOrEqual(0);
|
|
142
|
+
expect(typeof status.recentErrorCount).toBe("number");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("reflects message count from recordMessageProcessed", () => {
|
|
146
|
+
const before = getHealthStatus().totalMessagesProcessed;
|
|
147
|
+
recordMessageProcessed();
|
|
148
|
+
const after = getHealthStatus().totalMessagesProcessed;
|
|
149
|
+
expect(after).toBe(before + 1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("reflects error count from recordError", () => {
|
|
153
|
+
const before = getHealthStatus().recentErrorCount;
|
|
154
|
+
recordError("health-check-error");
|
|
155
|
+
const after = getHealthStatus().recentErrorCount;
|
|
156
|
+
// If already at cap (MAX_ERRORS=20), count stays at 20
|
|
157
|
+
if (before < 20) {
|
|
158
|
+
expect(after).toBe(before + 1);
|
|
159
|
+
} else {
|
|
160
|
+
expect(after).toBe(20);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("reports healthy when no messages have been processed yet (fresh start scenario)", () => {
|
|
165
|
+
// When totalMessagesProcessed > 0 and recent, should be healthy
|
|
166
|
+
recordMessageProcessed();
|
|
167
|
+
const status = getHealthStatus();
|
|
168
|
+
expect(status.healthy).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("uptimeMs increases over time", async () => {
|
|
172
|
+
const first = getUptimeMs();
|
|
173
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
174
|
+
const second = getUptimeMs();
|
|
175
|
+
expect(second).toBeGreaterThanOrEqual(first);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("msSinceLastMessage updates after recordMessageProcessed", () => {
|
|
179
|
+
recordMessageProcessed();
|
|
180
|
+
const status = getHealthStatus();
|
|
181
|
+
// Just processed a message, so msSinceLastMessage should be small
|
|
182
|
+
expect(status.msSinceLastMessage).toBeLessThan(1000);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("getUptimeMs", () => {
|
|
187
|
+
it("returns a positive number", () => {
|
|
188
|
+
expect(getUptimeMs()).toBeGreaterThanOrEqual(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns a number (type check)", () => {
|
|
192
|
+
expect(typeof getUptimeMs()).toBe("number");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("startWatchdog / stopWatchdog", () => {
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
stopWatchdog();
|
|
199
|
+
if (existsSync(WATCHDOG_TEST_DIR)) {
|
|
200
|
+
rmSync(WATCHDOG_TEST_DIR, { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("startWatchdog does not throw", () => {
|
|
205
|
+
expect(() => startWatchdog()).not.toThrow();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("stopWatchdog does not throw when not started", () => {
|
|
209
|
+
expect(() => stopWatchdog()).not.toThrow();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("startWatchdog is idempotent (calling twice is safe)", () => {
|
|
213
|
+
startWatchdog();
|
|
214
|
+
expect(() => startWatchdog()).not.toThrow();
|
|
215
|
+
stopWatchdog();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("stopWatchdog clears the timer", () => {
|
|
219
|
+
startWatchdog();
|
|
220
|
+
stopWatchdog();
|
|
221
|
+
// Calling stopWatchdog again should be a no-op
|
|
222
|
+
expect(() => stopWatchdog()).not.toThrow();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("can restart after stopping", () => {
|
|
226
|
+
startWatchdog();
|
|
227
|
+
stopWatchdog();
|
|
228
|
+
expect(() => startWatchdog()).not.toThrow();
|
|
229
|
+
stopWatchdog();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("accepts a workspace directory argument", () => {
|
|
233
|
+
mkdirSync(WATCHDOG_TEST_DIR, { recursive: true });
|
|
234
|
+
expect(() => startWatchdog(WATCHDOG_TEST_DIR)).not.toThrow();
|
|
235
|
+
stopWatchdog();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("watchdog interval callback checks workspace existence", () => {
|
|
239
|
+
// Use fake timers to trigger the interval
|
|
240
|
+
vi.useFakeTimers();
|
|
241
|
+
try {
|
|
242
|
+
mkdirSync(WATCHDOG_TEST_DIR, { recursive: true });
|
|
243
|
+
startWatchdog(WATCHDOG_TEST_DIR);
|
|
244
|
+
|
|
245
|
+
// Remove the workspace dir to trigger the recreate path
|
|
246
|
+
rmSync(WATCHDOG_TEST_DIR, { recursive: true });
|
|
247
|
+
expect(existsSync(WATCHDOG_TEST_DIR)).toBe(false);
|
|
248
|
+
|
|
249
|
+
// Advance timer to trigger interval callback (60 seconds)
|
|
250
|
+
vi.advanceTimersByTime(60_000);
|
|
251
|
+
|
|
252
|
+
// Watchdog should have recreated the directory
|
|
253
|
+
expect(existsSync(WATCHDOG_TEST_DIR)).toBe(true);
|
|
254
|
+
|
|
255
|
+
stopWatchdog();
|
|
256
|
+
} finally {
|
|
257
|
+
vi.useRealTimers();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("watchdog interval logs warning on inactivity", async () => {
|
|
262
|
+
const logModule = await import("../util/log.js");
|
|
263
|
+
const { logWarn } = vi.mocked(logModule);
|
|
264
|
+
vi.useFakeTimers();
|
|
265
|
+
try {
|
|
266
|
+
// Record a message first so totalMessagesProcessed > 0
|
|
267
|
+
recordMessageProcessed();
|
|
268
|
+
|
|
269
|
+
startWatchdog();
|
|
270
|
+
|
|
271
|
+
// Advance past the inactivity threshold (10 minutes = 600_000ms)
|
|
272
|
+
// Need to advance enough for both: setting lastProcessedAt to be old + triggering the interval
|
|
273
|
+
vi.advanceTimersByTime(11 * 60_000);
|
|
274
|
+
|
|
275
|
+
// logWarn should have been called with an inactivity message
|
|
276
|
+
expect(logWarn).toHaveBeenCalledWith(
|
|
277
|
+
"watchdog",
|
|
278
|
+
expect.stringContaining("No messages processed"),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
stopWatchdog();
|
|
282
|
+
} finally {
|
|
283
|
+
vi.useRealTimers();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|