@herdctl/chat 0.0.1 → 0.2.1

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/dist/__tests__/dm-filter.test.d.ts +5 -0
  3. package/dist/__tests__/dm-filter.test.d.ts.map +1 -0
  4. package/dist/__tests__/dm-filter.test.js +136 -0
  5. package/dist/__tests__/dm-filter.test.js.map +1 -0
  6. package/dist/__tests__/error-handler.test.d.ts +5 -0
  7. package/dist/__tests__/error-handler.test.d.ts.map +1 -0
  8. package/dist/__tests__/error-handler.test.js +235 -0
  9. package/dist/__tests__/error-handler.test.js.map +1 -0
  10. package/dist/__tests__/errors.test.d.ts +5 -0
  11. package/dist/__tests__/errors.test.d.ts.map +1 -0
  12. package/dist/__tests__/errors.test.js +140 -0
  13. package/dist/__tests__/errors.test.js.map +1 -0
  14. package/dist/__tests__/index.test.d.ts +2 -0
  15. package/dist/__tests__/index.test.d.ts.map +1 -0
  16. package/dist/__tests__/index.test.js +25 -0
  17. package/dist/__tests__/index.test.js.map +1 -0
  18. package/dist/__tests__/message-extraction.test.d.ts +5 -0
  19. package/dist/__tests__/message-extraction.test.d.ts.map +1 -0
  20. package/dist/__tests__/message-extraction.test.js +157 -0
  21. package/dist/__tests__/message-extraction.test.js.map +1 -0
  22. package/dist/__tests__/message-splitting.test.d.ts +5 -0
  23. package/dist/__tests__/message-splitting.test.d.ts.map +1 -0
  24. package/dist/__tests__/message-splitting.test.js +153 -0
  25. package/dist/__tests__/message-splitting.test.js.map +1 -0
  26. package/dist/__tests__/session-manager.test.d.ts +2 -0
  27. package/dist/__tests__/session-manager.test.d.ts.map +1 -0
  28. package/dist/__tests__/session-manager.test.js +779 -0
  29. package/dist/__tests__/session-manager.test.js.map +1 -0
  30. package/dist/__tests__/status-formatting.test.d.ts +5 -0
  31. package/dist/__tests__/status-formatting.test.d.ts.map +1 -0
  32. package/dist/__tests__/status-formatting.test.js +160 -0
  33. package/dist/__tests__/status-formatting.test.js.map +1 -0
  34. package/dist/__tests__/streaming-responder.test.d.ts +5 -0
  35. package/dist/__tests__/streaming-responder.test.d.ts.map +1 -0
  36. package/dist/__tests__/streaming-responder.test.js +154 -0
  37. package/dist/__tests__/streaming-responder.test.js.map +1 -0
  38. package/dist/dm-filter.d.ts +121 -0
  39. package/dist/dm-filter.d.ts.map +1 -0
  40. package/dist/dm-filter.js +162 -0
  41. package/dist/dm-filter.js.map +1 -0
  42. package/dist/error-handler.d.ts +217 -0
  43. package/dist/error-handler.d.ts.map +1 -0
  44. package/dist/error-handler.js +313 -0
  45. package/dist/error-handler.js.map +1 -0
  46. package/dist/errors.d.ts +118 -0
  47. package/dist/errors.d.ts.map +1 -0
  48. package/dist/errors.js +157 -0
  49. package/dist/errors.js.map +1 -0
  50. package/dist/index.d.ts +22 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +69 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/message-extraction.d.ts +81 -0
  55. package/dist/message-extraction.d.ts.map +1 -0
  56. package/dist/message-extraction.js +90 -0
  57. package/dist/message-extraction.js.map +1 -0
  58. package/dist/message-splitting.d.ts +133 -0
  59. package/dist/message-splitting.d.ts.map +1 -0
  60. package/dist/message-splitting.js +188 -0
  61. package/dist/message-splitting.js.map +1 -0
  62. package/dist/session-manager/errors.d.ts +59 -0
  63. package/dist/session-manager/errors.d.ts.map +1 -0
  64. package/dist/session-manager/errors.js +71 -0
  65. package/dist/session-manager/errors.js.map +1 -0
  66. package/dist/session-manager/index.d.ts +10 -0
  67. package/dist/session-manager/index.d.ts.map +1 -0
  68. package/dist/session-manager/index.js +14 -0
  69. package/dist/session-manager/index.js.map +1 -0
  70. package/dist/session-manager/session-manager.d.ts +123 -0
  71. package/dist/session-manager/session-manager.d.ts.map +1 -0
  72. package/dist/session-manager/session-manager.js +394 -0
  73. package/dist/session-manager/session-manager.js.map +1 -0
  74. package/dist/session-manager/types.d.ts +205 -0
  75. package/dist/session-manager/types.d.ts.map +1 -0
  76. package/dist/session-manager/types.js +67 -0
  77. package/dist/session-manager/types.js.map +1 -0
  78. package/dist/status-formatting.d.ts +147 -0
  79. package/dist/status-formatting.d.ts.map +1 -0
  80. package/dist/status-formatting.js +234 -0
  81. package/dist/status-formatting.js.map +1 -0
  82. package/dist/streaming-responder.d.ts +130 -0
  83. package/dist/streaming-responder.d.ts.map +1 -0
  84. package/dist/streaming-responder.js +178 -0
  85. package/dist/streaming-responder.js.map +1 -0
  86. package/dist/types.d.ts +184 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +8 -0
  89. package/dist/types.js.map +1 -0
  90. package/package.json +39 -4
