talon-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +137 -0
  2. package/bin/talon.js +5 -0
  3. package/package.json +86 -0
  4. package/prompts/base.md +13 -0
  5. package/prompts/custom.md.example +22 -0
  6. package/prompts/dream.md +41 -0
  7. package/prompts/identity.md +45 -0
  8. package/prompts/teams.md +52 -0
  9. package/prompts/telegram.md +89 -0
  10. package/prompts/terminal.md +13 -0
  11. package/src/__tests__/chat-id.test.ts +91 -0
  12. package/src/__tests__/chat-settings.test.ts +337 -0
  13. package/src/__tests__/config.test.ts +546 -0
  14. package/src/__tests__/cron-store.test.ts +440 -0
  15. package/src/__tests__/daily-log.test.ts +146 -0
  16. package/src/__tests__/dispatcher.test.ts +383 -0
  17. package/src/__tests__/errors.test.ts +240 -0
  18. package/src/__tests__/fuzz.test.ts +302 -0
  19. package/src/__tests__/gateway-actions.test.ts +1453 -0
  20. package/src/__tests__/gateway-context.test.ts +102 -0
  21. package/src/__tests__/gateway-http.test.ts +245 -0
  22. package/src/__tests__/handlers.test.ts +351 -0
  23. package/src/__tests__/history-persistence.test.ts +172 -0
  24. package/src/__tests__/history.test.ts +659 -0
  25. package/src/__tests__/integration.test.ts +189 -0
  26. package/src/__tests__/log.test.ts +110 -0
  27. package/src/__tests__/media-index.test.ts +277 -0
  28. package/src/__tests__/plugin.test.ts +317 -0
  29. package/src/__tests__/prompt-builder.test.ts +71 -0
  30. package/src/__tests__/sessions.test.ts +594 -0
  31. package/src/__tests__/teams-frontend.test.ts +239 -0
  32. package/src/__tests__/telegram.test.ts +177 -0
  33. package/src/__tests__/terminal-commands.test.ts +367 -0
  34. package/src/__tests__/terminal-frontend.test.ts +141 -0
  35. package/src/__tests__/terminal-renderer.test.ts +278 -0
  36. package/src/__tests__/watchdog.test.ts +287 -0
  37. package/src/__tests__/workspace.test.ts +184 -0
  38. package/src/backend/claude-sdk/index.ts +438 -0
  39. package/src/backend/claude-sdk/tools.ts +605 -0
  40. package/src/backend/opencode/index.ts +252 -0
  41. package/src/bootstrap.ts +134 -0
  42. package/src/cli.ts +611 -0
  43. package/src/core/cron.ts +148 -0
  44. package/src/core/dispatcher.ts +126 -0
  45. package/src/core/dream.ts +295 -0
  46. package/src/core/errors.ts +206 -0
  47. package/src/core/gateway-actions.ts +267 -0
  48. package/src/core/gateway.ts +258 -0
  49. package/src/core/plugin.ts +432 -0
  50. package/src/core/prompt-builder.ts +43 -0
  51. package/src/core/pulse.ts +175 -0
  52. package/src/core/types.ts +85 -0
  53. package/src/frontend/teams/actions.ts +101 -0
  54. package/src/frontend/teams/formatting.ts +220 -0
  55. package/src/frontend/teams/graph.ts +297 -0
  56. package/src/frontend/teams/index.ts +308 -0
  57. package/src/frontend/teams/proxy-fetch.ts +28 -0
  58. package/src/frontend/teams/tools.ts +177 -0
  59. package/src/frontend/telegram/actions.ts +437 -0
  60. package/src/frontend/telegram/admin.ts +178 -0
  61. package/src/frontend/telegram/callbacks.ts +251 -0
  62. package/src/frontend/telegram/commands.ts +543 -0
  63. package/src/frontend/telegram/formatting.ts +101 -0
  64. package/src/frontend/telegram/handlers.ts +1008 -0
  65. package/src/frontend/telegram/helpers.ts +105 -0
  66. package/src/frontend/telegram/index.ts +130 -0
  67. package/src/frontend/telegram/middleware.ts +177 -0
  68. package/src/frontend/telegram/userbot.ts +546 -0
  69. package/src/frontend/terminal/commands.ts +303 -0
  70. package/src/frontend/terminal/index.ts +282 -0
  71. package/src/frontend/terminal/input.ts +297 -0
  72. package/src/frontend/terminal/renderer.ts +248 -0
  73. package/src/index.ts +144 -0
  74. package/src/login.ts +89 -0
  75. package/src/storage/chat-settings.ts +218 -0
  76. package/src/storage/cron-store.ts +165 -0
  77. package/src/storage/daily-log.ts +97 -0
  78. package/src/storage/history.ts +278 -0
  79. package/src/storage/media-index.ts +116 -0
  80. package/src/storage/sessions.ts +328 -0
  81. package/src/util/chat-id.ts +21 -0
  82. package/src/util/config.ts +244 -0
  83. package/src/util/log.ts +122 -0
  84. package/src/util/paths.ts +80 -0
  85. package/src/util/time.ts +86 -0
  86. package/src/util/trace.ts +35 -0
  87. package/src/util/watchdog.ts +108 -0
  88. package/src/util/workspace.ts +208 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,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
+ });