@herdctl/core 0.2.0 → 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 (30) hide show
  1. package/dist/fleet-manager/__tests__/discord-manager.test.d.ts +8 -0
  2. package/dist/fleet-manager/__tests__/discord-manager.test.d.ts.map +1 -0
  3. package/dist/fleet-manager/__tests__/discord-manager.test.js +2168 -0
  4. package/dist/fleet-manager/__tests__/discord-manager.test.js.map +1 -0
  5. package/dist/fleet-manager/context.d.ts +4 -0
  6. package/dist/fleet-manager/context.d.ts.map +1 -1
  7. package/dist/fleet-manager/discord-manager.d.ts +280 -0
  8. package/dist/fleet-manager/discord-manager.d.ts.map +1 -0
  9. package/dist/fleet-manager/discord-manager.js +583 -0
  10. package/dist/fleet-manager/discord-manager.js.map +1 -0
  11. package/dist/fleet-manager/event-types.d.ts +45 -0
  12. package/dist/fleet-manager/event-types.d.ts.map +1 -1
  13. package/dist/fleet-manager/fleet-manager.d.ts +3 -0
  14. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  15. package/dist/fleet-manager/fleet-manager.js +10 -0
  16. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  17. package/dist/fleet-manager/index.d.ts +3 -1
  18. package/dist/fleet-manager/index.d.ts.map +1 -1
  19. package/dist/fleet-manager/index.js +1 -0
  20. package/dist/fleet-manager/index.js.map +1 -1
  21. package/dist/fleet-manager/job-control.d.ts.map +1 -1
  22. package/dist/fleet-manager/job-control.js +3 -0
  23. package/dist/fleet-manager/job-control.js.map +1 -1
  24. package/dist/fleet-manager/status-queries.d.ts +5 -1
  25. package/dist/fleet-manager/status-queries.d.ts.map +1 -1
  26. package/dist/fleet-manager/status-queries.js +42 -3
  27. package/dist/fleet-manager/status-queries.js.map +1 -1
  28. package/dist/fleet-manager/types.d.ts +48 -1
  29. package/dist/fleet-manager/types.d.ts.map +1 -1
  30. package/package.json +9 -1