@@ -0,0 +1,779 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
+ import { ChatSessionManager } from "../session-manager/session-manager.js";
8
+ import { createInitialSessionState, } from "../session-manager/types.js";
9
+ import { SessionStateReadError, } from "../session-manager/errors.js";
10
+ // =============================================================================
11
+ // Test Fixtures
12
+ // =============================================================================
13
+ function createTestDir() {
14
+ const random = randomBytes(8).toString("hex");
15
+ return join(tmpdir(), `herdctl-chat-session-test-${random}`);
16
+ }
17
+ function createMockLogger() {
18
+ return {
19
+ debug: vi.fn(),
20
+ info: vi.fn(),
21
+ warn: vi.fn(),
22
+ error: vi.fn(),
23
+ };
24
+ }
25
+ // =============================================================================
26
+ // Session Manager Tests - Discord Platform
27
+ // =============================================================================
28
+ describe("ChatSessionManager (platform: discord)", () => {
29
+ let testDir;
30
+ let mockLogger;
31
+ const platform = "discord";
32
+ beforeEach(async () => {
33
+ testDir = createTestDir();
34
+ await mkdir(testDir, { recursive: true });
35
+ mockLogger = createMockLogger();
36
+ vi.clearAllMocks();
37
+ });
38
+ afterEach(async () => {
39
+ try {
40
+ await rm(testDir, { recursive: true, force: true });
41
+ }
42
+ catch {
43
+ // Ignore cleanup errors
44
+ }
45
+ });
46
+ // ===========================================================================
47
+ // Constructor Tests
48
+ // ===========================================================================
49
+ describe("constructor", () => {
50
+ it("creates session manager with valid options", () => {
51
+ const manager = new ChatSessionManager({
52
+ platform,
53
+ agentName: "test-agent",
54
+ stateDir: testDir,
55
+ logger: mockLogger,
56
+ });
57
+ expect(manager.agentName).toBe("test-agent");
58
+ expect(manager.platform).toBe("discord");
59
+ });
60
+ it("uses default expiry hours when not specified", async () => {
61
+ const manager = new ChatSessionManager({
62
+ platform,
63
+ agentName: "test-agent",
64
+ stateDir: testDir,
65
+ logger: mockLogger,
66
+ });
67
+ // Create a session
68
+ await manager.getOrCreateSession("channel-1");
69
+ // Read the state file to verify it was created
70
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
71
+ const content = await readFile(stateFilePath, "utf-8");
72
+ const state = parseYaml(content);
73
+ expect(state.channels["channel-1"]).toBeDefined();
74
+ });
75
+ it("accepts custom expiry hours", () => {
76
+ const manager = new ChatSessionManager({
77
+ platform,
78
+ agentName: "test-agent",
79
+ stateDir: testDir,
80
+ sessionExpiryHours: 48,
81
+ logger: mockLogger,
82
+ });
83
+ expect(manager.agentName).toBe("test-agent");
84
+ });
85
+ });
86
+ // ===========================================================================
87
+ // getOrCreateSession Tests
88
+ // ===========================================================================
89
+ describe("getOrCreateSession", () => {
90
+ it("creates new session when none exists", async () => {
91
+ const manager = new ChatSessionManager({
92
+ platform,
93
+ agentName: "test-agent",
94
+ stateDir: testDir,
95
+ logger: mockLogger,
96
+ });
97
+ const result = await manager.getOrCreateSession("channel-123");
98
+ expect(result.isNew).toBe(true);
99
+ expect(result.sessionId).toMatch(/^discord-test-agent-/);
100
+ expect(mockLogger.info).toHaveBeenCalledWith("Created new session", expect.objectContaining({ channelId: "channel-123" }));
101
+ });
102
+ it("returns existing session when one exists", async () => {
103
+ const manager = new ChatSessionManager({
104
+ platform,
105
+ agentName: "test-agent",
106
+ stateDir: testDir,
107
+ logger: mockLogger,
108
+ });
109
+ // Create first session
110
+ const first = await manager.getOrCreateSession("channel-123");
111
+ // Get session again
112
+ const second = await manager.getOrCreateSession("channel-123");
113
+ expect(second.isNew).toBe(false);
114
+ expect(second.sessionId).toBe(first.sessionId);
115
+ expect(mockLogger.info).toHaveBeenCalledWith("Resuming existing session", expect.objectContaining({
116
+ channelId: "channel-123",
117
+ sessionId: first.sessionId,
118
+ }));
119
+ });
120
+ it("creates different sessions for different channels", async () => {
121
+ const manager = new ChatSessionManager({
122
+ platform,
123
+ agentName: "test-agent",
124
+ stateDir: testDir,
125
+ logger: mockLogger,
126
+ });
127
+ const session1 = await manager.getOrCreateSession("channel-1");
128
+ const session2 = await manager.getOrCreateSession("channel-2");
129
+ expect(session1.sessionId).not.toBe(session2.sessionId);
130
+ expect(session1.isNew).toBe(true);
131
+ expect(session2.isNew).toBe(true);
132
+ });
133
+ it("creates new session when previous is expired", async () => {
134
+ const manager = new ChatSessionManager({
135
+ platform,
136
+ agentName: "test-agent",
137
+ stateDir: testDir,
138
+ sessionExpiryHours: 1, // 1 hour expiry
139
+ logger: mockLogger,
140
+ });
141
+ // Create initial session
142
+ const first = await manager.getOrCreateSession("channel-123");
143
+ // Manually update state to have old timestamp
144
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
145
+ const content = await readFile(stateFilePath, "utf-8");
146
+ const state = parseYaml(content);
147
+ // Set last message to 2 hours ago
148
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
149
+ state.channels["channel-123"].lastMessageAt = twoHoursAgo.toISOString();
150
+ await writeFile(stateFilePath, stringifyYaml(state), "utf-8");
151
+ // Clear cache by creating new manager
152
+ const manager2 = new ChatSessionManager({
153
+ platform,
154
+ agentName: "test-agent",
155
+ stateDir: testDir,
156
+ sessionExpiryHours: 1,
157
+ logger: mockLogger,
158
+ });
159
+ // Get session again - should be new
160
+ const second = await manager2.getOrCreateSession("channel-123");
161
+ expect(second.isNew).toBe(true);
162
+ expect(second.sessionId).not.toBe(first.sessionId);
163
+ });
164
+ it("creates discord-sessions directory if it doesn't exist", async () => {
165
+ const manager = new ChatSessionManager({
166
+ platform,
167
+ agentName: "test-agent",
168
+ stateDir: testDir,
169
+ logger: mockLogger,
170
+ });
171
+ await manager.getOrCreateSession("channel-123");
172
+ // Verify directory was created
173
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
174
+ const content = await readFile(stateFilePath, "utf-8");
175
+ expect(content).toBeTruthy();
176
+ });
177
+ it("persists state to YAML file", async () => {
178
+ const manager = new ChatSessionManager({
179
+ platform,
180
+ agentName: "test-agent",
181
+ stateDir: testDir,
182
+ logger: mockLogger,
183
+ });
184
+ const result = await manager.getOrCreateSession("channel-123");
185
+ // Read and verify the state file
186
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
187
+ const content = await readFile(stateFilePath, "utf-8");
188
+ const state = parseYaml(content);
189
+ expect(state.version).toBe(1);
190
+ expect(state.agentName).toBe("test-agent");
191
+ expect(state.channels["channel-123"].sessionId).toBe(result.sessionId);
192
+ expect(state.channels["channel-123"].lastMessageAt).toBeTruthy();
193
+ });
194
+ });
195
+ // ===========================================================================
196
+ // touchSession Tests
197
+ // ===========================================================================
198
+ describe("touchSession", () => {
199
+ it("updates lastMessageAt timestamp", async () => {
200
+ const manager = new ChatSessionManager({
201
+ platform,
202
+ agentName: "test-agent",
203
+ stateDir: testDir,
204
+ logger: mockLogger,
205
+ });
206
+ // Create session
207
+ await manager.getOrCreateSession("channel-123");
208
+ // Wait a bit
209
+ await new Promise((resolve) => setTimeout(resolve, 50));
210
+ // Touch the session
211
+ await manager.touchSession("channel-123");
212
+ // Read state and verify timestamp was updated
213
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
214
+ const content = await readFile(stateFilePath, "utf-8");
215
+ const state = parseYaml(content);
216
+ const lastMessageAt = new Date(state.channels["channel-123"].lastMessageAt);
217
+ const now = new Date();
218
+ // Should be within last second
219
+ expect(now.getTime() - lastMessageAt.getTime()).toBeLessThan(1000);
220
+ });
221
+ it("warns when touching non-existent session", async () => {
222
+ const manager = new ChatSessionManager({
223
+ platform,
224
+ agentName: "test-agent",
225
+ stateDir: testDir,
226
+ logger: mockLogger,
227
+ });
228
+ await manager.touchSession("non-existent-channel");
229
+ expect(mockLogger.warn).toHaveBeenCalledWith("Attempted to touch non-existent session", { channelId: "non-existent-channel" });
230
+ });
231
+ });
232
+ // ===========================================================================
233
+ // getSession Tests
234
+ // ===========================================================================
235
+ describe("getSession", () => {
236
+ it("returns null when no session exists", async () => {
237
+ const manager = new ChatSessionManager({
238
+ platform,
239
+ agentName: "test-agent",
240
+ stateDir: testDir,
241
+ logger: mockLogger,
242
+ });
243
+ const session = await manager.getSession("channel-123");
244
+ expect(session).toBeNull();
245
+ });
246
+ it("returns session when it exists and is not expired", async () => {
247
+ const manager = new ChatSessionManager({
248
+ platform,
249
+ agentName: "test-agent",
250
+ stateDir: testDir,
251
+ logger: mockLogger,
252
+ });
253
+ // Create session
254
+ const created = await manager.getOrCreateSession("channel-123");
255
+ // Get session
256
+ const session = await manager.getSession("channel-123");
257
+ expect(session).not.toBeNull();
258
+ expect(session.sessionId).toBe(created.sessionId);
259
+ });
260
+ it("returns null when session is expired", async () => {
261
+ const manager = new ChatSessionManager({
262
+ platform,
263
+ agentName: "test-agent",
264
+ stateDir: testDir,
265
+ sessionExpiryHours: 1,
266
+ logger: mockLogger,
267
+ });
268
+ // Create session
269
+ await manager.getOrCreateSession("channel-123");
270
+ // Manually update state to have old timestamp
271
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
272
+ const content = await readFile(stateFilePath, "utf-8");
273
+ const state = parseYaml(content);
274
+ // Set last message to 2 hours ago
275
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
276
+ state.channels["channel-123"].lastMessageAt = twoHoursAgo.toISOString();
277
+ await writeFile(stateFilePath, stringifyYaml(state), "utf-8");
278
+ // Clear cache by creating new manager
279
+ const manager2 = new ChatSessionManager({
280
+ platform,
281
+ agentName: "test-agent",
282
+ stateDir: testDir,
283
+ sessionExpiryHours: 1,
284
+ logger: mockLogger,
285
+ });
286
+ // Get session - should be null
287
+ const session = await manager2.getSession("channel-123");
288
+ expect(session).toBeNull();
289
+ expect(mockLogger.info).toHaveBeenCalledWith("Session expired", expect.objectContaining({ channelId: "channel-123" }));
290
+ });
291
+ });
292
+ // ===========================================================================
293
+ // setSession Tests
294
+ // ===========================================================================
295
+ describe("setSession", () => {
296
+ it("stores a new session for a channel", async () => {
297
+ const manager = new ChatSessionManager({
298
+ platform,
299
+ agentName: "test-agent",
300
+ stateDir: testDir,
301
+ logger: mockLogger,
302
+ });
303
+ await manager.setSession("channel-123", "sdk-session-456");
304
+ // Verify session was stored
305
+ const session = await manager.getSession("channel-123");
306
+ expect(session).toBeDefined();
307
+ expect(session?.sessionId).toBe("sdk-session-456");
308
+ expect(mockLogger.info).toHaveBeenCalledWith("Stored new session", expect.objectContaining({
309
+ channelId: "channel-123",
310
+ sessionId: "sdk-session-456",
311
+ }));
312
+ });
313
+ it("updates an existing session with a new session ID", async () => {
314
+ const manager = new ChatSessionManager({
315
+ platform,
316
+ agentName: "test-agent",
317
+ stateDir: testDir,
318
+ logger: mockLogger,
319
+ });
320
+ // Store initial session
321
+ await manager.setSession("channel-123", "old-session-id");
322
+ // Update with new session ID
323
+ await manager.setSession("channel-123", "new-session-id");
324
+ // Verify session was updated
325
+ const session = await manager.getSession("channel-123");
326
+ expect(session).toBeDefined();
327
+ expect(session?.sessionId).toBe("new-session-id");
328
+ expect(mockLogger.debug).toHaveBeenCalledWith("Updated session", expect.objectContaining({
329
+ channelId: "channel-123",
330
+ oldSessionId: "old-session-id",
331
+ newSessionId: "new-session-id",
332
+ }));
333
+ });
334
+ it("updates the lastMessageAt timestamp", async () => {
335
+ const manager = new ChatSessionManager({
336
+ platform,
337
+ agentName: "test-agent",
338
+ stateDir: testDir,
339
+ logger: mockLogger,
340
+ });
341
+ const beforeSet = new Date();
342
+ await manager.setSession("channel-123", "sdk-session-456");
343
+ const afterSet = new Date();
344
+ const session = await manager.getSession("channel-123");
345
+ expect(session).toBeDefined();
346
+ const lastMessageAt = new Date(session.lastMessageAt);
347
+ expect(lastMessageAt.getTime()).toBeGreaterThanOrEqual(beforeSet.getTime());
348
+ expect(lastMessageAt.getTime()).toBeLessThanOrEqual(afterSet.getTime());
349
+ });
350
+ });
351
+ // ===========================================================================
352
+ // clearSession Tests
353
+ // ===========================================================================
354
+ describe("clearSession", () => {
355
+ it("returns false when no session exists", async () => {
356
+ const manager = new ChatSessionManager({
357
+ platform,
358
+ agentName: "test-agent",
359
+ stateDir: testDir,
360
+ logger: mockLogger,
361
+ });
362
+ const result = await manager.clearSession("channel-123");
363
+ expect(result).toBe(false);
364
+ });
365
+ it("clears existing session and returns true", async () => {
366
+ const manager = new ChatSessionManager({
367
+ platform,
368
+ agentName: "test-agent",
369
+ stateDir: testDir,
370
+ logger: mockLogger,
371
+ });
372
+ // Create session
373
+ await manager.getOrCreateSession("channel-123");
374
+ // Clear it
375
+ const result = await manager.clearSession("channel-123");
376
+ expect(result).toBe(true);
377
+ expect(mockLogger.info).toHaveBeenCalledWith("Cleared session", expect.objectContaining({ channelId: "channel-123" }));
378
+ // Verify it's gone
379
+ const session = await manager.getSession("channel-123");
380
+ expect(session).toBeNull();
381
+ });
382
+ it("only clears the specified session", async () => {
383
+ const manager = new ChatSessionManager({
384
+ platform,
385
+ agentName: "test-agent",
386
+ stateDir: testDir,
387
+ logger: mockLogger,
388
+ });
389
+ // Create sessions
390
+ await manager.getOrCreateSession("channel-1");
391
+ const session2 = await manager.getOrCreateSession("channel-2");
392
+ // Clear only channel-1
393
+ await manager.clearSession("channel-1");
394
+ // Verify channel-2 still exists
395
+ const remaining = await manager.getSession("channel-2");
396
+ expect(remaining).not.toBeNull();
397
+ expect(remaining.sessionId).toBe(session2.sessionId);
398
+ });
399
+ });
400
+ // ===========================================================================
401
+ // cleanupExpiredSessions Tests
402
+ // ===========================================================================
403
+ describe("cleanupExpiredSessions", () => {
404
+ it("returns 0 when no sessions exist", async () => {
405
+ const manager = new ChatSessionManager({
406
+ platform,
407
+ agentName: "test-agent",
408
+ stateDir: testDir,
409
+ logger: mockLogger,
410
+ });
411
+ const count = await manager.cleanupExpiredSessions();
412
+ expect(count).toBe(0);
413
+ });
414
+ it("returns 0 when no sessions are expired", async () => {
415
+ const manager = new ChatSessionManager({
416
+ platform,
417
+ agentName: "test-agent",
418
+ stateDir: testDir,
419
+ logger: mockLogger,
420
+ });
421
+ // Create sessions
422
+ await manager.getOrCreateSession("channel-1");
423
+ await manager.getOrCreateSession("channel-2");
424
+ const count = await manager.cleanupExpiredSessions();
425
+ expect(count).toBe(0);
426
+ });
427
+ it("cleans up expired sessions", async () => {
428
+ const manager = new ChatSessionManager({
429
+ platform,
430
+ agentName: "test-agent",
431
+ stateDir: testDir,
432
+ sessionExpiryHours: 1,
433
+ logger: mockLogger,
434
+ });
435
+ // Create sessions
436
+ await manager.getOrCreateSession("channel-1");
437
+ await manager.getOrCreateSession("channel-2");
438
+ await manager.getOrCreateSession("channel-3");
439
+ // Manually expire some sessions
440
+ const stateFilePath = join(testDir, "discord-sessions", "test-agent.yaml");
441
+ const content = await readFile(stateFilePath, "utf-8");
442
+ const state = parseYaml(content);
443
+ // Expire channel-1 and channel-3 (2 hours ago)
444
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
445
+ state.channels["channel-1"].lastMessageAt = twoHoursAgo.toISOString();
446
+ state.channels["channel-3"].lastMessageAt = twoHoursAgo.toISOString();
447
+ await writeFile(stateFilePath, stringifyYaml(state), "utf-8");
448
+ // Clear cache by creating new manager
449
+ const manager2 = new ChatSessionManager({
450
+ platform,
451
+ agentName: "test-agent",
452
+ stateDir: testDir,
453
+ sessionExpiryHours: 1,
454
+ logger: mockLogger,
455
+ });
456
+ // Cleanup
457
+ const count = await manager2.cleanupExpiredSessions();
458
+ expect(count).toBe(2);
459
+ expect(mockLogger.info).toHaveBeenCalledWith("Cleaned up expired sessions", { count: 2 });
460
+ // Verify channel-2 still exists
461
+ const session2 = await manager2.getSession("channel-2");
462
+ expect(session2).not.toBeNull();
463
+ // Verify channel-1 and channel-3 are gone
464
+ const session1 = await manager2.getSession("channel-1");
465
+ const session3 = await manager2.getSession("channel-3");
466
+ expect(session1).toBeNull();
467
+ expect(session3).toBeNull();
468
+ });
469
+ });
470
+ // ===========================================================================
471
+ // State File Recovery Tests
472
+ // ===========================================================================
473
+ describe("state file recovery", () => {
474
+ it("creates fresh state when file is corrupted", async () => {
475
+ // Write corrupted YAML
476
+ const stateDir = join(testDir, "discord-sessions");
477
+ await mkdir(stateDir, { recursive: true });
478
+ await writeFile(join(stateDir, "test-agent.yaml"), "invalid: yaml: content: {{");
479
+ const manager = new ChatSessionManager({
480
+ platform,
481
+ agentName: "test-agent",
482
+ stateDir: testDir,
483
+ logger: mockLogger,
484
+ });
485
+ // Should still work
486
+ const result = await manager.getOrCreateSession("channel-123");
487
+ expect(result.isNew).toBe(true);
488
+ expect(mockLogger.warn).toHaveBeenCalledWith("Corrupted session state file, creating fresh state", expect.any(Object));
489
+ });
490
+ it("creates fresh state when file has invalid schema", async () => {
491
+ // Write valid YAML but invalid schema
492
+ const stateDir = join(testDir, "discord-sessions");
493
+ await mkdir(stateDir, { recursive: true });
494
+ await writeFile(join(stateDir, "test-agent.yaml"), stringifyYaml({ version: 999, invalid: true }));
495
+ const manager = new ChatSessionManager({
496
+ platform,
497
+ agentName: "test-agent",
498
+ stateDir: testDir,
499
+ logger: mockLogger,
500
+ });
501
+ // Should still work
502
+ const result = await manager.getOrCreateSession("channel-123");
503
+ expect(result.isNew).toBe(true);
504
+ expect(mockLogger.warn).toHaveBeenCalledWith("Corrupted session state file, creating fresh state", expect.any(Object));
505
+ });
506
+ it("creates fresh state when file is empty", async () => {
507
+ // Write empty file
508
+ const stateDir = join(testDir, "discord-sessions");
509
+ await mkdir(stateDir, { recursive: true });
510
+ await writeFile(join(stateDir, "test-agent.yaml"), "");
511
+ const manager = new ChatSessionManager({
512
+ platform,
513
+ agentName: "test-agent",
514
+ stateDir: testDir,
515
+ logger: mockLogger,
516
+ });
517
+ // Should still work
518
+ const result = await manager.getOrCreateSession("channel-123");
519
+ expect(result.isNew).toBe(true);
520
+ });
521
+ });
522
+ // ===========================================================================
523
+ // agentName Property Tests
524
+ // ===========================================================================
525
+ describe("agentName", () => {
526
+ it("returns the agent name from options", () => {
527
+ const manager = new ChatSessionManager({
528
+ platform,
529
+ agentName: "my-test-agent",
530
+ stateDir: testDir,
531
+ logger: mockLogger,
532
+ });
533
+ expect(manager.agentName).toBe("my-test-agent");
534
+ });
535
+ });
536
+ // ===========================================================================
537
+ // Session ID Format Tests
538
+ // ===========================================================================
539
+ describe("session ID format", () => {
540
+ it("generates session IDs with expected format", async () => {
541
+ const manager = new ChatSessionManager({
542
+ platform,
543
+ agentName: "my-agent",
544
+ stateDir: testDir,
545
+ logger: mockLogger,
546
+ });
547
+ const result = await manager.getOrCreateSession("channel-123");
548
+ // Should match pattern: discord-<agent-name>-<uuid>
549
+ expect(result.sessionId).toMatch(/^discord-my-agent-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
550
+ });
551
+ });
552
+ // ===========================================================================
553
+ // Concurrent Access Tests
554
+ // ===========================================================================
555
+ describe("concurrent access", () => {
556
+ it("handles concurrent getOrCreateSession calls", async () => {
557
+ const manager = new ChatSessionManager({
558
+ platform,
559
+ agentName: "test-agent",
560
+ stateDir: testDir,
561
+ logger: mockLogger,
562
+ });
563
+ // Make concurrent calls
564
+ const promises = [
565
+ manager.getOrCreateSession("channel-1"),
566
+ manager.getOrCreateSession("channel-2"),
567
+ manager.getOrCreateSession("channel-3"),
568
+ ];
569
+ const results = await Promise.all(promises);
570
+ // All should succeed
571
+ expect(results[0].isNew).toBe(true);
572
+ expect(results[1].isNew).toBe(true);
573
+ expect(results[2].isNew).toBe(true);
574
+ // Each should have unique session ID
575
+ const sessionIds = new Set(results.map((r) => r.sessionId));
576
+ expect(sessionIds.size).toBe(3);
577
+ });
578
+ });
579
+ });
580
+ // =============================================================================
581
+ // Session Manager Tests - Slack Platform
582
+ // =============================================================================
583
+ describe("ChatSessionManager (platform: slack)", () => {
584
+ let testDir;
585
+ let mockLogger;
586
+ const platform = "slack";
587
+ beforeEach(async () => {
588
+ testDir = createTestDir();
589
+ await mkdir(testDir, { recursive: true });
590
+ mockLogger = createMockLogger();
591
+ vi.clearAllMocks();
592
+ });
593
+ afterEach(async () => {
594
+ try {
595
+ await rm(testDir, { recursive: true, force: true });
596
+ }
597
+ catch {
598
+ // Ignore cleanup errors
599
+ }
600
+ });
601
+ describe("platform-specific behavior", () => {
602
+ it("stores sessions in slack-sessions directory", async () => {
603
+ const manager = new ChatSessionManager({
604
+ platform,
605
+ agentName: "test-agent",
606
+ stateDir: testDir,
607
+ logger: mockLogger,
608
+ });
609
+ await manager.getOrCreateSession("channel-123");
610
+ // Verify directory was created with slack prefix
611
+ const stateFilePath = join(testDir, "slack-sessions", "test-agent.yaml");
612
+ const content = await readFile(stateFilePath, "utf-8");
613
+ expect(content).toBeTruthy();
614
+ });
615
+ it("generates session IDs with slack prefix", async () => {
616
+ const manager = new ChatSessionManager({
617
+ platform,
618
+ agentName: "my-agent",
619
+ stateDir: testDir,
620
+ logger: mockLogger,
621
+ });
622
+ const result = await manager.getOrCreateSession("channel-123");
623
+ // Should match pattern: slack-<agent-name>-<uuid>
624
+ expect(result.sessionId).toMatch(/^slack-my-agent-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
625
+ });
626
+ it("exposes platform property", () => {
627
+ const manager = new ChatSessionManager({
628
+ platform,
629
+ agentName: "test-agent",
630
+ stateDir: testDir,
631
+ logger: mockLogger,
632
+ });
633
+ expect(manager.platform).toBe("slack");
634
+ });
635
+ });
636
+ describe("full session lifecycle", () => {
637
+ it("completes full session lifecycle", async () => {
638
+ const manager = new ChatSessionManager({
639
+ platform,
640
+ agentName: "test-agent",
641
+ stateDir: testDir,
642
+ logger: mockLogger,
643
+ });
644
+ // Create session
645
+ const created = await manager.getOrCreateSession("channel-123");
646
+ expect(created.isNew).toBe(true);
647
+ expect(created.sessionId).toMatch(/^slack-test-agent-/);
648
+ // Touch session
649
+ await manager.touchSession("channel-123");
650
+ // Get session
651
+ const retrieved = await manager.getSession("channel-123");
652
+ expect(retrieved).not.toBeNull();
653
+ expect(retrieved.sessionId).toBe(created.sessionId);
654
+ // Set session with new ID
655
+ await manager.setSession("channel-123", "new-sdk-session");
656
+ const updated = await manager.getSession("channel-123");
657
+ expect(updated.sessionId).toBe("new-sdk-session");
658
+ // Get active session count
659
+ const count = await manager.getActiveSessionCount();
660
+ expect(count).toBe(1);
661
+ // Clear session
662
+ const cleared = await manager.clearSession("channel-123");
663
+ expect(cleared).toBe(true);
664
+ // Verify cleared
665
+ const afterClear = await manager.getSession("channel-123");
666
+ expect(afterClear).toBeNull();
667
+ });
668
+ });
669
+ });
670
+ // =============================================================================
671
+ // Platform-Agnostic Tests
672
+ // =============================================================================
673
+ describe("ChatSessionManager (platform-agnostic)", () => {
674
+ let testDir;
675
+ let mockLogger;
676
+ beforeEach(async () => {
677
+ testDir = createTestDir();
678
+ await mkdir(testDir, { recursive: true });
679
+ mockLogger = createMockLogger();
680
+ vi.clearAllMocks();
681
+ });
682
+ afterEach(async () => {
683
+ try {
684
+ await rm(testDir, { recursive: true, force: true });
685
+ }
686
+ catch {
687
+ // Ignore cleanup errors
688
+ }
689
+ });
690
+ describe("custom platform", () => {
691
+ it("supports arbitrary platform names", async () => {
692
+ const manager = new ChatSessionManager({
693
+ platform: "teams",
694
+ agentName: "test-agent",
695
+ stateDir: testDir,
696
+ logger: mockLogger,
697
+ });
698
+ const result = await manager.getOrCreateSession("channel-123");
699
+ // Verify custom platform in session ID
700
+ expect(result.sessionId).toMatch(/^teams-test-agent-/);
701
+ // Verify custom platform directory
702
+ const stateFilePath = join(testDir, "teams-sessions", "test-agent.yaml");
703
+ const content = await readFile(stateFilePath, "utf-8");
704
+ expect(content).toBeTruthy();
705
+ // Verify platform property
706
+ expect(manager.platform).toBe("teams");
707
+ });
708
+ });
709
+ describe("multiple platforms can coexist", () => {
710
+ it("maintains separate session stores per platform", async () => {
711
+ const discordManager = new ChatSessionManager({
712
+ platform: "discord",
713
+ agentName: "test-agent",
714
+ stateDir: testDir,
715
+ logger: mockLogger,
716
+ });
717
+ const slackManager = new ChatSessionManager({
718
+ platform: "slack",
719
+ agentName: "test-agent",
720
+ stateDir: testDir,
721
+ logger: mockLogger,
722
+ });
723
+ // Create sessions in both
724
+ const discordSession = await discordManager.getOrCreateSession("channel-123");
725
+ const slackSession = await slackManager.getOrCreateSession("channel-123");
726
+ // Sessions should be independent
727
+ expect(discordSession.sessionId).not.toBe(slackSession.sessionId);
728
+ expect(discordSession.sessionId).toMatch(/^discord-/);
729
+ expect(slackSession.sessionId).toMatch(/^slack-/);
730
+ // Clear Discord session
731
+ await discordManager.clearSession("channel-123");
732
+ // Slack session should still exist
733
+ const slackAfterClear = await slackManager.getSession("channel-123");
734
+ expect(slackAfterClear).not.toBeNull();
735
+ expect(slackAfterClear.sessionId).toBe(slackSession.sessionId);
736
+ });
737
+ });
738
+ });
739
+ // =============================================================================
740
+ // Error Tests
741
+ // =============================================================================
742
+ describe("ChatSessionManager errors", () => {
743
+ let mockLogger;
744
+ beforeEach(() => {
745
+ mockLogger = createMockLogger();
746
+ vi.clearAllMocks();
747
+ });
748
+ describe("SessionStateReadError", () => {
749
+ it("is thrown on permission errors", async () => {
750
+ // Create a directory that looks like a file (causes read to fail)
751
+ const testDir = createTestDir();
752
+ const stateDir = join(testDir, "discord-sessions");
753
+ await mkdir(stateDir, { recursive: true });
754
+ // Create the state file as a directory (which will cause read to fail)
755
+ await mkdir(join(stateDir, "test-agent.yaml"));
756
+ const manager = new ChatSessionManager({
757
+ platform: "discord",
758
+ agentName: "test-agent",
759
+ stateDir: testDir,
760
+ logger: mockLogger,
761
+ });
762
+ await expect(manager.getOrCreateSession("channel-123")).rejects.toThrow(SessionStateReadError);
763
+ // Cleanup
764
+ await rm(testDir, { recursive: true, force: true });
765
+ });
766
+ });
767
+ });
768
+ // =============================================================================
769
+ // Factory Function Tests
770
+ // =============================================================================
771
+ describe("createInitialSessionState", () => {
772
+ it("creates state with version 1", () => {
773
+ const state = createInitialSessionState("my-agent");
774
+ expect(state.version).toBe(1);
775
+ expect(state.agentName).toBe("my-agent");
776
+ expect(state.channels).toEqual({});
777
+ });
778
+ });
779
+ //# sourceMappingURL=session-manager.test.js.map