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,189 @@
1
+ /**
2
+ * Integration test: dispatcher → backend (mocked) → result.
3
+ * Tests the full query lifecycle without spawning actual SDK processes.
4
+ */
5
+
6
+ import { describe, it, expect, vi } from "vitest";
7
+ import { initDispatcher, execute } from "../core/dispatcher.js";
8
+ import type { QueryBackend, ContextManager } from "../core/types.js";
9
+ import { TalonError } from "../core/errors.js";
10
+
11
+ function setup(overrides: { queryResult?: Record<string, unknown>; queryError?: Error } = {}) {
12
+ const acquired: number[] = [];
13
+ const released: number[] = [];
14
+ const typingCalls: number[] = [];
15
+ let activityCount = 0;
16
+
17
+ const backend: QueryBackend = {
18
+ query: vi.fn(async () => {
19
+ if (overrides.queryError) throw overrides.queryError;
20
+ return {
21
+ text: "test response",
22
+ durationMs: 50,
23
+ inputTokens: 10,
24
+ outputTokens: 20,
25
+ cacheRead: 5,
26
+ cacheWrite: 3,
27
+ ...overrides.queryResult,
28
+ };
29
+ }),
30
+ };
31
+
32
+ const context: ContextManager = {
33
+ acquire: vi.fn((id: number) => acquired.push(id)),
34
+ release: vi.fn((id: number) => released.push(id)),
35
+ getMessageCount: vi.fn(() => 0),
36
+ };
37
+
38
+ initDispatcher({
39
+ backend,
40
+ context,
41
+ sendTyping: vi.fn(async (id: number) => { typingCalls.push(id); }),
42
+ onActivity: vi.fn(() => { activityCount++; }),
43
+ });
44
+
45
+ return { backend, context, acquired, released, typingCalls, getActivityCount: () => activityCount };
46
+ }
47
+
48
+ describe("integration: dispatcher lifecycle", () => {
49
+ it("full happy path: acquire → type → query → activity → release", async () => {
50
+ const { backend, acquired, released, typingCalls, getActivityCount } = setup();
51
+
52
+ const result = await execute({
53
+ chatId: "123",
54
+ numericChatId: 123,
55
+ prompt: "hello world",
56
+ senderName: "TestUser",
57
+ isGroup: false,
58
+ source: "message",
59
+ });
60
+
61
+ expect(result.text).toBe("test response");
62
+ expect(result.durationMs).toBe(50);
63
+ expect(result.bridgeMessageCount).toBe(0);
64
+
65
+ // Context lifecycle
66
+ expect(acquired).toEqual([123]);
67
+ expect(released).toEqual([123]);
68
+
69
+ // Typing was sent
70
+ expect(typingCalls).toEqual([123]);
71
+
72
+ // Activity callback fired
73
+ expect(getActivityCount()).toBe(1);
74
+
75
+ // Backend was called with correct params
76
+ expect(backend.query).toHaveBeenCalledWith(
77
+ expect.objectContaining({
78
+ chatId: "123",
79
+ text: "hello world",
80
+ senderName: "TestUser",
81
+ isGroup: false,
82
+ }),
83
+ );
84
+ });
85
+
86
+ it("error path: context released even on failure", async () => {
87
+ const { released } = setup({ queryError: new Error("SDK crashed") });
88
+
89
+ await expect(
90
+ execute({
91
+ chatId: "456",
92
+ numericChatId: 456,
93
+ prompt: "will fail",
94
+ senderName: "User",
95
+ isGroup: false,
96
+ source: "message",
97
+ }),
98
+ ).rejects.toThrow("SDK crashed");
99
+
100
+ expect(released).toEqual([456]);
101
+ });
102
+
103
+ it("classified error path: TalonError propagated", async () => {
104
+ const { released } = setup({
105
+ queryError: new TalonError("rate limited", {
106
+ reason: "rate_limit",
107
+ retryable: true,
108
+ retryAfterMs: 5000,
109
+ }),
110
+ });
111
+
112
+ try {
113
+ await execute({
114
+ chatId: "789",
115
+ numericChatId: 789,
116
+ prompt: "will rate limit",
117
+ senderName: "User",
118
+ isGroup: false,
119
+ source: "message",
120
+ });
121
+ expect.unreachable();
122
+ } catch (err) {
123
+ expect(err).toBeInstanceOf(TalonError);
124
+ const te = err as TalonError;
125
+ expect(te.reason).toBe("rate_limit");
126
+ expect(te.retryable).toBe(true);
127
+ }
128
+
129
+ expect(released).toEqual([789]);
130
+ });
131
+
132
+ it("cross-chat parallel execution", async () => {
133
+ const order: string[] = [];
134
+ const backend: QueryBackend = {
135
+ query: vi.fn(async (params) => {
136
+ order.push(`start:${params.chatId}`);
137
+ await new Promise((r) => setTimeout(r, 20));
138
+ order.push(`end:${params.chatId}`);
139
+ return { text: "", durationMs: 20, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0 };
140
+ }),
141
+ };
142
+
143
+ initDispatcher({
144
+ backend,
145
+ context: {
146
+ acquire: () => {},
147
+ release: () => {},
148
+ getMessageCount: () => 0,
149
+ },
150
+ sendTyping: async () => {},
151
+ onActivity: () => {},
152
+ });
153
+
154
+ // Fire two queries simultaneously
155
+ await Promise.all([
156
+ execute({ chatId: "A", numericChatId: 1, prompt: "a", senderName: "U", isGroup: false, source: "message" }),
157
+ execute({ chatId: "B", numericChatId: 2, prompt: "b", senderName: "U", isGroup: false, source: "message" }),
158
+ ]);
159
+
160
+ // True concurrency — both start before either ends
161
+ expect(order[0]).toBe("start:A");
162
+ expect(order[1]).toBe("start:B");
163
+ });
164
+
165
+ it("stream callbacks are passed through to backend", async () => {
166
+ const { backend } = setup();
167
+ const onStreamDelta = vi.fn();
168
+ const onTextBlock = vi.fn();
169
+
170
+ await execute({
171
+ chatId: "999",
172
+ numericChatId: 999,
173
+ prompt: "stream test",
174
+ senderName: "User",
175
+ isGroup: true,
176
+ source: "pulse",
177
+ onStreamDelta,
178
+ onTextBlock,
179
+ });
180
+
181
+ expect(backend.query).toHaveBeenCalledWith(
182
+ expect.objectContaining({
183
+ onStreamDelta,
184
+ onTextBlock,
185
+ isGroup: true,
186
+ }),
187
+ );
188
+ });
189
+ });
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock pino before importing log module
4
+ const mockInfo = vi.fn();
5
+ const mockError = vi.fn();
6
+ const mockWarn = vi.fn();
7
+ const mockDebug = vi.fn();
8
+
9
+ vi.mock("pino", () => ({
10
+ default: () => ({
11
+ info: mockInfo,
12
+ error: mockError,
13
+ warn: mockWarn,
14
+ debug: mockDebug,
15
+ }),
16
+ }));
17
+
18
+ const { log, logError, logWarn, logDebug } = await import("../util/log.js");
19
+
20
+ describe("log", () => {
21
+ beforeEach(() => {
22
+ mockInfo.mockClear();
23
+ mockError.mockClear();
24
+ mockWarn.mockClear();
25
+ mockDebug.mockClear();
26
+ });
27
+
28
+ describe("log(component, message)", () => {
29
+ it("calls pino.info with component and message", () => {
30
+ log("bot", "started successfully");
31
+ expect(mockInfo).toHaveBeenCalledOnce();
32
+ expect(mockInfo).toHaveBeenCalledWith({ component: "bot" }, "started successfully");
33
+ });
34
+
35
+ it("works with all valid component types", () => {
36
+ const components = [
37
+ "bot", "bridge", "agent", "pulse", "userbot", "users",
38
+ "watchdog", "workspace", "shutdown", "file", "sessions",
39
+ "settings", "commands", "cron", "dispatcher",
40
+ ] as const;
41
+ for (const component of components) {
42
+ mockInfo.mockClear();
43
+ log(component, "test");
44
+ expect(mockInfo).toHaveBeenCalledWith({ component }, "test");
45
+ }
46
+ });
47
+ });
48
+
49
+ describe("logError(component, message, err?)", () => {
50
+ it("calls pino.error with component and message", () => {
51
+ logError("bot", "failed to start");
52
+ expect(mockError).toHaveBeenCalledOnce();
53
+ expect(mockError).toHaveBeenCalledWith({ component: "bot" }, "failed to start");
54
+ });
55
+
56
+ it("includes Error message in context", () => {
57
+ logError("bridge", "request failed", new Error("timeout"));
58
+ expect(mockError).toHaveBeenCalledWith(
59
+ { component: "bridge", err: "timeout" },
60
+ "request failed",
61
+ );
62
+ });
63
+
64
+ it("stringifies non-Error err values", () => {
65
+ logError("sessions", "save failed", "disk full");
66
+ expect(mockError).toHaveBeenCalledWith(
67
+ { component: "sessions", err: "disk full" },
68
+ "save failed",
69
+ );
70
+ });
71
+
72
+ it("handles numeric err values", () => {
73
+ logError("sessions", "exit code", 1);
74
+ expect(mockError).toHaveBeenCalledWith(
75
+ { component: "sessions", err: "1" },
76
+ "exit code",
77
+ );
78
+ });
79
+
80
+ it("omits err field when err is undefined", () => {
81
+ logError("settings", "something wrong");
82
+ expect(mockError).toHaveBeenCalledWith(
83
+ { component: "settings" },
84
+ "something wrong",
85
+ );
86
+ });
87
+ });
88
+
89
+ describe("logWarn(component, message)", () => {
90
+ it("calls pino.warn with component and message", () => {
91
+ logWarn("watchdog", "inactivity detected");
92
+ expect(mockWarn).toHaveBeenCalledOnce();
93
+ expect(mockWarn).toHaveBeenCalledWith(
94
+ { component: "watchdog" },
95
+ "inactivity detected",
96
+ );
97
+ });
98
+ });
99
+
100
+ describe("logDebug(component, message)", () => {
101
+ it("calls pino.debug with component and message", () => {
102
+ logDebug("agent", "processing query");
103
+ expect(mockDebug).toHaveBeenCalledOnce();
104
+ expect(mockDebug).toHaveBeenCalledWith(
105
+ { component: "agent" },
106
+ "processing query",
107
+ );
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../util/log.js", () => ({
4
+ log: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn(),
5
+ }));
6
+
7
+ const existsSyncMock = vi.fn(() => false);
8
+ const readFileSyncMock = vi.fn(() => "[]");
9
+ const mkdirSyncMock = vi.fn();
10
+ const unlinkSyncMock = vi.fn();
11
+
12
+ vi.mock("node:fs", () => ({
13
+ existsSync: existsSyncMock,
14
+ readFileSync: readFileSyncMock,
15
+ mkdirSync: mkdirSyncMock,
16
+ unlinkSync: unlinkSyncMock,
17
+ }));
18
+
19
+ const writeFileSyncMock = vi.fn();
20
+
21
+ vi.mock("write-file-atomic", () => ({
22
+ default: { sync: (...args: unknown[]) => writeFileSyncMock(...args) },
23
+ }));
24
+
25
+ const { addMedia, getRecentMedia, getMediaByType, formatMediaIndex, loadMediaIndex, flushMediaIndex } = await import("../storage/media-index.js");
26
+
27
+ describe("media-index", () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ existsSyncMock.mockReturnValue(false);
31
+ readFileSyncMock.mockReturnValue("[]");
32
+ loadMediaIndex(); // reset state
33
+ });
34
+
35
+ it("adds and retrieves media", () => {
36
+ const cid = `add-${Date.now()}`;
37
+ addMedia({
38
+ chatId: cid, msgId: 1, senderName: "Alice", type: "photo",
39
+ filePath: "/tmp/photo.jpg", timestamp: Date.now(),
40
+ });
41
+ const media = getRecentMedia(cid);
42
+ expect(media).toHaveLength(1);
43
+ expect(media[0].type).toBe("photo");
44
+ expect(media[0].filePath).toBe("/tmp/photo.jpg");
45
+ });
46
+
47
+ it("returns empty for unknown chat", () => {
48
+ expect(getRecentMedia(`unknown-${Date.now()}`)).toHaveLength(0);
49
+ });
50
+
51
+ it("filters by type", () => {
52
+ const cid = `type-${Date.now()}`;
53
+ addMedia({ chatId: cid, msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
54
+ addMedia({ chatId: cid, msgId: 2, senderName: "A", type: "document", filePath: "/b.pdf", timestamp: Date.now() });
55
+ addMedia({ chatId: cid, msgId: 3, senderName: "A", type: "photo", filePath: "/c.jpg", timestamp: Date.now() });
56
+
57
+ expect(getMediaByType(cid, "photo")).toHaveLength(2);
58
+ expect(getMediaByType(cid, "document")).toHaveLength(1);
59
+ });
60
+
61
+ it("deduplicates by chatId:msgId", () => {
62
+ const chatId = `dedup-${Date.now()}`;
63
+ addMedia({ chatId, msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: 1000 });
64
+ addMedia({ chatId, msgId: 1, senderName: "A", type: "photo", filePath: "/b.jpg", timestamp: 2000 });
65
+
66
+ const media = getRecentMedia(chatId);
67
+ expect(media).toHaveLength(1);
68
+ expect(media[0].filePath).toBe("/b.jpg");
69
+ });
70
+
71
+ it("formats index as text", () => {
72
+ addMedia({ chatId: "456", msgId: 10, senderName: "Bob", type: "photo", filePath: "/photo.jpg", caption: "sunset", timestamp: Date.now() });
73
+ const text = formatMediaIndex("456");
74
+ expect(text).toContain("photo");
75
+ expect(text).toContain("Bob");
76
+ expect(text).toContain("/photo.jpg");
77
+ expect(text).toContain("sunset");
78
+ });
79
+
80
+ it("returns 'no recent media' for empty chat", () => {
81
+ expect(formatMediaIndex("empty")).toContain("No recent media");
82
+ });
83
+
84
+ it("limits results", () => {
85
+ for (let i = 0; i < 15; i++) {
86
+ addMedia({ chatId: "789", msgId: i, senderName: "C", type: "photo", filePath: `/p${i}.jpg`, timestamp: Date.now() + i });
87
+ }
88
+ expect(getRecentMedia("789", 5)).toHaveLength(5);
89
+ });
90
+
91
+ it("returns newest first", () => {
92
+ addMedia({ chatId: "100", msgId: 1, senderName: "A", type: "photo", filePath: "/old.jpg", timestamp: 1000 });
93
+ addMedia({ chatId: "100", msgId: 2, senderName: "A", type: "photo", filePath: "/new.jpg", timestamp: 2000 });
94
+
95
+ const media = getRecentMedia("100");
96
+ expect(media[0].filePath).toBe("/new.jpg");
97
+ });
98
+
99
+ describe("addMedia with all media types", () => {
100
+ it("supports all media type variants", () => {
101
+ const cid = `types-${Date.now()}`;
102
+ const types = ["photo", "document", "voice", "video", "animation", "audio", "sticker"] as const;
103
+ types.forEach((type, i) => {
104
+ addMedia({ chatId: cid, msgId: i + 1, senderName: "User", type, filePath: `/tmp/${type}.bin`, timestamp: Date.now() + i });
105
+ });
106
+ const media = getRecentMedia(cid, 20);
107
+ expect(media).toHaveLength(7);
108
+ const returnedTypes = media.map((m) => m.type).sort();
109
+ expect(returnedTypes).toEqual([...types].sort());
110
+ });
111
+
112
+ it("supports caption field", () => {
113
+ const cid = `cap-${Date.now()}`;
114
+ addMedia({ chatId: cid, msgId: 1, senderName: "User", type: "photo", filePath: "/a.jpg", caption: "My caption", timestamp: Date.now() });
115
+ const media = getRecentMedia(cid);
116
+ expect(media[0].caption).toBe("My caption");
117
+ });
118
+
119
+ it("generates correct id from chatId:msgId", () => {
120
+ const cid = `id-${Date.now()}`;
121
+ addMedia({ chatId: cid, msgId: 42, senderName: "User", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
122
+ const media = getRecentMedia(cid);
123
+ expect(media[0].id).toBe(`${cid}:42`);
124
+ });
125
+ });
126
+
127
+ describe("formatMediaIndex output format", () => {
128
+ it("includes timestamp in readable format", () => {
129
+ const ts = new Date("2025-03-15T14:30:00Z").getTime();
130
+ addMedia({ chatId: "fmt-1", msgId: 1, senderName: "Alice", type: "document", filePath: "/doc.pdf", timestamp: ts });
131
+ const text = formatMediaIndex("fmt-1");
132
+ expect(text).toContain("2025-03-15 14:30");
133
+ expect(text).toContain("[document]");
134
+ expect(text).toContain("msg:1");
135
+ expect(text).toContain("by Alice");
136
+ expect(text).toContain("file: /doc.pdf");
137
+ });
138
+
139
+ it("truncates long captions at 50 characters", () => {
140
+ const longCaption = "A".repeat(100);
141
+ addMedia({ chatId: "fmt-2", msgId: 1, senderName: "Bob", type: "photo", filePath: "/p.jpg", caption: longCaption, timestamp: Date.now() });
142
+ const text = formatMediaIndex("fmt-2");
143
+ // Caption should be truncated to 50 chars
144
+ expect(text).toContain(`"${"A".repeat(50)}"`);
145
+ expect(text).not.toContain(`"${"A".repeat(51)}"`);
146
+ });
147
+
148
+ it("omits caption when not provided", () => {
149
+ addMedia({ chatId: "fmt-3", msgId: 1, senderName: "Bob", type: "photo", filePath: "/p.jpg", timestamp: Date.now() });
150
+ const text = formatMediaIndex("fmt-3");
151
+ // Should not contain empty quotes
152
+ expect(text).not.toContain('""');
153
+ });
154
+
155
+ it("respects limit parameter", () => {
156
+ for (let i = 0; i < 20; i++) {
157
+ addMedia({ chatId: "fmt-4", msgId: i, senderName: "C", type: "photo", filePath: `/p${i}.jpg`, timestamp: Date.now() + i });
158
+ }
159
+ const text = formatMediaIndex("fmt-4", 3);
160
+ // Each entry has 2 lines (info + file path), so 3 entries
161
+ const entryCount = (text.match(/\[photo\]/g) || []).length;
162
+ expect(entryCount).toBe(3);
163
+ });
164
+ });
165
+
166
+ describe("getMediaByType", () => {
167
+ it("returns empty array when no entries match type", () => {
168
+ const cid = `type-none-${Date.now()}`;
169
+ addMedia({ chatId: cid, msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
170
+ expect(getMediaByType(cid, "voice")).toHaveLength(0);
171
+ });
172
+
173
+ it("respects limit parameter", () => {
174
+ const cid = `type-limit-${Date.now()}`;
175
+ for (let i = 0; i < 15; i++) {
176
+ addMedia({ chatId: cid, msgId: i, senderName: "A", type: "photo", filePath: `/p${i}.jpg`, timestamp: Date.now() + i });
177
+ }
178
+ expect(getMediaByType(cid, "photo", 5)).toHaveLength(5);
179
+ });
180
+
181
+ it("returns newest first", () => {
182
+ const cid = `type-order-${Date.now()}`;
183
+ addMedia({ chatId: cid, msgId: 1, senderName: "A", type: "voice", filePath: "/old.ogg", timestamp: 1000 });
184
+ addMedia({ chatId: cid, msgId: 2, senderName: "A", type: "voice", filePath: "/new.ogg", timestamp: 2000 });
185
+ const result = getMediaByType(cid, "voice");
186
+ expect(result[0].filePath).toBe("/new.ogg");
187
+ });
188
+ });
189
+
190
+ describe("loadMediaIndex", () => {
191
+ it("loads entries from existing file", () => {
192
+ const stored = [
193
+ { id: "load-1:1", chatId: "load-1", msgId: 1, senderName: "Alice", type: "photo", filePath: "/a.jpg", timestamp: Date.now() },
194
+ { id: "load-1:2", chatId: "load-1", msgId: 2, senderName: "Bob", type: "document", filePath: "/b.pdf", timestamp: Date.now() },
195
+ ];
196
+ existsSyncMock.mockReturnValue(true);
197
+ readFileSyncMock.mockReturnValue(JSON.stringify(stored));
198
+
199
+ loadMediaIndex();
200
+
201
+ const media = getRecentMedia("load-1");
202
+ expect(media).toHaveLength(2);
203
+ });
204
+
205
+ it("handles JSON parse errors gracefully", () => {
206
+ existsSyncMock.mockReturnValue(true);
207
+ readFileSyncMock.mockReturnValue("not valid json{{{");
208
+
209
+ // Should not throw, entries should be reset to []
210
+ expect(() => loadMediaIndex()).not.toThrow();
211
+ });
212
+
213
+ it("purges expired entries on load", () => {
214
+ const oldTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago (expired)
215
+ const recentTimestamp = Date.now() - 1000; // 1 second ago (fresh)
216
+ const stored = [
217
+ { id: "purge:1", chatId: "purge", msgId: 1, senderName: "A", type: "photo", filePath: "/old.jpg", timestamp: oldTimestamp },
218
+ { id: "purge:2", chatId: "purge", msgId: 2, senderName: "A", type: "photo", filePath: "/new.jpg", timestamp: recentTimestamp },
219
+ ];
220
+ existsSyncMock.mockReturnValue(true);
221
+ readFileSyncMock.mockReturnValue(JSON.stringify(stored));
222
+
223
+ loadMediaIndex();
224
+
225
+ const media = getRecentMedia("purge");
226
+ expect(media).toHaveLength(1);
227
+ expect(media[0].filePath).toBe("/new.jpg");
228
+ });
229
+
230
+ it("deletes expired media files from disk during purge", () => {
231
+ const oldTimestamp = Date.now() - 8 * 24 * 60 * 60 * 1000;
232
+ const stored = [
233
+ { id: "del:1", chatId: "del", msgId: 1, senderName: "A", type: "photo", filePath: "/expired.jpg", timestamp: oldTimestamp },
234
+ ];
235
+ // existsSync: first call for STORE_FILE=true, then for filePath during purge=true
236
+ existsSyncMock.mockReturnValue(true);
237
+ readFileSyncMock.mockReturnValue(JSON.stringify(stored));
238
+
239
+ loadMediaIndex();
240
+
241
+ expect(unlinkSyncMock).toHaveBeenCalledWith("/expired.jpg");
242
+ });
243
+ });
244
+
245
+ describe("flushMediaIndex", () => {
246
+ it("writes entries to disk", () => {
247
+ addMedia({ chatId: "flush-1", msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
248
+
249
+ existsSyncMock.mockReturnValue(true);
250
+ flushMediaIndex();
251
+
252
+ expect(writeFileSyncMock).toHaveBeenCalled();
253
+ const writtenData = writeFileSyncMock.mock.calls[0][1] as string;
254
+ const parsed = JSON.parse(writtenData.trim());
255
+ expect(Array.isArray(parsed)).toBe(true);
256
+ expect(parsed.length).toBeGreaterThan(0);
257
+ });
258
+
259
+ it("creates workspace directory if it does not exist", () => {
260
+ addMedia({ chatId: "flush-2", msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
261
+
262
+ existsSyncMock.mockReturnValue(false);
263
+ flushMediaIndex();
264
+
265
+ expect(mkdirSyncMock).toHaveBeenCalledWith(expect.any(String), { recursive: true });
266
+ });
267
+
268
+ it("handles write errors gracefully", () => {
269
+ addMedia({ chatId: "flush-3", msgId: 1, senderName: "A", type: "photo", filePath: "/a.jpg", timestamp: Date.now() });
270
+
271
+ existsSyncMock.mockReturnValue(true);
272
+ writeFileSyncMock.mockImplementationOnce(() => { throw new Error("disk full"); });
273
+
274
+ expect(() => flushMediaIndex()).not.toThrow();
275
+ });
276
+ });
277
+ });