@@ -0,0 +1,2168 @@
1
+ /**
2
+ * Tests for DiscordManager
3
+ *
4
+ * Tests the DiscordManager class which manages Discord connectors
5
+ * for agents with chat.discord configured.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
+ import { EventEmitter } from "node:events";
9
+ import { DiscordManager } from "../discord-manager.js";
10
+ // Mock logger
11
+ const mockLogger = {
12
+ debug: vi.fn(),
13
+ info: vi.fn(),
14
+ warn: vi.fn(),
15
+ error: vi.fn(),
16
+ };
17
+ // Mock emitter
18
+ const mockEmitter = new EventEmitter();
19
+ // Create mock FleetManagerContext
20
+ function createMockContext(config = null) {
21
+ return {
22
+ getConfig: () => config,
23
+ getStateDir: () => "/tmp/test-state",
24
+ getStateDirInfo: () => null,
25
+ getLogger: () => mockLogger,
26
+ getScheduler: () => null,
27
+ getStatus: () => "initialized",
28
+ getInitializedAt: () => null,
29
+ getStartedAt: () => null,
30
+ getStoppedAt: () => null,
31
+ getLastError: () => null,
32
+ getCheckInterval: () => 1000,
33
+ emit: (event, ...args) => mockEmitter.emit(event, ...args),
34
+ getEmitter: () => mockEmitter,
35
+ };
36
+ }
37
+ // Create a mock agent with Discord config
38
+ function createDiscordAgent(name, discordConfig) {
39
+ return {
40
+ name,
41
+ model: "sonnet",
42
+ schedules: {},
43
+ chat: {
44
+ discord: discordConfig,
45
+ },
46
+ configPath: "/test/herdctl.yaml",
47
+ };
48
+ }
49
+ // Create a mock agent without Discord config
50
+ function createNonDiscordAgent(name) {
51
+ return {
52
+ name,
53
+ model: "sonnet",
54
+ schedules: {},
55
+ configPath: "/test/herdctl.yaml",
56
+ };
57
+ }
58
+ describe("DiscordManager", () => {
59
+ beforeEach(() => {
60
+ vi.clearAllMocks();
61
+ });
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+ });
65
+ describe("constructor", () => {
66
+ it("creates instance with context", () => {
67
+ const ctx = createMockContext();
68
+ const manager = new DiscordManager(ctx);
69
+ expect(manager).toBeDefined();
70
+ });
71
+ });
72
+ describe("initialize", () => {
73
+ it("skips initialization when no config is available", async () => {
74
+ const ctx = createMockContext(null);
75
+ const manager = new DiscordManager(ctx);
76
+ await manager.initialize();
77
+ expect(mockLogger.debug).toHaveBeenCalledWith("No config available, skipping Discord initialization");
78
+ expect(manager.getConnectorNames()).toEqual([]);
79
+ });
80
+ it("skips initialization when no agents have Discord configured", async () => {
81
+ const config = {
82
+ fleet: { name: "test-fleet" },
83
+ agents: [
84
+ createNonDiscordAgent("agent1"),
85
+ createNonDiscordAgent("agent2"),
86
+ ],
87
+ configPath: "/test/herdctl.yaml",
88
+ configDir: "/test",
89
+ };
90
+ const ctx = createMockContext(config);
91
+ const manager = new DiscordManager(ctx);
92
+ // Mock the dynamic import to return null (package not installed)
93
+ vi.doMock("@herdctl/discord", () => {
94
+ throw new Error("Package not found");
95
+ });
96
+ await manager.initialize();
97
+ // Should either say "not installed" or "No agents with Discord configured"
98
+ const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
99
+ expect(debugCalls.some((msg) => msg.includes("not installed") ||
100
+ msg.includes("No agents with Discord configured"))).toBe(true);
101
+ });
102
+ it("is idempotent - multiple calls only initialize once", async () => {
103
+ const config = {
104
+ fleet: { name: "test-fleet" },
105
+ agents: [createNonDiscordAgent("agent1")],
106
+ configPath: "/test/herdctl.yaml",
107
+ configDir: "/test",
108
+ };
109
+ const ctx = createMockContext(config);
110
+ const manager = new DiscordManager(ctx);
111
+ await manager.initialize();
112
+ await manager.initialize();
113
+ // The second call should return early without doing anything
114
+ // We can verify by checking the debug logs
115
+ const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
116
+ // First init will log something, second call should not add more logs
117
+ // about initialization because it returns early
118
+ });
119
+ it("warns when bot token environment variable is not set", async () => {
120
+ const discordConfig = {
121
+ bot_token_env: "NONEXISTENT_BOT_TOKEN_VAR",
122
+ session_expiry_hours: 24,
123
+ log_level: "standard",
124
+ guilds: [],
125
+ };
126
+ const config = {
127
+ fleet: { name: "test-fleet" },
128
+ agents: [createDiscordAgent("agent1", discordConfig)],
129
+ configPath: "/test/herdctl.yaml",
130
+ configDir: "/test",
131
+ };
132
+ const ctx = createMockContext(config);
133
+ const manager = new DiscordManager(ctx);
134
+ // Clear the env var if it exists
135
+ const originalValue = process.env["NONEXISTENT_BOT_TOKEN_VAR"];
136
+ delete process.env["NONEXISTENT_BOT_TOKEN_VAR"];
137
+ await manager.initialize();
138
+ // Restore if it existed
139
+ if (originalValue !== undefined) {
140
+ process.env["NONEXISTENT_BOT_TOKEN_VAR"] = originalValue;
141
+ }
142
+ // The warning should only be logged if the discord package is available
143
+ // If the package is not available, it will log "not installed" first
144
+ const warnCalls = mockLogger.warn.mock.calls;
145
+ const debugCalls = mockLogger.debug.mock.calls;
146
+ // Either the package is not installed (debug log) or the token is missing (warn log)
147
+ const packageNotInstalled = debugCalls.some((call) => call[0].includes("not installed"));
148
+ const tokenMissing = warnCalls.some((call) => call[0].includes("Bot token not found"));
149
+ expect(packageNotInstalled || tokenMissing || warnCalls.length === 0).toBe(true);
150
+ });
151
+ });
152
+ describe("start", () => {
153
+ it("logs when no connectors to start", async () => {
154
+ const ctx = createMockContext(null);
155
+ const manager = new DiscordManager(ctx);
156
+ await manager.initialize();
157
+ await manager.start();
158
+ expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to start");
159
+ });
160
+ });
161
+ describe("stop", () => {
162
+ it("logs when no connectors to stop", async () => {
163
+ const ctx = createMockContext(null);
164
+ const manager = new DiscordManager(ctx);
165
+ await manager.initialize();
166
+ await manager.stop();
167
+ expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to stop");
168
+ });
169
+ });
170
+ describe("getConnector", () => {
171
+ it("returns undefined for non-existent agent", () => {
172
+ const ctx = createMockContext(null);
173
+ const manager = new DiscordManager(ctx);
174
+ const connector = manager.getConnector("nonexistent");
175
+ expect(connector).toBeUndefined();
176
+ });
177
+ });
178
+ describe("getConnectorNames", () => {
179
+ it("returns empty array when no connectors", () => {
180
+ const ctx = createMockContext(null);
181
+ const manager = new DiscordManager(ctx);
182
+ expect(manager.getConnectorNames()).toEqual([]);
183
+ });
184
+ });
185
+ describe("getConnectedCount", () => {
186
+ it("returns 0 when no connectors", () => {
187
+ const ctx = createMockContext(null);
188
+ const manager = new DiscordManager(ctx);
189
+ expect(manager.getConnectedCount()).toBe(0);
190
+ });
191
+ });
192
+ describe("hasConnector", () => {
193
+ it("returns false for non-existent agent", () => {
194
+ const ctx = createMockContext(null);
195
+ const manager = new DiscordManager(ctx);
196
+ expect(manager.hasConnector("nonexistent")).toBe(false);
197
+ });
198
+ });
199
+ });
200
+ describe("DiscordConnectorState type", () => {
201
+ it("defines proper connector state structure", () => {
202
+ // This test verifies the type is exported correctly
203
+ const state = {
204
+ status: "disconnected",
205
+ connectedAt: null,
206
+ disconnectedAt: null,
207
+ reconnectAttempts: 0,
208
+ lastError: null,
209
+ botUser: null,
210
+ rateLimits: {
211
+ totalCount: 0,
212
+ lastRateLimitAt: null,
213
+ isRateLimited: false,
214
+ currentResetTime: 0,
215
+ },
216
+ messageStats: {
217
+ received: 0,
218
+ sent: 0,
219
+ ignored: 0,
220
+ },
221
+ };
222
+ expect(state.status).toBe("disconnected");
223
+ expect(state.botUser).toBeNull();
224
+ expect(state.rateLimits.isRateLimited).toBe(false);
225
+ expect(state.messageStats.received).toBe(0);
226
+ });
227
+ it("supports all connection status values", () => {
228
+ const statuses = [
229
+ "disconnected",
230
+ "connecting",
231
+ "connected",
232
+ "reconnecting",
233
+ "disconnecting",
234
+ "error",
235
+ ];
236
+ statuses.forEach((status) => {
237
+ const state = {
238
+ status,
239
+ connectedAt: null,
240
+ disconnectedAt: null,
241
+ reconnectAttempts: 0,
242
+ lastError: null,
243
+ botUser: null,
244
+ rateLimits: {
245
+ totalCount: 0,
246
+ lastRateLimitAt: null,
247
+ isRateLimited: false,
248
+ currentResetTime: 0,
249
+ },
250
+ messageStats: {
251
+ received: 0,
252
+ sent: 0,
253
+ ignored: 0,
254
+ },
255
+ };
256
+ expect(state.status).toBe(status);
257
+ });
258
+ });
259
+ it("supports connected state with bot user", () => {
260
+ const state = {
261
+ status: "connected",
262
+ connectedAt: "2024-01-01T00:00:00.000Z",
263
+ disconnectedAt: null,
264
+ reconnectAttempts: 0,
265
+ lastError: null,
266
+ botUser: {
267
+ id: "123456789",
268
+ username: "TestBot",
269
+ discriminator: "1234",
270
+ },
271
+ rateLimits: {
272
+ totalCount: 5,
273
+ lastRateLimitAt: "2024-01-01T00:01:00.000Z",
274
+ isRateLimited: false,
275
+ currentResetTime: 0,
276
+ },
277
+ messageStats: {
278
+ received: 100,
279
+ sent: 50,
280
+ ignored: 25,
281
+ },
282
+ };
283
+ expect(state.status).toBe("connected");
284
+ expect(state.botUser?.username).toBe("TestBot");
285
+ expect(state.messageStats.received).toBe(100);
286
+ });
287
+ });
288
+ describe("DiscordMessageEvent type", () => {
289
+ it("defines proper message event structure", () => {
290
+ const event = {
291
+ agentName: "test-agent",
292
+ prompt: "Hello, how are you?",
293
+ context: {
294
+ messages: [
295
+ {
296
+ author: "user123",
297
+ content: "Hello!",
298
+ isBot: false,
299
+ timestamp: "2024-01-01T00:00:00.000Z",
300
+ },
301
+ ],
302
+ wasMentioned: true,
303
+ prompt: "Hello, how are you?",
304
+ },
305
+ metadata: {
306
+ guildId: "guild123",
307
+ channelId: "channel456",
308
+ messageId: "msg789",
309
+ userId: "user123",
310
+ username: "TestUser",
311
+ wasMentioned: true,
312
+ mode: "mention",
313
+ },
314
+ reply: async (content) => {
315
+ console.log("Reply:", content);
316
+ },
317
+ startTyping: () => () => { },
318
+ };
319
+ expect(event.agentName).toBe("test-agent");
320
+ expect(event.prompt).toBe("Hello, how are you?");
321
+ expect(event.metadata.guildId).toBe("guild123");
322
+ expect(event.context.wasMentioned).toBe(true);
323
+ });
324
+ it("supports DM context (null guildId)", () => {
325
+ const event = {
326
+ agentName: "dm-agent",
327
+ prompt: "Private message",
328
+ context: {
329
+ messages: [],
330
+ wasMentioned: false,
331
+ prompt: "Private message",
332
+ },
333
+ metadata: {
334
+ guildId: null,
335
+ channelId: "dm-channel",
336
+ messageId: "dm-msg",
337
+ userId: "user1",
338
+ username: "DMUser",
339
+ wasMentioned: false,
340
+ mode: "auto",
341
+ },
342
+ reply: async () => { },
343
+ startTyping: () => () => { },
344
+ };
345
+ expect(event.metadata.guildId).toBeNull();
346
+ expect(event.metadata.mode).toBe("auto");
347
+ });
348
+ });
349
+ describe("DiscordErrorEvent type", () => {
350
+ it("defines proper error event structure", () => {
351
+ const event = {
352
+ agentName: "test-agent",
353
+ error: new Error("Connection failed"),
354
+ };
355
+ expect(event.agentName).toBe("test-agent");
356
+ expect(event.error.message).toBe("Connection failed");
357
+ });
358
+ });
359
+ describe("DiscordManager response splitting", () => {
360
+ let manager;
361
+ beforeEach(() => {
362
+ vi.clearAllMocks();
363
+ const ctx = createMockContext(null);
364
+ manager = new DiscordManager(ctx);
365
+ });
366
+ describe("splitResponse", () => {
367
+ it("returns text as-is when under 2000 characters", () => {
368
+ const text = "Hello, this is a short message.";
369
+ const result = manager.splitResponse(text);
370
+ expect(result).toEqual([text]);
371
+ });
372
+ it("returns text as-is when exactly 2000 characters", () => {
373
+ const text = "a".repeat(2000);
374
+ const result = manager.splitResponse(text);
375
+ expect(result).toEqual([text]);
376
+ });
377
+ it("splits text at natural boundaries (newlines)", () => {
378
+ // Create text that's over 2000 chars with newlines
379
+ const line = "This is a line of text.\n";
380
+ const text = line.repeat(100); // About 2400 chars
381
+ const result = manager.splitResponse(text);
382
+ expect(result.length).toBeGreaterThan(1);
383
+ // Each chunk should be under 2000 chars
384
+ result.forEach((chunk) => {
385
+ expect(chunk.length).toBeLessThanOrEqual(2000);
386
+ });
387
+ // Chunks should join back to original
388
+ expect(result.join("")).toBe(text);
389
+ });
390
+ it("splits text at spaces when no newlines available", () => {
391
+ // Create text that's over 2000 chars with spaces but no newlines
392
+ const words = "word ".repeat(500); // About 2500 chars
393
+ const result = manager.splitResponse(words);
394
+ expect(result.length).toBeGreaterThan(1);
395
+ result.forEach((chunk) => {
396
+ expect(chunk.length).toBeLessThanOrEqual(2000);
397
+ });
398
+ });
399
+ it("handles text with no natural break points", () => {
400
+ const text = "a".repeat(3000); // No spaces or newlines
401
+ const result = manager.splitResponse(text);
402
+ expect(result.length).toBe(2);
403
+ expect(result[0].length).toBe(2000);
404
+ expect(result[1].length).toBe(1000);
405
+ });
406
+ it("preserves code blocks when splitting", () => {
407
+ // Create a code block that spans beyond 2000 chars
408
+ const codeBlock = "```typescript\n" + "const x = 1;\n".repeat(200) + "```";
409
+ const result = manager.splitResponse(codeBlock);
410
+ expect(result.length).toBeGreaterThan(1);
411
+ // First chunk should close the code block
412
+ expect(result[0]).toMatch(/```$/);
413
+ // Second chunk should reopen with the same language
414
+ expect(result[1]).toMatch(/^```typescript/);
415
+ });
416
+ it("preserves code blocks with no language specified", () => {
417
+ const codeBlock = "```\n" + "line of code\n".repeat(200) + "```";
418
+ const result = manager.splitResponse(codeBlock);
419
+ expect(result.length).toBeGreaterThan(1);
420
+ // First chunk should close the code block
421
+ expect(result[0]).toMatch(/```$/);
422
+ // Second chunk should reopen (possibly with empty language)
423
+ expect(result[1]).toMatch(/^```/);
424
+ });
425
+ it("handles multiple code blocks", () => {
426
+ const text = "Some text\n```js\nconsole.log('hello');\n```\nMore text\n```python\nprint('hello')\n```";
427
+ const result = manager.splitResponse(text);
428
+ // This should fit in one message
429
+ expect(result).toEqual([text]);
430
+ });
431
+ it("handles empty string", () => {
432
+ const result = manager.splitResponse("");
433
+ expect(result).toEqual([""]);
434
+ });
435
+ it("prefers paragraph breaks over line breaks", () => {
436
+ // Create text with both paragraph and line breaks
437
+ const paragraph1 = "First paragraph. ".repeat(50) + "\n\n";
438
+ const paragraph2 = "Second paragraph. ".repeat(50);
439
+ const text = paragraph1 + paragraph2;
440
+ if (text.length > 2000) {
441
+ const result = manager.splitResponse(text);
442
+ // Should split at the paragraph break
443
+ expect(result[0]).toMatch(/\n\n$/);
444
+ }
445
+ });
446
+ it("handles code block that opens and closes within split region", () => {
447
+ // Create text where a code block opens and then closes before split point
448
+ // This tests the code path where insideBlock becomes false after closing
449
+ const text = "Some intro text\n```js\nconst x = 1;\n```\nMore text here " + "padding ".repeat(250);
450
+ const result = manager.splitResponse(text);
451
+ expect(result.length).toBeGreaterThanOrEqual(1);
452
+ // Should not break inside code block since it's closed
453
+ result.forEach((chunk) => {
454
+ expect(chunk.length).toBeLessThanOrEqual(2000);
455
+ });
456
+ });
457
+ it("handles code block analysis when initially inside but closes on re-analysis", () => {
458
+ // Create text where initial analysis shows inside block at 2000 chars,
459
+ // but when we find a natural break and re-analyze, the block is closed
460
+ // This exercises the code path at line 727 where actualState.insideBlock is false
461
+ const codeBlock = "```js\nshort code\n```";
462
+ const paddingToReachSplit = "x".repeat(1900 - codeBlock.length);
463
+ const moreContent = " ".repeat(50) + "y".repeat(200); // Add space for split and more content
464
+ const text = codeBlock + paddingToReachSplit + moreContent;
465
+ const result = manager.splitResponse(text);
466
+ expect(result.length).toBeGreaterThanOrEqual(1);
467
+ result.forEach((chunk) => {
468
+ expect(chunk.length).toBeLessThanOrEqual(2000);
469
+ });
470
+ });
471
+ it("handles multiple code blocks opening and closing", () => {
472
+ // Multiple code blocks that open and close
473
+ const text = "```js\ncode1\n```\n" + "text ".repeat(100) + "\n```py\ncode2\n```\n" + "more ".repeat(200);
474
+ const result = manager.splitResponse(text);
475
+ expect(result.length).toBeGreaterThanOrEqual(1);
476
+ result.forEach((chunk) => {
477
+ expect(chunk.length).toBeLessThanOrEqual(2000);
478
+ });
479
+ });
480
+ it("splits at paragraph break when within 500 chars of split point", () => {
481
+ // Create text where paragraph break is close enough to the split point to be used
482
+ // Need text > 2000 chars with a paragraph break in the last 500 chars before 2000
483
+ const part1 = "a".repeat(1600);
484
+ const part2 = "\n\n"; // paragraph break
485
+ const part3 = "b".repeat(600); // Pushes us over 2000
486
+ const text = part1 + part2 + part3;
487
+ const result = manager.splitResponse(text);
488
+ expect(result.length).toBe(2);
489
+ // First chunk should end at paragraph break
490
+ expect(result[0]).toBe(part1 + part2);
491
+ expect(result[1]).toBe(part3);
492
+ });
493
+ it("falls back to newline when paragraph break is too far from split point", () => {
494
+ // Create text where paragraph break is too far but newline is close
495
+ const part1 = "a".repeat(1000);
496
+ const part2 = "\n\n"; // paragraph break too early
497
+ const part3 = "b".repeat(800);
498
+ const part4 = "\n"; // newline close to split point
499
+ const part5 = "c".repeat(400);
500
+ const text = part1 + part2 + part3 + part4 + part5;
501
+ const result = manager.splitResponse(text);
502
+ expect(result.length).toBeGreaterThanOrEqual(1);
503
+ result.forEach((chunk) => {
504
+ expect(chunk.length).toBeLessThanOrEqual(2000);
505
+ });
506
+ });
507
+ it("handles text just slightly over 2000 chars", () => {
508
+ const text = "a".repeat(2001);
509
+ const result = manager.splitResponse(text);
510
+ expect(result.length).toBe(2);
511
+ expect(result[0].length).toBe(2000);
512
+ expect(result[1].length).toBe(1);
513
+ });
514
+ });
515
+ describe("formatErrorMessage", () => {
516
+ it("formats error with message and guidance", () => {
517
+ const error = new Error("Something went wrong");
518
+ const result = manager.formatErrorMessage(error);
519
+ expect(result).toContain("❌ **Error**:");
520
+ expect(result).toContain("Something went wrong");
521
+ expect(result).toContain("/reset");
522
+ expect(result).toContain("Please try again");
523
+ });
524
+ it("handles errors with special characters", () => {
525
+ const error = new Error("Error with `code` and *markdown*");
526
+ const result = manager.formatErrorMessage(error);
527
+ expect(result).toContain("Error with `code` and *markdown*");
528
+ });
529
+ });
530
+ describe("sendResponse", () => {
531
+ it("sends single message for short content", async () => {
532
+ const replyMock = vi.fn().mockResolvedValue(undefined);
533
+ await manager.sendResponse(replyMock, "Short message");
534
+ expect(replyMock).toHaveBeenCalledTimes(1);
535
+ expect(replyMock).toHaveBeenCalledWith("Short message");
536
+ });
537
+ it("sends multiple messages for long content", async () => {
538
+ const replyMock = vi.fn().mockResolvedValue(undefined);
539
+ const longText = "word ".repeat(500); // About 2500 chars
540
+ await manager.sendResponse(replyMock, longText);
541
+ expect(replyMock).toHaveBeenCalledTimes(2);
542
+ });
543
+ it("sends messages in order", async () => {
544
+ const calls = [];
545
+ const replyMock = vi.fn().mockImplementation(async (content) => {
546
+ calls.push(content);
547
+ });
548
+ const text = "First part.\n" + "x".repeat(2000) + "\nLast part.";
549
+ await manager.sendResponse(replyMock, text);
550
+ // Verify order by checking first call starts with "First"
551
+ expect(calls[0]).toMatch(/^First/);
552
+ });
553
+ });
554
+ });
555
+ describe("DiscordManager message handling", () => {
556
+ let manager;
557
+ let mockContext;
558
+ let triggerMock;
559
+ let emitterWithTrigger;
560
+ beforeEach(() => {
561
+ vi.clearAllMocks();
562
+ // Create a mock FleetManager (emitter) with trigger method
563
+ triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123" });
564
+ emitterWithTrigger = Object.assign(new EventEmitter(), {
565
+ trigger: triggerMock,
566
+ });
567
+ const config = {
568
+ fleet: { name: "test-fleet" },
569
+ agents: [
570
+ createDiscordAgent("test-agent", {
571
+ bot_token_env: "TEST_BOT_TOKEN",
572
+ session_expiry_hours: 24,
573
+ log_level: "standard",
574
+ guilds: [],
575
+ }),
576
+ ],
577
+ configPath: "/test/herdctl.yaml",
578
+ configDir: "/test",
579
+ };
580
+ mockContext = {
581
+ getConfig: () => config,
582
+ getStateDir: () => "/tmp/test-state",
583
+ getStateDirInfo: () => null,
584
+ getLogger: () => mockLogger,
585
+ getScheduler: () => null,
586
+ getStatus: () => "running",
587
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
588
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
589
+ getStoppedAt: () => null,
590
+ getLastError: () => null,
591
+ getCheckInterval: () => 1000,
592
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
593
+ getEmitter: () => emitterWithTrigger,
594
+ };
595
+ manager = new DiscordManager(mockContext);
596
+ });
597
+ describe("start with mock connector", () => {
598
+ it("subscribes to connector events when starting", async () => {
599
+ // Create a mock connector that supports event handling
600
+ const mockConnector = new EventEmitter();
601
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
602
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
603
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
604
+ mockConnector.getState = vi.fn().mockReturnValue({
605
+ status: "connected",
606
+ connectedAt: "2024-01-01T00:00:00.000Z",
607
+ disconnectedAt: null,
608
+ reconnectAttempts: 0,
609
+ lastError: null,
610
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
611
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
612
+ messageStats: { received: 0, sent: 0, ignored: 0 },
613
+ });
614
+ mockConnector.agentName = "test-agent";
615
+ // Access private connectors map to inject mock
616
+ // @ts-expect-error - accessing private property for testing
617
+ manager.connectors.set("test-agent", mockConnector);
618
+ // @ts-expect-error - accessing private property for testing
619
+ manager.initialized = true;
620
+ await manager.start();
621
+ expect(mockConnector.connect).toHaveBeenCalled();
622
+ // Verify event listeners were attached
623
+ expect(mockConnector.listenerCount("message")).toBeGreaterThan(0);
624
+ expect(mockConnector.listenerCount("error")).toBeGreaterThan(0);
625
+ });
626
+ it("handles message events from connector", async () => {
627
+ // Create a mock connector
628
+ const mockConnector = new EventEmitter();
629
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
630
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
631
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
632
+ mockConnector.getState = vi.fn().mockReturnValue({
633
+ status: "connected",
634
+ connectedAt: "2024-01-01T00:00:00.000Z",
635
+ disconnectedAt: null,
636
+ reconnectAttempts: 0,
637
+ lastError: null,
638
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
639
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
640
+ messageStats: { received: 0, sent: 0, ignored: 0 },
641
+ });
642
+ mockConnector.agentName = "test-agent";
643
+ // @ts-expect-error - accessing private property for testing
644
+ manager.connectors.set("test-agent", mockConnector);
645
+ // @ts-expect-error - accessing private property for testing
646
+ manager.initialized = true;
647
+ await manager.start();
648
+ // Create a mock message event
649
+ const replyMock = vi.fn().mockResolvedValue(undefined);
650
+ const messageEvent = {
651
+ agentName: "test-agent",
652
+ prompt: "Hello bot!",
653
+ context: {
654
+ messages: [],
655
+ wasMentioned: true,
656
+ prompt: "Hello bot!",
657
+ },
658
+ metadata: {
659
+ guildId: "guild1",
660
+ channelId: "channel1",
661
+ messageId: "msg1",
662
+ userId: "user1",
663
+ username: "TestUser",
664
+ wasMentioned: true,
665
+ mode: "mention",
666
+ },
667
+ reply: replyMock,
668
+ startTyping: () => () => { },
669
+ };
670
+ // Emit the message event
671
+ mockConnector.emit("message", messageEvent);
672
+ // Wait for async processing
673
+ await new Promise((resolve) => setTimeout(resolve, 50));
674
+ // Should have called trigger
675
+ expect(triggerMock).toHaveBeenCalledWith("test-agent", undefined, expect.objectContaining({
676
+ prompt: "Hello bot!",
677
+ }));
678
+ });
679
+ it("collects and sends streaming response with onMessage callback", async () => {
680
+ // Create trigger mock that invokes onMessage callback with streaming content
681
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
682
+ // Simulate streaming messages from the agent
683
+ if (options?.onMessage) {
684
+ options.onMessage({ type: "assistant", content: "Hello! " });
685
+ options.onMessage({ type: "assistant", content: "How can I help you today?" });
686
+ // Non-assistant message should be ignored
687
+ options.onMessage({ type: "system", content: "System message" });
688
+ }
689
+ return { jobId: "streaming-job-123" };
690
+ });
691
+ const streamingEmitter = Object.assign(new EventEmitter(), {
692
+ trigger: customTriggerMock,
693
+ });
694
+ const streamingConfig = {
695
+ fleet: { name: "test-fleet" },
696
+ agents: [
697
+ createDiscordAgent("streaming-agent", {
698
+ bot_token_env: "TEST_BOT_TOKEN",
699
+ session_expiry_hours: 24,
700
+ log_level: "standard",
701
+ guilds: [],
702
+ }),
703
+ ],
704
+ configPath: "/test/herdctl.yaml",
705
+ configDir: "/test",
706
+ };
707
+ const streamingContext = {
708
+ getConfig: () => streamingConfig,
709
+ getStateDir: () => "/tmp/test-state",
710
+ getStateDirInfo: () => null,
711
+ getLogger: () => mockLogger,
712
+ getScheduler: () => null,
713
+ getStatus: () => "running",
714
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
715
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
716
+ getStoppedAt: () => null,
717
+ getLastError: () => null,
718
+ getCheckInterval: () => 1000,
719
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
720
+ getEmitter: () => streamingEmitter,
721
+ };
722
+ const streamingManager = new DiscordManager(streamingContext);
723
+ const mockConnector = new EventEmitter();
724
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
725
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
726
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
727
+ mockConnector.getState = vi.fn().mockReturnValue({
728
+ status: "connected",
729
+ connectedAt: "2024-01-01T00:00:00.000Z",
730
+ disconnectedAt: null,
731
+ reconnectAttempts: 0,
732
+ lastError: null,
733
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
734
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
735
+ messageStats: { received: 0, sent: 0, ignored: 0 },
736
+ });
737
+ mockConnector.agentName = "streaming-agent";
738
+ mockConnector.sessionManager = {
739
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
740
+ getSession: vi.fn().mockResolvedValue({ sessionId: "s1", lastMessageAt: new Date().toISOString() }),
741
+ setSession: vi.fn().mockResolvedValue(undefined),
742
+ touchSession: vi.fn().mockResolvedValue(undefined),
743
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
744
+ };
745
+ // @ts-expect-error - accessing private property for testing
746
+ streamingManager.connectors.set("streaming-agent", mockConnector);
747
+ // @ts-expect-error - accessing private property for testing
748
+ streamingManager.initialized = true;
749
+ await streamingManager.start();
750
+ // Create a mock message event
751
+ const replyMock = vi.fn().mockResolvedValue(undefined);
752
+ const messageEvent = {
753
+ agentName: "streaming-agent",
754
+ prompt: "Hello bot!",
755
+ context: {
756
+ messages: [],
757
+ wasMentioned: true,
758
+ prompt: "Hello bot!",
759
+ },
760
+ metadata: {
761
+ guildId: "guild1",
762
+ channelId: "channel1",
763
+ messageId: "msg1",
764
+ userId: "user1",
765
+ username: "TestUser",
766
+ wasMentioned: true,
767
+ mode: "mention",
768
+ },
769
+ reply: replyMock,
770
+ startTyping: () => () => { },
771
+ };
772
+ // Emit the message event
773
+ mockConnector.emit("message", messageEvent);
774
+ // Wait for async processing
775
+ await new Promise((resolve) => setTimeout(resolve, 50));
776
+ // Should have collected the streaming messages and sent them
777
+ expect(replyMock).toHaveBeenCalledWith("Hello! How can I help you today?");
778
+ });
779
+ it("sends long streaming response with splitResponse", async () => {
780
+ // Create trigger mock that produces a long response
781
+ const longResponse = "This is a very long response. ".repeat(100); // About 3100 chars
782
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
783
+ if (options?.onMessage) {
784
+ options.onMessage({ type: "assistant", content: longResponse });
785
+ }
786
+ return { jobId: "long-job-123" };
787
+ });
788
+ const streamingEmitter = Object.assign(new EventEmitter(), {
789
+ trigger: customTriggerMock,
790
+ });
791
+ const streamingConfig = {
792
+ fleet: { name: "test-fleet" },
793
+ agents: [
794
+ createDiscordAgent("long-agent", {
795
+ bot_token_env: "TEST_BOT_TOKEN",
796
+ session_expiry_hours: 24,
797
+ log_level: "standard",
798
+ guilds: [],
799
+ }),
800
+ ],
801
+ configPath: "/test/herdctl.yaml",
802
+ configDir: "/test",
803
+ };
804
+ const streamingContext = {
805
+ getConfig: () => streamingConfig,
806
+ getStateDir: () => "/tmp/test-state",
807
+ getStateDirInfo: () => null,
808
+ getLogger: () => mockLogger,
809
+ getScheduler: () => null,
810
+ getStatus: () => "running",
811
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
812
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
813
+ getStoppedAt: () => null,
814
+ getLastError: () => null,
815
+ getCheckInterval: () => 1000,
816
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
817
+ getEmitter: () => streamingEmitter,
818
+ };
819
+ const streamingManager = new DiscordManager(streamingContext);
820
+ const mockConnector = new EventEmitter();
821
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
822
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
823
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
824
+ mockConnector.getState = vi.fn().mockReturnValue({
825
+ status: "connected",
826
+ connectedAt: "2024-01-01T00:00:00.000Z",
827
+ disconnectedAt: null,
828
+ reconnectAttempts: 0,
829
+ lastError: null,
830
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
831
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
832
+ messageStats: { received: 0, sent: 0, ignored: 0 },
833
+ });
834
+ mockConnector.agentName = "long-agent";
835
+ mockConnector.sessionManager = {
836
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
837
+ getSession: vi.fn().mockResolvedValue({ sessionId: "s1", lastMessageAt: new Date().toISOString() }),
838
+ setSession: vi.fn().mockResolvedValue(undefined),
839
+ touchSession: vi.fn().mockResolvedValue(undefined),
840
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
841
+ };
842
+ // @ts-expect-error - accessing private property for testing
843
+ streamingManager.connectors.set("long-agent", mockConnector);
844
+ // @ts-expect-error - accessing private property for testing
845
+ streamingManager.initialized = true;
846
+ await streamingManager.start();
847
+ // Create a mock message event
848
+ const replyMock = vi.fn().mockResolvedValue(undefined);
849
+ const messageEvent = {
850
+ agentName: "long-agent",
851
+ prompt: "Hello bot!",
852
+ context: {
853
+ messages: [],
854
+ wasMentioned: true,
855
+ prompt: "Hello bot!",
856
+ },
857
+ metadata: {
858
+ guildId: "guild1",
859
+ channelId: "channel1",
860
+ messageId: "msg1",
861
+ userId: "user1",
862
+ username: "TestUser",
863
+ wasMentioned: true,
864
+ mode: "mention",
865
+ },
866
+ reply: replyMock,
867
+ startTyping: () => () => { },
868
+ };
869
+ // Emit the message event
870
+ mockConnector.emit("message", messageEvent);
871
+ // Wait for async processing
872
+ await new Promise((resolve) => setTimeout(resolve, 50));
873
+ // Should have sent multiple messages (split response)
874
+ expect(replyMock).toHaveBeenCalledTimes(2);
875
+ });
876
+ it("handles message handler rejection via catch handler", async () => {
877
+ // This tests the .catch(error => this.handleError()) path in start()
878
+ // when handleMessage throws an error that propagates to the catch handler
879
+ // Create a config with no agents to trigger the "agent not found" error path
880
+ const emptyConfig = {
881
+ fleet: { name: "test-fleet" },
882
+ agents: [], // No agents!
883
+ configPath: "/test/herdctl.yaml",
884
+ configDir: "/test",
885
+ };
886
+ const errorEmitter = new EventEmitter();
887
+ const errorContext = {
888
+ getConfig: () => emptyConfig,
889
+ getStateDir: () => "/tmp/test-state",
890
+ getStateDirInfo: () => null,
891
+ getLogger: () => mockLogger,
892
+ getScheduler: () => null,
893
+ getStatus: () => "running",
894
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
895
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
896
+ getStoppedAt: () => null,
897
+ getLastError: () => null,
898
+ getCheckInterval: () => 1000,
899
+ emit: (event, ...args) => errorEmitter.emit(event, ...args),
900
+ getEmitter: () => errorEmitter,
901
+ };
902
+ const errorManager = new DiscordManager(errorContext);
903
+ const mockConnector = new EventEmitter();
904
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
905
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
906
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
907
+ mockConnector.getState = vi.fn().mockReturnValue({
908
+ status: "connected",
909
+ connectedAt: "2024-01-01T00:00:00.000Z",
910
+ disconnectedAt: null,
911
+ reconnectAttempts: 0,
912
+ lastError: null,
913
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
914
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
915
+ messageStats: { received: 0, sent: 0, ignored: 0 },
916
+ });
917
+ mockConnector.agentName = "missing-agent";
918
+ mockConnector.sessionManager = {
919
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
920
+ };
921
+ // @ts-expect-error - accessing private property for testing
922
+ errorManager.connectors.set("missing-agent", mockConnector);
923
+ // @ts-expect-error - accessing private property for testing
924
+ errorManager.initialized = true;
925
+ await errorManager.start();
926
+ // Create a message event with a reply that throws
927
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply threw"));
928
+ const messageEvent = {
929
+ agentName: "missing-agent",
930
+ prompt: "Hello!",
931
+ context: {
932
+ messages: [],
933
+ wasMentioned: true,
934
+ prompt: "Hello!",
935
+ },
936
+ metadata: {
937
+ guildId: "guild1",
938
+ channelId: "channel1",
939
+ messageId: "msg1",
940
+ userId: "user1",
941
+ username: "TestUser",
942
+ wasMentioned: true,
943
+ mode: "mention",
944
+ },
945
+ reply: replyMock,
946
+ startTyping: () => () => { },
947
+ };
948
+ // Emit the message event - this will trigger handleMessage which will fail
949
+ // because agent is not in config, and then try to reply, and that also fails
950
+ mockConnector.emit("message", messageEvent);
951
+ // Wait for async processing
952
+ await new Promise((resolve) => setTimeout(resolve, 50));
953
+ // The catch handler should have caught the error and called handleError
954
+ // which logs the error via discord:error event
955
+ expect(mockLogger.error).toHaveBeenCalled();
956
+ });
957
+ it("handles error events from connector", async () => {
958
+ // Create a mock connector
959
+ const mockConnector = new EventEmitter();
960
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
961
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
962
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
963
+ mockConnector.getState = vi.fn().mockReturnValue({
964
+ status: "connected",
965
+ connectedAt: "2024-01-01T00:00:00.000Z",
966
+ disconnectedAt: null,
967
+ reconnectAttempts: 0,
968
+ lastError: null,
969
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
970
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
971
+ messageStats: { received: 0, sent: 0, ignored: 0 },
972
+ });
973
+ mockConnector.agentName = "test-agent";
974
+ // @ts-expect-error - accessing private property for testing
975
+ manager.connectors.set("test-agent", mockConnector);
976
+ // @ts-expect-error - accessing private property for testing
977
+ manager.initialized = true;
978
+ await manager.start();
979
+ // Emit an error event
980
+ const errorEvent = {
981
+ agentName: "test-agent",
982
+ error: new Error("Test error"),
983
+ };
984
+ mockConnector.emit("error", errorEvent);
985
+ // Wait for async processing
986
+ await new Promise((resolve) => setTimeout(resolve, 10));
987
+ // Should have logged the error
988
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord connector error"));
989
+ });
990
+ it("sends formatted error reply when trigger fails", async () => {
991
+ // Create a mock connector
992
+ const mockConnector = new EventEmitter();
993
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
994
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
995
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
996
+ mockConnector.getState = vi.fn().mockReturnValue({
997
+ status: "connected",
998
+ connectedAt: "2024-01-01T00:00:00.000Z",
999
+ disconnectedAt: null,
1000
+ reconnectAttempts: 0,
1001
+ lastError: null,
1002
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1003
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1004
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1005
+ });
1006
+ mockConnector.agentName = "test-agent";
1007
+ // Make trigger fail
1008
+ triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
1009
+ // @ts-expect-error - accessing private property for testing
1010
+ manager.connectors.set("test-agent", mockConnector);
1011
+ // @ts-expect-error - accessing private property for testing
1012
+ manager.initialized = true;
1013
+ await manager.start();
1014
+ // Create a mock message event
1015
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1016
+ const messageEvent = {
1017
+ agentName: "test-agent",
1018
+ prompt: "Hello bot!",
1019
+ context: {
1020
+ messages: [],
1021
+ wasMentioned: true,
1022
+ prompt: "Hello bot!",
1023
+ },
1024
+ metadata: {
1025
+ guildId: "guild1",
1026
+ channelId: "channel1",
1027
+ messageId: "msg1",
1028
+ userId: "user1",
1029
+ username: "TestUser",
1030
+ wasMentioned: true,
1031
+ mode: "mention",
1032
+ },
1033
+ reply: replyMock,
1034
+ startTyping: () => () => { },
1035
+ };
1036
+ // Emit the message event
1037
+ mockConnector.emit("message", messageEvent);
1038
+ // Wait for async processing
1039
+ await new Promise((resolve) => setTimeout(resolve, 50));
1040
+ // Should have sent a formatted error reply
1041
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("❌ **Error**:"));
1042
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Agent execution failed"));
1043
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("/reset"));
1044
+ });
1045
+ it("handles error reply failure when trigger fails", async () => {
1046
+ // Create a mock connector
1047
+ const mockConnector = new EventEmitter();
1048
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1049
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1050
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1051
+ mockConnector.getState = vi.fn().mockReturnValue({
1052
+ status: "connected",
1053
+ connectedAt: "2024-01-01T00:00:00.000Z",
1054
+ disconnectedAt: null,
1055
+ reconnectAttempts: 0,
1056
+ lastError: null,
1057
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1058
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1059
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1060
+ });
1061
+ mockConnector.agentName = "test-agent";
1062
+ // Make trigger fail
1063
+ triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
1064
+ // @ts-expect-error - accessing private property for testing
1065
+ manager.connectors.set("test-agent", mockConnector);
1066
+ // @ts-expect-error - accessing private property for testing
1067
+ manager.initialized = true;
1068
+ await manager.start();
1069
+ // Create a mock message event with reply that also fails
1070
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply also failed"));
1071
+ const messageEvent = {
1072
+ agentName: "test-agent",
1073
+ prompt: "Hello bot!",
1074
+ context: {
1075
+ messages: [],
1076
+ wasMentioned: true,
1077
+ prompt: "Hello bot!",
1078
+ },
1079
+ metadata: {
1080
+ guildId: "guild1",
1081
+ channelId: "channel1",
1082
+ messageId: "msg1",
1083
+ userId: "user1",
1084
+ username: "TestUser",
1085
+ wasMentioned: true,
1086
+ mode: "mention",
1087
+ },
1088
+ reply: replyMock,
1089
+ startTyping: () => () => { },
1090
+ };
1091
+ // Emit the message event
1092
+ mockConnector.emit("message", messageEvent);
1093
+ // Wait for async processing
1094
+ await new Promise((resolve) => setTimeout(resolve, 50));
1095
+ // Should have logged both errors
1096
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord message handling failed"));
1097
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
1098
+ });
1099
+ it("sends error reply when agent not found", async () => {
1100
+ // Create a mock connector
1101
+ const mockConnector = new EventEmitter();
1102
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1103
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1104
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1105
+ mockConnector.getState = vi.fn().mockReturnValue({
1106
+ status: "connected",
1107
+ connectedAt: "2024-01-01T00:00:00.000Z",
1108
+ disconnectedAt: null,
1109
+ reconnectAttempts: 0,
1110
+ lastError: null,
1111
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1112
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1113
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1114
+ });
1115
+ mockConnector.agentName = "unknown-agent";
1116
+ // @ts-expect-error - accessing private property for testing
1117
+ manager.connectors.set("unknown-agent", mockConnector);
1118
+ // @ts-expect-error - accessing private property for testing
1119
+ manager.initialized = true;
1120
+ await manager.start();
1121
+ // Create a mock message event for an agent not in config
1122
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1123
+ const messageEvent = {
1124
+ agentName: "unknown-agent",
1125
+ prompt: "Hello bot!",
1126
+ context: {
1127
+ messages: [],
1128
+ wasMentioned: true,
1129
+ prompt: "Hello bot!",
1130
+ },
1131
+ metadata: {
1132
+ guildId: "guild1",
1133
+ channelId: "channel1",
1134
+ messageId: "msg1",
1135
+ userId: "user1",
1136
+ username: "TestUser",
1137
+ wasMentioned: true,
1138
+ mode: "mention",
1139
+ },
1140
+ reply: replyMock,
1141
+ startTyping: () => () => { },
1142
+ };
1143
+ // Emit the message event
1144
+ mockConnector.emit("message", messageEvent);
1145
+ // Wait for async processing
1146
+ await new Promise((resolve) => setTimeout(resolve, 50));
1147
+ // Should have sent an error reply
1148
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("not properly configured"));
1149
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
1150
+ });
1151
+ });
1152
+ describe("extractMessageContent", () => {
1153
+ it("extracts direct string content", () => {
1154
+ // @ts-expect-error - accessing private method for testing
1155
+ const result = manager.extractMessageContent({
1156
+ type: "assistant",
1157
+ content: "Direct content",
1158
+ });
1159
+ expect(result).toBe("Direct content");
1160
+ });
1161
+ it("extracts nested message content", () => {
1162
+ // @ts-expect-error - accessing private method for testing
1163
+ const result = manager.extractMessageContent({
1164
+ type: "assistant",
1165
+ message: { content: "Nested content" },
1166
+ });
1167
+ expect(result).toBe("Nested content");
1168
+ });
1169
+ it("extracts text from content blocks", () => {
1170
+ // @ts-expect-error - accessing private method for testing
1171
+ const result = manager.extractMessageContent({
1172
+ type: "assistant",
1173
+ message: {
1174
+ content: [
1175
+ { type: "text", text: "First part" },
1176
+ { type: "text", text: " Second part" },
1177
+ ],
1178
+ },
1179
+ });
1180
+ expect(result).toBe("First part Second part");
1181
+ });
1182
+ it("returns undefined for empty content", () => {
1183
+ // @ts-expect-error - accessing private method for testing
1184
+ const result = manager.extractMessageContent({
1185
+ type: "assistant",
1186
+ });
1187
+ expect(result).toBeUndefined();
1188
+ });
1189
+ it("returns undefined for non-text content blocks", () => {
1190
+ // @ts-expect-error - accessing private method for testing
1191
+ const result = manager.extractMessageContent({
1192
+ type: "assistant",
1193
+ message: {
1194
+ content: [
1195
+ { type: "tool_use", name: "some_tool" },
1196
+ ],
1197
+ },
1198
+ });
1199
+ expect(result).toBeUndefined();
1200
+ });
1201
+ it("returns undefined for empty string content", () => {
1202
+ // @ts-expect-error - accessing private method for testing
1203
+ const result = manager.extractMessageContent({
1204
+ type: "assistant",
1205
+ content: "",
1206
+ });
1207
+ expect(result).toBeUndefined();
1208
+ });
1209
+ it("handles mixed content blocks (text and non-text)", () => {
1210
+ // @ts-expect-error - accessing private method for testing
1211
+ const result = manager.extractMessageContent({
1212
+ type: "assistant",
1213
+ message: {
1214
+ content: [
1215
+ { type: "tool_use", name: "some_tool" },
1216
+ { type: "text", text: "After tool" },
1217
+ ],
1218
+ },
1219
+ });
1220
+ expect(result).toBe("After tool");
1221
+ });
1222
+ it("handles empty content blocks array", () => {
1223
+ // @ts-expect-error - accessing private method for testing
1224
+ const result = manager.extractMessageContent({
1225
+ type: "assistant",
1226
+ message: {
1227
+ content: [],
1228
+ },
1229
+ });
1230
+ expect(result).toBeUndefined();
1231
+ });
1232
+ it("returns undefined for content that is not a string or array", () => {
1233
+ // @ts-expect-error - accessing private method for testing
1234
+ const result = manager.extractMessageContent({
1235
+ type: "assistant",
1236
+ message: {
1237
+ content: { someObject: "value" }, // Not string or array
1238
+ },
1239
+ });
1240
+ expect(result).toBeUndefined();
1241
+ });
1242
+ it("handles content blocks with missing text property", () => {
1243
+ // @ts-expect-error - accessing private method for testing
1244
+ const result = manager.extractMessageContent({
1245
+ type: "assistant",
1246
+ message: {
1247
+ content: [
1248
+ { type: "text" }, // Missing text property
1249
+ ],
1250
+ },
1251
+ });
1252
+ expect(result).toBeUndefined();
1253
+ });
1254
+ it("handles content block with non-string text", () => {
1255
+ // @ts-expect-error - accessing private method for testing
1256
+ const result = manager.extractMessageContent({
1257
+ type: "assistant",
1258
+ message: {
1259
+ content: [
1260
+ { type: "text", text: 123 }, // Non-string text
1261
+ ],
1262
+ },
1263
+ });
1264
+ expect(result).toBeUndefined();
1265
+ });
1266
+ });
1267
+ });
1268
+ describe("DiscordManager session integration", () => {
1269
+ let manager;
1270
+ let mockContext;
1271
+ let triggerMock;
1272
+ let emitterWithTrigger;
1273
+ let mockSessionManager;
1274
+ beforeEach(() => {
1275
+ vi.clearAllMocks();
1276
+ // Create mock session manager
1277
+ mockSessionManager = {
1278
+ agentName: "test-agent",
1279
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "session-123", isNew: false }),
1280
+ getSession: vi.fn().mockResolvedValue({ sessionId: "session-123", lastMessageAt: new Date().toISOString() }),
1281
+ setSession: vi.fn().mockResolvedValue(undefined),
1282
+ touchSession: vi.fn().mockResolvedValue(undefined),
1283
+ clearSession: vi.fn().mockResolvedValue(true),
1284
+ cleanupExpiredSessions: vi.fn().mockResolvedValue(0),
1285
+ getActiveSessionCount: vi.fn().mockResolvedValue(5),
1286
+ };
1287
+ // Create a mock FleetManager (emitter) with trigger method
1288
+ triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123", success: true, sessionId: "sdk-session-456" });
1289
+ emitterWithTrigger = Object.assign(new EventEmitter(), {
1290
+ trigger: triggerMock,
1291
+ });
1292
+ const config = {
1293
+ fleet: { name: "test-fleet" },
1294
+ agents: [
1295
+ createDiscordAgent("test-agent", {
1296
+ bot_token_env: "TEST_BOT_TOKEN",
1297
+ session_expiry_hours: 24,
1298
+ log_level: "standard",
1299
+ guilds: [],
1300
+ }),
1301
+ ],
1302
+ configPath: "/test/herdctl.yaml",
1303
+ configDir: "/test",
1304
+ };
1305
+ mockContext = {
1306
+ getConfig: () => config,
1307
+ getStateDir: () => "/tmp/test-state",
1308
+ getStateDirInfo: () => null,
1309
+ getLogger: () => mockLogger,
1310
+ getScheduler: () => null,
1311
+ getStatus: () => "running",
1312
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1313
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1314
+ getStoppedAt: () => null,
1315
+ getLastError: () => null,
1316
+ getCheckInterval: () => 1000,
1317
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1318
+ getEmitter: () => emitterWithTrigger,
1319
+ };
1320
+ manager = new DiscordManager(mockContext);
1321
+ });
1322
+ it("calls getSession on message to check for existing session", async () => {
1323
+ // Create a mock connector with session manager
1324
+ const mockConnector = new EventEmitter();
1325
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1326
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1327
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1328
+ mockConnector.getState = vi.fn().mockReturnValue({
1329
+ status: "connected",
1330
+ connectedAt: "2024-01-01T00:00:00.000Z",
1331
+ disconnectedAt: null,
1332
+ reconnectAttempts: 0,
1333
+ lastError: null,
1334
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1335
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1336
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1337
+ });
1338
+ mockConnector.agentName = "test-agent";
1339
+ mockConnector.sessionManager = mockSessionManager;
1340
+ // @ts-expect-error - accessing private property for testing
1341
+ manager.connectors.set("test-agent", mockConnector);
1342
+ // @ts-expect-error - accessing private property for testing
1343
+ manager.initialized = true;
1344
+ await manager.start();
1345
+ // Create a mock message event
1346
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1347
+ const messageEvent = {
1348
+ agentName: "test-agent",
1349
+ prompt: "Hello bot!",
1350
+ context: {
1351
+ messages: [],
1352
+ wasMentioned: true,
1353
+ prompt: "Hello bot!",
1354
+ },
1355
+ metadata: {
1356
+ guildId: "guild1",
1357
+ channelId: "channel1",
1358
+ messageId: "msg1",
1359
+ userId: "user1",
1360
+ username: "TestUser",
1361
+ wasMentioned: true,
1362
+ mode: "mention",
1363
+ },
1364
+ reply: replyMock,
1365
+ startTyping: () => () => { },
1366
+ };
1367
+ // Emit the message event
1368
+ mockConnector.emit("message", messageEvent);
1369
+ // Wait for async processing
1370
+ await new Promise((resolve) => setTimeout(resolve, 50));
1371
+ // Should have called getSession to check for existing session
1372
+ expect(mockSessionManager.getSession).toHaveBeenCalledWith("channel1");
1373
+ });
1374
+ it("calls setSession after successful response with SDK session ID", async () => {
1375
+ // Create a mock connector with session manager
1376
+ const mockConnector = new EventEmitter();
1377
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1378
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1379
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1380
+ mockConnector.getState = vi.fn().mockReturnValue({
1381
+ status: "connected",
1382
+ connectedAt: "2024-01-01T00:00:00.000Z",
1383
+ disconnectedAt: null,
1384
+ reconnectAttempts: 0,
1385
+ lastError: null,
1386
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1387
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1388
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1389
+ });
1390
+ mockConnector.agentName = "test-agent";
1391
+ mockConnector.sessionManager = mockSessionManager;
1392
+ // @ts-expect-error - accessing private property for testing
1393
+ manager.connectors.set("test-agent", mockConnector);
1394
+ // @ts-expect-error - accessing private property for testing
1395
+ manager.initialized = true;
1396
+ await manager.start();
1397
+ // Create a mock message event
1398
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1399
+ const messageEvent = {
1400
+ agentName: "test-agent",
1401
+ prompt: "Hello bot!",
1402
+ context: {
1403
+ messages: [],
1404
+ wasMentioned: true,
1405
+ prompt: "Hello bot!",
1406
+ },
1407
+ metadata: {
1408
+ guildId: "guild1",
1409
+ channelId: "channel1",
1410
+ messageId: "msg1",
1411
+ userId: "user1",
1412
+ username: "TestUser",
1413
+ wasMentioned: true,
1414
+ mode: "mention",
1415
+ },
1416
+ reply: replyMock,
1417
+ startTyping: () => () => { },
1418
+ };
1419
+ // Emit the message event
1420
+ mockConnector.emit("message", messageEvent);
1421
+ // Wait for async processing
1422
+ await new Promise((resolve) => setTimeout(resolve, 50));
1423
+ // Should have called setSession with the SDK session ID from trigger result
1424
+ expect(mockSessionManager.setSession).toHaveBeenCalledWith("channel1", "sdk-session-456");
1425
+ });
1426
+ it("handles getSession errors gracefully", async () => {
1427
+ // Create a mock connector with session manager where getSession fails
1428
+ mockSessionManager.getSession.mockRejectedValue(new Error("Session error"));
1429
+ const mockConnector = new EventEmitter();
1430
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1431
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1432
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1433
+ mockConnector.getState = vi.fn().mockReturnValue({
1434
+ status: "connected",
1435
+ connectedAt: "2024-01-01T00:00:00.000Z",
1436
+ disconnectedAt: null,
1437
+ reconnectAttempts: 0,
1438
+ lastError: null,
1439
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1440
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1441
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1442
+ });
1443
+ mockConnector.agentName = "test-agent";
1444
+ mockConnector.sessionManager = mockSessionManager;
1445
+ // @ts-expect-error - accessing private property for testing
1446
+ manager.connectors.set("test-agent", mockConnector);
1447
+ // @ts-expect-error - accessing private property for testing
1448
+ manager.initialized = true;
1449
+ await manager.start();
1450
+ // Create a mock message event
1451
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1452
+ const messageEvent = {
1453
+ agentName: "test-agent",
1454
+ prompt: "Hello bot!",
1455
+ context: {
1456
+ messages: [],
1457
+ wasMentioned: true,
1458
+ prompt: "Hello bot!",
1459
+ },
1460
+ metadata: {
1461
+ guildId: "guild1",
1462
+ channelId: "channel1",
1463
+ messageId: "msg1",
1464
+ userId: "user1",
1465
+ username: "TestUser",
1466
+ wasMentioned: true,
1467
+ mode: "mention",
1468
+ },
1469
+ reply: replyMock,
1470
+ startTyping: () => () => { },
1471
+ };
1472
+ // Emit the message event
1473
+ mockConnector.emit("message", messageEvent);
1474
+ // Wait for async processing
1475
+ await new Promise((resolve) => setTimeout(resolve, 50));
1476
+ // Should have logged a warning but continued processing
1477
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get session"));
1478
+ // Should still have called trigger
1479
+ expect(triggerMock).toHaveBeenCalled();
1480
+ });
1481
+ it("handles setSession errors gracefully", async () => {
1482
+ // Create a mock connector with session manager where setSession fails
1483
+ mockSessionManager.setSession.mockRejectedValue(new Error("Session storage error"));
1484
+ const mockConnector = new EventEmitter();
1485
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1486
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1487
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1488
+ mockConnector.getState = vi.fn().mockReturnValue({
1489
+ status: "connected",
1490
+ connectedAt: "2024-01-01T00:00:00.000Z",
1491
+ disconnectedAt: null,
1492
+ reconnectAttempts: 0,
1493
+ lastError: null,
1494
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1495
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1496
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1497
+ });
1498
+ mockConnector.agentName = "test-agent";
1499
+ mockConnector.sessionManager = mockSessionManager;
1500
+ // @ts-expect-error - accessing private property for testing
1501
+ manager.connectors.set("test-agent", mockConnector);
1502
+ // @ts-expect-error - accessing private property for testing
1503
+ manager.initialized = true;
1504
+ await manager.start();
1505
+ // Create a mock message event
1506
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1507
+ const messageEvent = {
1508
+ agentName: "test-agent",
1509
+ prompt: "Hello bot!",
1510
+ context: {
1511
+ messages: [],
1512
+ wasMentioned: true,
1513
+ prompt: "Hello bot!",
1514
+ },
1515
+ metadata: {
1516
+ guildId: "guild1",
1517
+ channelId: "channel1",
1518
+ messageId: "msg1",
1519
+ userId: "user1",
1520
+ username: "TestUser",
1521
+ wasMentioned: true,
1522
+ mode: "mention",
1523
+ },
1524
+ reply: replyMock,
1525
+ startTyping: () => () => { },
1526
+ };
1527
+ // Emit the message event
1528
+ mockConnector.emit("message", messageEvent);
1529
+ // Wait for async processing
1530
+ await new Promise((resolve) => setTimeout(resolve, 50));
1531
+ // Should have logged a warning but continued
1532
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store session"));
1533
+ // Reply should still have been sent
1534
+ expect(replyMock).toHaveBeenCalled();
1535
+ });
1536
+ it("logs session count on stop", async () => {
1537
+ // Create a mock connector with session manager
1538
+ const mockConnector = new EventEmitter();
1539
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1540
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1541
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1542
+ mockConnector.getState = vi.fn().mockReturnValue({
1543
+ status: "connected",
1544
+ connectedAt: "2024-01-01T00:00:00.000Z",
1545
+ disconnectedAt: null,
1546
+ reconnectAttempts: 0,
1547
+ lastError: null,
1548
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1549
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1550
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1551
+ });
1552
+ mockConnector.agentName = "test-agent";
1553
+ mockConnector.sessionManager = mockSessionManager;
1554
+ // @ts-expect-error - accessing private property for testing
1555
+ manager.connectors.set("test-agent", mockConnector);
1556
+ // @ts-expect-error - accessing private property for testing
1557
+ manager.initialized = true;
1558
+ await manager.stop();
1559
+ // Should have queried session count
1560
+ expect(mockSessionManager.getActiveSessionCount).toHaveBeenCalled();
1561
+ // Should have logged about preserving sessions
1562
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Preserving 5 active session(s)"));
1563
+ });
1564
+ it("handles getActiveSessionCount errors on stop", async () => {
1565
+ // Create a mock connector with session manager that fails
1566
+ mockSessionManager.getActiveSessionCount.mockRejectedValue(new Error("Count error"));
1567
+ const mockConnector = new EventEmitter();
1568
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1569
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1570
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1571
+ mockConnector.getState = vi.fn().mockReturnValue({
1572
+ status: "connected",
1573
+ connectedAt: "2024-01-01T00:00:00.000Z",
1574
+ disconnectedAt: null,
1575
+ reconnectAttempts: 0,
1576
+ lastError: null,
1577
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1578
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1579
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1580
+ });
1581
+ mockConnector.agentName = "test-agent";
1582
+ mockConnector.sessionManager = mockSessionManager;
1583
+ // @ts-expect-error - accessing private property for testing
1584
+ manager.connectors.set("test-agent", mockConnector);
1585
+ // @ts-expect-error - accessing private property for testing
1586
+ manager.initialized = true;
1587
+ await manager.stop();
1588
+ // Should have warned about the error
1589
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get session count"));
1590
+ // Should still disconnect
1591
+ expect(mockConnector.disconnect).toHaveBeenCalled();
1592
+ });
1593
+ it("does not log session preservation when count is 0", async () => {
1594
+ // Create a mock connector with session manager returning 0 sessions
1595
+ mockSessionManager.getActiveSessionCount.mockResolvedValue(0);
1596
+ const mockConnector = new EventEmitter();
1597
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1598
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1599
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1600
+ mockConnector.getState = vi.fn().mockReturnValue({
1601
+ status: "connected",
1602
+ connectedAt: "2024-01-01T00:00:00.000Z",
1603
+ disconnectedAt: null,
1604
+ reconnectAttempts: 0,
1605
+ lastError: null,
1606
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1607
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1608
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1609
+ });
1610
+ mockConnector.agentName = "test-agent";
1611
+ mockConnector.sessionManager = mockSessionManager;
1612
+ // @ts-expect-error - accessing private property for testing
1613
+ manager.connectors.set("test-agent", mockConnector);
1614
+ // @ts-expect-error - accessing private property for testing
1615
+ manager.initialized = true;
1616
+ await manager.stop();
1617
+ // Should NOT have logged about preserving sessions (0 sessions)
1618
+ expect(mockLogger.info).not.toHaveBeenCalledWith(expect.stringContaining("Preserving"));
1619
+ });
1620
+ });
1621
+ describe("DiscordManager lifecycle", () => {
1622
+ beforeEach(() => {
1623
+ vi.clearAllMocks();
1624
+ });
1625
+ it("handles connect failure gracefully", async () => {
1626
+ const mockConnector = new EventEmitter();
1627
+ mockConnector.connect = vi.fn().mockRejectedValue(new Error("Connection failed"));
1628
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1629
+ mockConnector.isConnected = vi.fn().mockReturnValue(false);
1630
+ mockConnector.getState = vi.fn().mockReturnValue({
1631
+ status: "error",
1632
+ connectedAt: null,
1633
+ disconnectedAt: null,
1634
+ reconnectAttempts: 0,
1635
+ lastError: "Connection failed",
1636
+ botUser: null,
1637
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1638
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1639
+ });
1640
+ mockConnector.agentName = "test-agent";
1641
+ mockConnector.sessionManager = {
1642
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1643
+ };
1644
+ const ctx = createMockContext(null);
1645
+ const manager = new DiscordManager(ctx);
1646
+ // @ts-expect-error - accessing private property for testing
1647
+ manager.connectors.set("test-agent", mockConnector);
1648
+ // @ts-expect-error - accessing private property for testing
1649
+ manager.initialized = true;
1650
+ // Should not throw
1651
+ await manager.start();
1652
+ // Should have logged the error
1653
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to connect Discord"));
1654
+ });
1655
+ it("handles disconnect failure gracefully", async () => {
1656
+ const mockConnector = new EventEmitter();
1657
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1658
+ mockConnector.disconnect = vi.fn().mockRejectedValue(new Error("Disconnect failed"));
1659
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1660
+ mockConnector.getState = vi.fn().mockReturnValue({
1661
+ status: "connected",
1662
+ connectedAt: "2024-01-01T00:00:00.000Z",
1663
+ disconnectedAt: null,
1664
+ reconnectAttempts: 0,
1665
+ lastError: null,
1666
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1667
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1668
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1669
+ });
1670
+ mockConnector.agentName = "test-agent";
1671
+ mockConnector.sessionManager = {
1672
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1673
+ };
1674
+ const ctx = createMockContext(null);
1675
+ const manager = new DiscordManager(ctx);
1676
+ // @ts-expect-error - accessing private property for testing
1677
+ manager.connectors.set("test-agent", mockConnector);
1678
+ // @ts-expect-error - accessing private property for testing
1679
+ manager.initialized = true;
1680
+ // Should not throw
1681
+ await manager.stop();
1682
+ // Should have logged the error
1683
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Error disconnecting Discord"));
1684
+ });
1685
+ it("reports correct connected count", async () => {
1686
+ const connectedConnector = new EventEmitter();
1687
+ connectedConnector.connect = vi.fn().mockResolvedValue(undefined);
1688
+ connectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1689
+ connectedConnector.isConnected = vi.fn().mockReturnValue(true);
1690
+ connectedConnector.getState = vi.fn().mockReturnValue({
1691
+ status: "connected",
1692
+ connectedAt: "2024-01-01T00:00:00.000Z",
1693
+ disconnectedAt: null,
1694
+ reconnectAttempts: 0,
1695
+ lastError: null,
1696
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1697
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1698
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1699
+ });
1700
+ connectedConnector.agentName = "connected-agent";
1701
+ connectedConnector.sessionManager = {
1702
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1703
+ };
1704
+ const disconnectedConnector = new EventEmitter();
1705
+ disconnectedConnector.connect = vi.fn().mockRejectedValue(new Error("Failed"));
1706
+ disconnectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1707
+ disconnectedConnector.isConnected = vi.fn().mockReturnValue(false);
1708
+ disconnectedConnector.getState = vi.fn().mockReturnValue({
1709
+ status: "error",
1710
+ connectedAt: null,
1711
+ disconnectedAt: null,
1712
+ reconnectAttempts: 0,
1713
+ lastError: "Failed",
1714
+ botUser: null,
1715
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1716
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1717
+ });
1718
+ disconnectedConnector.agentName = "disconnected-agent";
1719
+ disconnectedConnector.sessionManager = {
1720
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1721
+ };
1722
+ const ctx = createMockContext(null);
1723
+ const manager = new DiscordManager(ctx);
1724
+ // @ts-expect-error - accessing private property for testing
1725
+ manager.connectors.set("connected-agent", connectedConnector);
1726
+ // @ts-expect-error - accessing private property for testing
1727
+ manager.connectors.set("disconnected-agent", disconnectedConnector);
1728
+ // @ts-expect-error - accessing private property for testing
1729
+ manager.initialized = true;
1730
+ await manager.start();
1731
+ // Should report correct counts
1732
+ expect(manager.getConnectedCount()).toBe(1);
1733
+ expect(manager.getConnectorNames()).toEqual(["connected-agent", "disconnected-agent"]);
1734
+ });
1735
+ it("emits discord:message:handled event on successful message handling", async () => {
1736
+ const eventEmitter = new EventEmitter();
1737
+ const emittedEvents = [];
1738
+ // Track emitted events
1739
+ eventEmitter.on("discord:message:handled", (data) => {
1740
+ emittedEvents.push({ event: "discord:message:handled", data });
1741
+ });
1742
+ const triggerMock = vi.fn().mockResolvedValue({ jobId: "job-456" });
1743
+ const emitterWithTrigger = Object.assign(eventEmitter, {
1744
+ trigger: triggerMock,
1745
+ });
1746
+ const config = {
1747
+ fleet: { name: "test-fleet" },
1748
+ agents: [
1749
+ createDiscordAgent("test-agent", {
1750
+ bot_token_env: "TEST_BOT_TOKEN",
1751
+ session_expiry_hours: 24,
1752
+ log_level: "standard",
1753
+ guilds: [],
1754
+ }),
1755
+ ],
1756
+ configPath: "/test/herdctl.yaml",
1757
+ configDir: "/test",
1758
+ };
1759
+ const mockContext = {
1760
+ getConfig: () => config,
1761
+ getStateDir: () => "/tmp/test-state",
1762
+ getStateDirInfo: () => null,
1763
+ getLogger: () => mockLogger,
1764
+ getScheduler: () => null,
1765
+ getStatus: () => "running",
1766
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1767
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1768
+ getStoppedAt: () => null,
1769
+ getLastError: () => null,
1770
+ getCheckInterval: () => 1000,
1771
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1772
+ getEmitter: () => emitterWithTrigger,
1773
+ };
1774
+ const manager = new DiscordManager(mockContext);
1775
+ const mockConnector = new EventEmitter();
1776
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1777
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1778
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1779
+ mockConnector.getState = vi.fn().mockReturnValue({
1780
+ status: "connected",
1781
+ connectedAt: "2024-01-01T00:00:00.000Z",
1782
+ disconnectedAt: null,
1783
+ reconnectAttempts: 0,
1784
+ lastError: null,
1785
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1786
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1787
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1788
+ });
1789
+ mockConnector.agentName = "test-agent";
1790
+ mockConnector.sessionManager = {
1791
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
1792
+ touchSession: vi.fn().mockResolvedValue(undefined),
1793
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1794
+ };
1795
+ // @ts-expect-error - accessing private property for testing
1796
+ manager.connectors.set("test-agent", mockConnector);
1797
+ // @ts-expect-error - accessing private property for testing
1798
+ manager.initialized = true;
1799
+ await manager.start();
1800
+ // Create a mock message event
1801
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1802
+ const messageEvent = {
1803
+ agentName: "test-agent",
1804
+ prompt: "Hello bot!",
1805
+ context: {
1806
+ messages: [],
1807
+ wasMentioned: true,
1808
+ prompt: "Hello bot!",
1809
+ },
1810
+ metadata: {
1811
+ guildId: "guild1",
1812
+ channelId: "channel1",
1813
+ messageId: "msg1",
1814
+ userId: "user1",
1815
+ username: "TestUser",
1816
+ wasMentioned: true,
1817
+ mode: "mention",
1818
+ },
1819
+ reply: replyMock,
1820
+ startTyping: () => () => { },
1821
+ };
1822
+ // Emit the message event
1823
+ mockConnector.emit("message", messageEvent);
1824
+ // Wait for async processing
1825
+ await new Promise((resolve) => setTimeout(resolve, 50));
1826
+ // Should have emitted the handled event
1827
+ expect(emittedEvents.length).toBe(1);
1828
+ expect(emittedEvents[0].event).toBe("discord:message:handled");
1829
+ expect(emittedEvents[0].data).toMatchObject({
1830
+ agentName: "test-agent",
1831
+ channelId: "channel1",
1832
+ messageId: "msg1",
1833
+ jobId: "job-456",
1834
+ });
1835
+ });
1836
+ it("emits discord:message:error event on message handling failure", async () => {
1837
+ const eventEmitter = new EventEmitter();
1838
+ const emittedEvents = [];
1839
+ // Track emitted events
1840
+ eventEmitter.on("discord:message:error", (data) => {
1841
+ emittedEvents.push({ event: "discord:message:error", data });
1842
+ });
1843
+ const triggerMock = vi.fn().mockRejectedValue(new Error("Execution failed"));
1844
+ const emitterWithTrigger = Object.assign(eventEmitter, {
1845
+ trigger: triggerMock,
1846
+ });
1847
+ const config = {
1848
+ fleet: { name: "test-fleet" },
1849
+ agents: [
1850
+ createDiscordAgent("test-agent", {
1851
+ bot_token_env: "TEST_BOT_TOKEN",
1852
+ session_expiry_hours: 24,
1853
+ log_level: "standard",
1854
+ guilds: [],
1855
+ }),
1856
+ ],
1857
+ configPath: "/test/herdctl.yaml",
1858
+ configDir: "/test",
1859
+ };
1860
+ const mockContext = {
1861
+ getConfig: () => config,
1862
+ getStateDir: () => "/tmp/test-state",
1863
+ getStateDirInfo: () => null,
1864
+ getLogger: () => mockLogger,
1865
+ getScheduler: () => null,
1866
+ getStatus: () => "running",
1867
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1868
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1869
+ getStoppedAt: () => null,
1870
+ getLastError: () => null,
1871
+ getCheckInterval: () => 1000,
1872
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1873
+ getEmitter: () => emitterWithTrigger,
1874
+ };
1875
+ const manager = new DiscordManager(mockContext);
1876
+ const mockConnector = new EventEmitter();
1877
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1878
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1879
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1880
+ mockConnector.getState = vi.fn().mockReturnValue({
1881
+ status: "connected",
1882
+ connectedAt: "2024-01-01T00:00:00.000Z",
1883
+ disconnectedAt: null,
1884
+ reconnectAttempts: 0,
1885
+ lastError: null,
1886
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1887
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1888
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1889
+ });
1890
+ mockConnector.agentName = "test-agent";
1891
+ mockConnector.sessionManager = {
1892
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
1893
+ touchSession: vi.fn().mockResolvedValue(undefined),
1894
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1895
+ };
1896
+ // @ts-expect-error - accessing private property for testing
1897
+ manager.connectors.set("test-agent", mockConnector);
1898
+ // @ts-expect-error - accessing private property for testing
1899
+ manager.initialized = true;
1900
+ await manager.start();
1901
+ // Create a mock message event
1902
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1903
+ const messageEvent = {
1904
+ agentName: "test-agent",
1905
+ prompt: "Hello bot!",
1906
+ context: {
1907
+ messages: [],
1908
+ wasMentioned: true,
1909
+ prompt: "Hello bot!",
1910
+ },
1911
+ metadata: {
1912
+ guildId: "guild1",
1913
+ channelId: "channel1",
1914
+ messageId: "msg1",
1915
+ userId: "user1",
1916
+ username: "TestUser",
1917
+ wasMentioned: true,
1918
+ mode: "mention",
1919
+ },
1920
+ reply: replyMock,
1921
+ startTyping: () => () => { },
1922
+ };
1923
+ // Emit the message event
1924
+ mockConnector.emit("message", messageEvent);
1925
+ // Wait for async processing
1926
+ await new Promise((resolve) => setTimeout(resolve, 50));
1927
+ // Should have emitted the error event
1928
+ expect(emittedEvents.length).toBe(1);
1929
+ expect(emittedEvents[0].event).toBe("discord:message:error");
1930
+ expect(emittedEvents[0].data).toMatchObject({
1931
+ agentName: "test-agent",
1932
+ channelId: "channel1",
1933
+ messageId: "msg1",
1934
+ error: "Execution failed",
1935
+ });
1936
+ });
1937
+ it("emits discord:error event on connector error", async () => {
1938
+ const eventEmitter = new EventEmitter();
1939
+ const emittedEvents = [];
1940
+ // Track emitted events
1941
+ eventEmitter.on("discord:error", (data) => {
1942
+ emittedEvents.push({ event: "discord:error", data });
1943
+ });
1944
+ const mockContext = {
1945
+ getConfig: () => null,
1946
+ getStateDir: () => "/tmp/test-state",
1947
+ getStateDirInfo: () => null,
1948
+ getLogger: () => mockLogger,
1949
+ getScheduler: () => null,
1950
+ getStatus: () => "running",
1951
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1952
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1953
+ getStoppedAt: () => null,
1954
+ getLastError: () => null,
1955
+ getCheckInterval: () => 1000,
1956
+ emit: (event, ...args) => eventEmitter.emit(event, ...args),
1957
+ getEmitter: () => eventEmitter,
1958
+ };
1959
+ const manager = new DiscordManager(mockContext);
1960
+ const mockConnector = new EventEmitter();
1961
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1962
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1963
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1964
+ mockConnector.getState = vi.fn().mockReturnValue({
1965
+ status: "connected",
1966
+ connectedAt: "2024-01-01T00:00:00.000Z",
1967
+ disconnectedAt: null,
1968
+ reconnectAttempts: 0,
1969
+ lastError: null,
1970
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1971
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1972
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1973
+ });
1974
+ mockConnector.agentName = "test-agent";
1975
+ mockConnector.sessionManager = {
1976
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1977
+ };
1978
+ // @ts-expect-error - accessing private property for testing
1979
+ manager.connectors.set("test-agent", mockConnector);
1980
+ // @ts-expect-error - accessing private property for testing
1981
+ manager.initialized = true;
1982
+ await manager.start();
1983
+ // Emit error event from connector
1984
+ const errorEvent = {
1985
+ agentName: "test-agent",
1986
+ error: new Error("Connector error"),
1987
+ };
1988
+ mockConnector.emit("error", errorEvent);
1989
+ // Wait for async processing
1990
+ await new Promise((resolve) => setTimeout(resolve, 10));
1991
+ // Should have emitted the discord:error event
1992
+ expect(emittedEvents.length).toBe(1);
1993
+ expect(emittedEvents[0].event).toBe("discord:error");
1994
+ expect(emittedEvents[0].data).toMatchObject({
1995
+ agentName: "test-agent",
1996
+ error: "Connector error",
1997
+ });
1998
+ });
1999
+ it("handles reply failure when agent not found", async () => {
2000
+ const config = {
2001
+ fleet: { name: "test-fleet" },
2002
+ agents: [], // No agents!
2003
+ configPath: "/test/herdctl.yaml",
2004
+ configDir: "/test",
2005
+ };
2006
+ const mockContext = {
2007
+ getConfig: () => config,
2008
+ getStateDir: () => "/tmp/test-state",
2009
+ getStateDirInfo: () => null,
2010
+ getLogger: () => mockLogger,
2011
+ getScheduler: () => null,
2012
+ getStatus: () => "running",
2013
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2014
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2015
+ getStoppedAt: () => null,
2016
+ getLastError: () => null,
2017
+ getCheckInterval: () => 1000,
2018
+ emit: () => true,
2019
+ getEmitter: () => new EventEmitter(),
2020
+ };
2021
+ const manager = new DiscordManager(mockContext);
2022
+ const mockConnector = new EventEmitter();
2023
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2024
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2025
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2026
+ mockConnector.getState = vi.fn().mockReturnValue({
2027
+ status: "connected",
2028
+ connectedAt: "2024-01-01T00:00:00.000Z",
2029
+ disconnectedAt: null,
2030
+ reconnectAttempts: 0,
2031
+ lastError: null,
2032
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2033
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2034
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2035
+ });
2036
+ mockConnector.agentName = "unknown-agent";
2037
+ mockConnector.sessionManager = {
2038
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2039
+ };
2040
+ // @ts-expect-error - accessing private property for testing
2041
+ manager.connectors.set("unknown-agent", mockConnector);
2042
+ // @ts-expect-error - accessing private property for testing
2043
+ manager.initialized = true;
2044
+ await manager.start();
2045
+ // Reply that fails
2046
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply failed"));
2047
+ const messageEvent = {
2048
+ agentName: "unknown-agent",
2049
+ prompt: "Hello bot!",
2050
+ context: {
2051
+ messages: [],
2052
+ wasMentioned: true,
2053
+ prompt: "Hello bot!",
2054
+ },
2055
+ metadata: {
2056
+ guildId: "guild1",
2057
+ channelId: "channel1",
2058
+ messageId: "msg1",
2059
+ userId: "user1",
2060
+ username: "TestUser",
2061
+ wasMentioned: true,
2062
+ mode: "mention",
2063
+ },
2064
+ reply: replyMock,
2065
+ startTyping: () => () => { },
2066
+ };
2067
+ // Emit the message event
2068
+ mockConnector.emit("message", messageEvent);
2069
+ // Wait for async processing
2070
+ await new Promise((resolve) => setTimeout(resolve, 50));
2071
+ // Should have logged both the agent not found error and the reply failure
2072
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
2073
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
2074
+ });
2075
+ it("sends default response when job produces no output", async () => {
2076
+ const eventEmitter = new EventEmitter();
2077
+ // Trigger that returns but doesn't call onMessage
2078
+ const triggerMock = vi.fn().mockImplementation(async () => {
2079
+ return { jobId: "job-789" };
2080
+ });
2081
+ const emitterWithTrigger = Object.assign(eventEmitter, {
2082
+ trigger: triggerMock,
2083
+ });
2084
+ const config = {
2085
+ fleet: { name: "test-fleet" },
2086
+ agents: [
2087
+ createDiscordAgent("test-agent", {
2088
+ bot_token_env: "TEST_BOT_TOKEN",
2089
+ session_expiry_hours: 24,
2090
+ log_level: "standard",
2091
+ guilds: [],
2092
+ }),
2093
+ ],
2094
+ configPath: "/test/herdctl.yaml",
2095
+ configDir: "/test",
2096
+ };
2097
+ const mockContext = {
2098
+ getConfig: () => config,
2099
+ getStateDir: () => "/tmp/test-state",
2100
+ getStateDirInfo: () => null,
2101
+ getLogger: () => mockLogger,
2102
+ getScheduler: () => null,
2103
+ getStatus: () => "running",
2104
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2105
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2106
+ getStoppedAt: () => null,
2107
+ getLastError: () => null,
2108
+ getCheckInterval: () => 1000,
2109
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
2110
+ getEmitter: () => emitterWithTrigger,
2111
+ };
2112
+ const manager = new DiscordManager(mockContext);
2113
+ const mockConnector = new EventEmitter();
2114
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2115
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2116
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2117
+ mockConnector.getState = vi.fn().mockReturnValue({
2118
+ status: "connected",
2119
+ connectedAt: "2024-01-01T00:00:00.000Z",
2120
+ disconnectedAt: null,
2121
+ reconnectAttempts: 0,
2122
+ lastError: null,
2123
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2124
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2125
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2126
+ });
2127
+ mockConnector.agentName = "test-agent";
2128
+ mockConnector.sessionManager = {
2129
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
2130
+ touchSession: vi.fn().mockResolvedValue(undefined),
2131
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2132
+ };
2133
+ // @ts-expect-error - accessing private property for testing
2134
+ manager.connectors.set("test-agent", mockConnector);
2135
+ // @ts-expect-error - accessing private property for testing
2136
+ manager.initialized = true;
2137
+ await manager.start();
2138
+ // Create a mock message event
2139
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2140
+ const messageEvent = {
2141
+ agentName: "test-agent",
2142
+ prompt: "Hello bot!",
2143
+ context: {
2144
+ messages: [],
2145
+ wasMentioned: true,
2146
+ prompt: "Hello bot!",
2147
+ },
2148
+ metadata: {
2149
+ guildId: "guild1",
2150
+ channelId: "channel1",
2151
+ messageId: "msg1",
2152
+ userId: "user1",
2153
+ username: "TestUser",
2154
+ wasMentioned: true,
2155
+ mode: "mention",
2156
+ },
2157
+ reply: replyMock,
2158
+ startTyping: () => () => { },
2159
+ };
2160
+ // Emit the message event
2161
+ mockConnector.emit("message", messageEvent);
2162
+ // Wait for async processing
2163
+ await new Promise((resolve) => setTimeout(resolve, 50));
2164
+ // Should have sent the default "no output" message
2165
+ expect(replyMock).toHaveBeenCalledWith("I've completed the task, but I don't have a specific response to share.");
2166
+ });
2167
+ });
2168
+ //# sourceMappingURL=discord-manager.test.js.map