talon-agent 1.0.0 → 1.1.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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dylan Neve
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://www.typescriptlang.org/)
|
|
5
5
|
[](https://github.com/anthropics/claude-agent-sdk-typescript)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://github.com/dylanneve1/talon/actions/workflows/ci.yml)
|
|
7
8
|
|
|
8
9
|
Multi-platform agentic AI harness powered by Claude. Runs on Telegram, Teams, and Terminal with full tool access through MCP.
|
|
9
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "Dylan Neve",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"tsconfig.json"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
+
"build:binary": "bun build --compile --minify src/index.ts --outfile talon-bun",
|
|
39
40
|
"start": "tsx src/index.ts",
|
|
40
41
|
"cli": "tsx src/cli.ts",
|
|
41
42
|
"setup": "tsx src/cli.ts setup",
|
|
@@ -45,28 +46,30 @@
|
|
|
45
46
|
"test:coverage": "vitest run --coverage",
|
|
46
47
|
"test:mutation": "stryker run",
|
|
47
48
|
"typecheck": "tsc --noEmit",
|
|
48
|
-
"lint": "oxlint src/
|
|
49
|
+
"lint": "oxlint src/",
|
|
50
|
+
"knip": "knip",
|
|
49
51
|
"format": "prettier --write src/ prompts/",
|
|
50
52
|
"format:check": "prettier --check src/ prompts/"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
55
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.96",
|
|
54
56
|
"@clack/prompts": "^1.2.0",
|
|
55
57
|
"@grammyjs/auto-retry": "^2.0.2",
|
|
56
58
|
"@grammyjs/transformer-throttler": "^1.2.1",
|
|
57
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
|
-
"@opencode-ai/sdk": "^1.
|
|
60
|
+
"@opencode-ai/sdk": "^1.4.0",
|
|
59
61
|
"big-integer": "^1.6.52",
|
|
60
62
|
"cheerio": "^1.2.0",
|
|
61
63
|
"croner": "^10.0.1",
|
|
62
|
-
"grammy": "^1.
|
|
63
|
-
"marked": "^
|
|
64
|
+
"grammy": "^1.42.0",
|
|
65
|
+
"marked": "^18.0.0",
|
|
66
|
+
"p-retry": "^8.0.0",
|
|
64
67
|
"picocolors": "^1.1.1",
|
|
65
68
|
"pino": "^10.3.1",
|
|
66
69
|
"pino-pretty": "^13.1.3",
|
|
67
70
|
"telegram": "^2.26.22",
|
|
68
71
|
"tsx": "^4.21.0",
|
|
69
|
-
"undici": "^
|
|
72
|
+
"undici": "^8.0.2",
|
|
70
73
|
"write-file-atomic": "^7.0.1",
|
|
71
74
|
"zod": "^4.3.6"
|
|
72
75
|
},
|
|
@@ -74,13 +77,14 @@
|
|
|
74
77
|
"@stryker-mutator/core": "^9.6.0",
|
|
75
78
|
"@stryker-mutator/typescript-checker": "^9.6.0",
|
|
76
79
|
"@stryker-mutator/vitest-runner": "^9.6.0",
|
|
77
|
-
"@types/node": "^25.5.
|
|
80
|
+
"@types/node": "^25.5.2",
|
|
78
81
|
"@types/write-file-atomic": "^4.0.3",
|
|
79
|
-
"@vitest/coverage-v8": "^4.1.
|
|
82
|
+
"@vitest/coverage-v8": "^4.1.3",
|
|
80
83
|
"fast-check": "^4.6.0",
|
|
81
|
-
"
|
|
84
|
+
"knip": "^6.3.1",
|
|
85
|
+
"oxlint": "^1.59.0",
|
|
82
86
|
"prettier": "^3.8.1",
|
|
83
87
|
"typescript": "^6.0.2",
|
|
84
|
-
"vitest": "^4.1.
|
|
88
|
+
"vitest": "^4.1.3"
|
|
85
89
|
}
|
|
86
90
|
}
|
package/prompts/dream.md
CHANGED
|
@@ -5,11 +5,12 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
|
|
|
5
5
|
## Your 4-stage task
|
|
6
6
|
|
|
7
7
|
### Stage 1 — Orient
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
- List log files in `{{logsDir}}/` that are dated on or after `{{lastRunIso}}`
|
|
10
|
-
- If there are no new log files,
|
|
10
|
+
- If there are no new log files, stop — the system will handle state updates
|
|
11
11
|
|
|
12
12
|
### Stage 2 — Gather
|
|
13
|
+
|
|
13
14
|
- Read each new log file
|
|
14
15
|
- Each log file uses this format:
|
|
15
16
|
- User messages appear as `## HH:MM -- [Username]` followed by the full message text
|
|
@@ -25,17 +26,20 @@ You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep).
|
|
|
25
26
|
- Be selective — only extract genuinely new or updated information
|
|
26
27
|
|
|
27
28
|
### Stage 3 — Consolidate
|
|
29
|
+
|
|
28
30
|
- Read the current memory file at `{{memoryFile}}`
|
|
29
31
|
- Merge new information into the appropriate sections
|
|
30
32
|
- Update existing entries if new info contradicts or extends them
|
|
31
33
|
- Add new entries where appropriate
|
|
32
34
|
- Keep entries concise and factual — no padding, no narrative
|
|
33
35
|
- Preserve all existing structure and sections
|
|
36
|
+
- Also write daily memory summaries to `{{dailyMemoryDir}}/YYYY-MM-DD.md` for each day of logs you processed. Include key learnings, conversation summaries, and follow-ups. Keep these concise — the bot reads them on demand for context.
|
|
34
37
|
|
|
35
38
|
### Stage 4 — Prune
|
|
39
|
+
|
|
36
40
|
- Remove entries that have been explicitly contradicted
|
|
37
41
|
- Remove entries that are clearly stale or irrelevant
|
|
38
42
|
- Do NOT remove entries just because they're old — only remove if wrong or superseded
|
|
39
43
|
- Write the updated memory.md back to `{{memoryFile}}`
|
|
40
44
|
|
|
41
|
-
When done
|
|
45
|
+
When done with memory consolidation, stop. The system handles all dream_state.json updates.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
You are Talon's background heartbeat agent. You run periodically (every {{intervalMinutes}} minutes) to perform maintenance tasks defined by the user.
|
|
2
|
+
|
|
3
|
+
You have access ONLY to filesystem tools (Read, Write, Edit, Bash, Glob, Grep). Do NOT attempt to use any Telegram, MCP, or messaging tools.
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
- Workspace: `{{workspace}}`
|
|
8
|
+
- Memory file: `{{memoryFile}}`
|
|
9
|
+
- Logs directory: `{{logsDir}}`
|
|
10
|
+
- Last heartbeat: `{{lastRunIso}}`
|
|
11
|
+
- Run number: #{{runCount}}
|
|
12
|
+
- Today's daily memory: `{{dailyMemoryFile}}`
|
|
13
|
+
|
|
14
|
+
## Instructions
|
|
15
|
+
|
|
16
|
+
Read the user-defined instructions file at `{{instructionsFile}}`. Follow whatever tasks are defined there.
|
|
17
|
+
|
|
18
|
+
If the instructions file does not exist or is empty, perform these default tasks:
|
|
19
|
+
|
|
20
|
+
1. **Review recent logs** — Check `{{logsDir}}/` for log files dated after `{{lastRunIso}}`. If `{{lastRunIso}}` is `never`, treat it as the beginning of time and review all available logs. Extract any new facts, preferences, or notable events.
|
|
21
|
+
2. **Update memory** — Merge any new information into `{{memoryFile}}`, keeping entries concise and factual.
|
|
22
|
+
3. **Update daily notes** — Write today's learnings, observations, corrections, and follow-ups to `{{dailyMemoryFile}}`. Keep entries concise — the bot reads this file on demand for context.
|
|
23
|
+
4. **Workspace hygiene** — Note any issues but do not delete files unless the instructions explicitly say to.
|
|
24
|
+
|
|
25
|
+
## Rules
|
|
26
|
+
|
|
27
|
+
- Be surgical and precise. Do not rewrite files unnecessarily.
|
|
28
|
+
- Do not modify files outside the workspace unless the instructions explicitly allow it.
|
|
29
|
+
- Keep your work focused and efficient — you have a 10-minute time limit.
|
|
30
|
+
- When done, stop. The system handles all state tracking.
|
package/prompts/identity.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
Your identity is defined in `~/.talon/workspace/identity.md`. Read it to know who you are.
|
|
18
18
|
|
|
19
19
|
If the identity file is empty or only contains the template comments, you MUST ask the user during your first interaction:
|
|
20
|
+
|
|
20
21
|
- What should I be called?
|
|
21
22
|
- Who are you / who created me?
|
|
22
23
|
- What will I be used for?
|
package/prompts/teams.md
CHANGED
|
@@ -32,6 +32,7 @@ Webhook-based integration — no reactions, media uploads, message editing, typi
|
|
|
32
32
|
Messages render as Adaptive Cards. The formatting engine is NOT standard Markdown.
|
|
33
33
|
|
|
34
34
|
What WORKS:
|
|
35
|
+
|
|
35
36
|
- **bold** and _italic_
|
|
36
37
|
- [links](https://example.com)
|
|
37
38
|
- Fenced code blocks (triple backticks) — render as monospace in a grey box
|
|
@@ -39,11 +40,13 @@ What WORKS:
|
|
|
39
40
|
- Numbered and bulleted lists
|
|
40
41
|
|
|
41
42
|
What does NOT work:
|
|
43
|
+
|
|
42
44
|
- Inline code with backticks — do NOT use `code` style, just write the text plain
|
|
43
45
|
- Headings with # — use **bold** text instead
|
|
44
46
|
- Images/media — not supported via webhook
|
|
45
47
|
|
|
46
48
|
Style:
|
|
49
|
+
|
|
47
50
|
- Concise. No filler.
|
|
48
51
|
- Use **bold** for emphasis, _italic_ for secondary emphasis.
|
|
49
52
|
- Use markdown tables for structured/tabular data — they render as proper grid tables.
|
package/prompts/telegram.md
CHANGED
|
@@ -75,6 +75,7 @@ When a user presses a callback button, you'll receive "[Button pressed]" with th
|
|
|
75
75
|
### Stickers
|
|
76
76
|
|
|
77
77
|
Use stickers like a human would — they're part of Telegram culture:
|
|
78
|
+
|
|
78
79
|
- When users send stickers, their set_name is captured. Use `save_sticker_pack` to save packs you like.
|
|
79
80
|
- Once saved, read `~/.talon/workspace/stickers/<set_name>.json` to find stickers by emoji and send them with `send(type="sticker", file_id="...")`.
|
|
80
81
|
- Send stickers to express emotions, reactions, or just for fun. Don't overuse them.
|
|
@@ -16,6 +16,11 @@ vi.mock("node:fs", () => ({
|
|
|
16
16
|
mkdirSync: vi.fn(),
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
|
+
// Mock write-file-atomic to prevent writes to the real production file
|
|
20
|
+
vi.mock("write-file-atomic", () => ({
|
|
21
|
+
default: { sync: vi.fn() },
|
|
22
|
+
}));
|
|
23
|
+
|
|
19
24
|
const {
|
|
20
25
|
getChatSettings,
|
|
21
26
|
setChatModel,
|
|
@@ -252,7 +257,9 @@ describe("chat-settings", () => {
|
|
|
252
257
|
const settings = getChatSettings("migrate-1");
|
|
253
258
|
expect(settings.effort).toBe("off");
|
|
254
259
|
// maxThinkingTokens should be removed
|
|
255
|
-
expect(
|
|
260
|
+
expect(
|
|
261
|
+
(settings as Record<string, unknown>).maxThinkingTokens,
|
|
262
|
+
).toBeUndefined();
|
|
256
263
|
});
|
|
257
264
|
|
|
258
265
|
it("migrates maxThinkingTokens=1000 to effort=low", () => {
|
|
@@ -320,7 +327,9 @@ describe("chat-settings", () => {
|
|
|
320
327
|
const settings = getChatSettings("migrate-6");
|
|
321
328
|
// Should keep existing effort, just clean up old field
|
|
322
329
|
expect(settings.effort).toBe("high");
|
|
323
|
-
expect(
|
|
330
|
+
expect(
|
|
331
|
+
(settings as Record<string, unknown>).maxThinkingTokens,
|
|
332
|
+
).toBeUndefined();
|
|
324
333
|
});
|
|
325
334
|
|
|
326
335
|
it("handles missing store file gracefully", () => {
|
|
@@ -335,3 +344,100 @@ describe("chat-settings", () => {
|
|
|
335
344
|
});
|
|
336
345
|
});
|
|
337
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("chat-settings — setPulseLastCheckMsgId", () => {
|
|
349
|
+
it("sets pulseLastCheckMsgId when msgId is provided", async () => {
|
|
350
|
+
const { setPulseLastCheckMsgId, getChatSettings } =
|
|
351
|
+
await import("../storage/chat-settings.js");
|
|
352
|
+
setPulseLastCheckMsgId("pulse-check-1", 42);
|
|
353
|
+
expect(getChatSettings("pulse-check-1").pulseLastCheckMsgId).toBe(42);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("clears pulseLastCheckMsgId when undefined is passed", async () => {
|
|
357
|
+
const { setPulseLastCheckMsgId, getChatSettings } =
|
|
358
|
+
await import("../storage/chat-settings.js");
|
|
359
|
+
setPulseLastCheckMsgId("pulse-check-2", 100);
|
|
360
|
+
setPulseLastCheckMsgId("pulse-check-2", undefined);
|
|
361
|
+
expect(
|
|
362
|
+
getChatSettings("pulse-check-2").pulseLastCheckMsgId,
|
|
363
|
+
).toBeUndefined();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("removes empty settings object after all fields cleared", async () => {
|
|
367
|
+
const { setPulseLastCheckMsgId, getChatSettings } =
|
|
368
|
+
await import("../storage/chat-settings.js");
|
|
369
|
+
// Set only pulseLastCheckMsgId (no model, effort, pulse, pulseIntervalMs)
|
|
370
|
+
setPulseLastCheckMsgId("pulse-cleanup-1", 99);
|
|
371
|
+
setPulseLastCheckMsgId("pulse-cleanup-1", undefined);
|
|
372
|
+
// cleanupEmpty should have removed the settings object
|
|
373
|
+
expect(getChatSettings("pulse-cleanup-1")).toEqual({});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("chat-settings — migration of has-effort + maxThinkingTokens", () => {
|
|
378
|
+
it("cleans up maxThinkingTokens when effort already set", async () => {
|
|
379
|
+
const { loadChatSettings, getChatSettings } =
|
|
380
|
+
await import("../storage/chat-settings.js");
|
|
381
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
382
|
+
vi.mocked(existsSync).mockReturnValueOnce(true);
|
|
383
|
+
vi.mocked(readFileSync).mockReturnValueOnce(
|
|
384
|
+
JSON.stringify({
|
|
385
|
+
"migrate-has-effort": { effort: "high", maxThinkingTokens: 16000 },
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
loadChatSettings();
|
|
389
|
+
const s = getChatSettings("migrate-has-effort");
|
|
390
|
+
expect(s.effort).toBe("high");
|
|
391
|
+
expect((s as Record<string, unknown>).maxThinkingTokens).toBeUndefined();
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("chat-settings — flushChatSettings", () => {
|
|
396
|
+
it("does not throw when called", async () => {
|
|
397
|
+
const { flushChatSettings, setChatModel } =
|
|
398
|
+
await import("../storage/chat-settings.js");
|
|
399
|
+
// Make dirty first so save() runs
|
|
400
|
+
setChatModel("flush-test", "claude-opus-4-6");
|
|
401
|
+
expect(() => flushChatSettings()).not.toThrow();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("chat-settings — cleanupEmpty keeps entry when other fields remain (line 115 FALSE branch)", () => {
|
|
406
|
+
it("does not delete entry when effort is still set after clearing model", () => {
|
|
407
|
+
const chatId = `cleanup-keep-entry-${Date.now()}`;
|
|
408
|
+
// Set both model and effort
|
|
409
|
+
setChatModel(chatId, "claude-sonnet-4-6");
|
|
410
|
+
setChatEffort(chatId, "high");
|
|
411
|
+
expect(getChatSettings(chatId).model).toBe("claude-sonnet-4-6");
|
|
412
|
+
expect(getChatSettings(chatId).effort).toBe("high");
|
|
413
|
+
|
|
414
|
+
// Clear model only — effort still set → cleanupEmpty condition is FALSE → entry kept
|
|
415
|
+
setChatModel(chatId, undefined);
|
|
416
|
+
expect(getChatSettings(chatId).effort).toBe("high");
|
|
417
|
+
expect(getChatSettings(chatId).model).toBeUndefined();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("chat-settings — backup recovery on corrupt primary", () => {
|
|
422
|
+
it("loads from backup when primary JSON is corrupt", async () => {
|
|
423
|
+
const { loadChatSettings, getChatSettings } =
|
|
424
|
+
await import("../storage/chat-settings.js");
|
|
425
|
+
vi.mocked(existsSync)
|
|
426
|
+
.mockReturnValueOnce(true) // primary exists
|
|
427
|
+
.mockReturnValueOnce(true); // backup exists
|
|
428
|
+
vi.mocked(readFileSync)
|
|
429
|
+
.mockReturnValueOnce("{ INVALID JSON") // primary corrupt
|
|
430
|
+
.mockReturnValueOnce(
|
|
431
|
+
JSON.stringify({
|
|
432
|
+
"backup-settings-chat": {
|
|
433
|
+
model: "claude-sonnet-4-6",
|
|
434
|
+
effort: "medium",
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
loadChatSettings();
|
|
439
|
+
const s = getChatSettings("backup-settings-chat");
|
|
440
|
+
expect(s.model).toBe("claude-sonnet-4-6");
|
|
441
|
+
expect(s.effort).toBe("medium");
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/util/cleanup-registry.ts
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { registerCleanup } from "../util/cleanup-registry.js";
|
|
6
|
+
|
|
7
|
+
describe("cleanup-registry", () => {
|
|
8
|
+
it("registered handler is called when the exit event fires", () => {
|
|
9
|
+
let called = false;
|
|
10
|
+
registerCleanup(() => {
|
|
11
|
+
called = true;
|
|
12
|
+
});
|
|
13
|
+
process.emit("exit", 0);
|
|
14
|
+
expect(called).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("multiple handlers are all called on exit", () => {
|
|
18
|
+
const results: number[] = [];
|
|
19
|
+
registerCleanup(() => results.push(1));
|
|
20
|
+
registerCleanup(() => results.push(2));
|
|
21
|
+
registerCleanup(() => results.push(3));
|
|
22
|
+
process.emit("exit", 0);
|
|
23
|
+
expect(results).toContain(1);
|
|
24
|
+
expect(results).toContain(2);
|
|
25
|
+
expect(results).toContain(3);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("handler that throws does not prevent subsequent handlers from running", () => {
|
|
29
|
+
let afterCalled = false;
|
|
30
|
+
registerCleanup(() => {
|
|
31
|
+
throw new Error("boom");
|
|
32
|
+
});
|
|
33
|
+
registerCleanup(() => {
|
|
34
|
+
afterCalled = true;
|
|
35
|
+
});
|
|
36
|
+
expect(() => process.emit("exit", 0)).not.toThrow();
|
|
37
|
+
expect(afterCalled).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("registering the same function object twice calls it twice", () => {
|
|
41
|
+
let count = 0;
|
|
42
|
+
const fn = () => count++;
|
|
43
|
+
registerCleanup(fn);
|
|
44
|
+
registerCleanup(fn);
|
|
45
|
+
process.emit("exit", 0);
|
|
46
|
+
expect(count).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not add a new process exit listener on each registerCleanup call", () => {
|
|
50
|
+
const before = process.listenerCount("exit");
|
|
51
|
+
registerCleanup(() => {});
|
|
52
|
+
registerCleanup(() => {});
|
|
53
|
+
registerCleanup(() => {});
|
|
54
|
+
const after = process.listenerCount("exit");
|
|
55
|
+
// Listener count must not grow — one listener handles all handlers
|
|
56
|
+
expect(after).toBe(before);
|
|
57
|
+
});
|
|
58
|
+
});
|