@herdctl/core 0.2.0 → 0.3.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.
@@ -0,0 +1,2162 @@
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
+ touchSession: vi.fn().mockResolvedValue(undefined),
741
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
742
+ };
743
+ // @ts-expect-error - accessing private property for testing
744
+ streamingManager.connectors.set("streaming-agent", mockConnector);
745
+ // @ts-expect-error - accessing private property for testing
746
+ streamingManager.initialized = true;
747
+ await streamingManager.start();
748
+ // Create a mock message event
749
+ const replyMock = vi.fn().mockResolvedValue(undefined);
750
+ const messageEvent = {
751
+ agentName: "streaming-agent",
752
+ prompt: "Hello bot!",
753
+ context: {
754
+ messages: [],
755
+ wasMentioned: true,
756
+ prompt: "Hello bot!",
757
+ },
758
+ metadata: {
759
+ guildId: "guild1",
760
+ channelId: "channel1",
761
+ messageId: "msg1",
762
+ userId: "user1",
763
+ username: "TestUser",
764
+ wasMentioned: true,
765
+ mode: "mention",
766
+ },
767
+ reply: replyMock,
768
+ startTyping: () => () => { },
769
+ };
770
+ // Emit the message event
771
+ mockConnector.emit("message", messageEvent);
772
+ // Wait for async processing
773
+ await new Promise((resolve) => setTimeout(resolve, 50));
774
+ // Should have collected the streaming messages and sent them
775
+ expect(replyMock).toHaveBeenCalledWith("Hello! How can I help you today?");
776
+ });
777
+ it("sends long streaming response with splitResponse", async () => {
778
+ // Create trigger mock that produces a long response
779
+ const longResponse = "This is a very long response. ".repeat(100); // About 3100 chars
780
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
781
+ if (options?.onMessage) {
782
+ options.onMessage({ type: "assistant", content: longResponse });
783
+ }
784
+ return { jobId: "long-job-123" };
785
+ });
786
+ const streamingEmitter = Object.assign(new EventEmitter(), {
787
+ trigger: customTriggerMock,
788
+ });
789
+ const streamingConfig = {
790
+ fleet: { name: "test-fleet" },
791
+ agents: [
792
+ createDiscordAgent("long-agent", {
793
+ bot_token_env: "TEST_BOT_TOKEN",
794
+ session_expiry_hours: 24,
795
+ log_level: "standard",
796
+ guilds: [],
797
+ }),
798
+ ],
799
+ configPath: "/test/herdctl.yaml",
800
+ configDir: "/test",
801
+ };
802
+ const streamingContext = {
803
+ getConfig: () => streamingConfig,
804
+ getStateDir: () => "/tmp/test-state",
805
+ getStateDirInfo: () => null,
806
+ getLogger: () => mockLogger,
807
+ getScheduler: () => null,
808
+ getStatus: () => "running",
809
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
810
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
811
+ getStoppedAt: () => null,
812
+ getLastError: () => null,
813
+ getCheckInterval: () => 1000,
814
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
815
+ getEmitter: () => streamingEmitter,
816
+ };
817
+ const streamingManager = new DiscordManager(streamingContext);
818
+ const mockConnector = new EventEmitter();
819
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
820
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
821
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
822
+ mockConnector.getState = vi.fn().mockReturnValue({
823
+ status: "connected",
824
+ connectedAt: "2024-01-01T00:00:00.000Z",
825
+ disconnectedAt: null,
826
+ reconnectAttempts: 0,
827
+ lastError: null,
828
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
829
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
830
+ messageStats: { received: 0, sent: 0, ignored: 0 },
831
+ });
832
+ mockConnector.agentName = "long-agent";
833
+ mockConnector.sessionManager = {
834
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
835
+ touchSession: vi.fn().mockResolvedValue(undefined),
836
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
837
+ };
838
+ // @ts-expect-error - accessing private property for testing
839
+ streamingManager.connectors.set("long-agent", mockConnector);
840
+ // @ts-expect-error - accessing private property for testing
841
+ streamingManager.initialized = true;
842
+ await streamingManager.start();
843
+ // Create a mock message event
844
+ const replyMock = vi.fn().mockResolvedValue(undefined);
845
+ const messageEvent = {
846
+ agentName: "long-agent",
847
+ prompt: "Hello bot!",
848
+ context: {
849
+ messages: [],
850
+ wasMentioned: true,
851
+ prompt: "Hello bot!",
852
+ },
853
+ metadata: {
854
+ guildId: "guild1",
855
+ channelId: "channel1",
856
+ messageId: "msg1",
857
+ userId: "user1",
858
+ username: "TestUser",
859
+ wasMentioned: true,
860
+ mode: "mention",
861
+ },
862
+ reply: replyMock,
863
+ startTyping: () => () => { },
864
+ };
865
+ // Emit the message event
866
+ mockConnector.emit("message", messageEvent);
867
+ // Wait for async processing
868
+ await new Promise((resolve) => setTimeout(resolve, 50));
869
+ // Should have sent multiple messages (split response)
870
+ expect(replyMock).toHaveBeenCalledTimes(2);
871
+ });
872
+ it("handles message handler rejection via catch handler", async () => {
873
+ // This tests the .catch(error => this.handleError()) path in start()
874
+ // when handleMessage throws an error that propagates to the catch handler
875
+ // Create a config with no agents to trigger the "agent not found" error path
876
+ const emptyConfig = {
877
+ fleet: { name: "test-fleet" },
878
+ agents: [], // No agents!
879
+ configPath: "/test/herdctl.yaml",
880
+ configDir: "/test",
881
+ };
882
+ const errorEmitter = new EventEmitter();
883
+ const errorContext = {
884
+ getConfig: () => emptyConfig,
885
+ getStateDir: () => "/tmp/test-state",
886
+ getStateDirInfo: () => null,
887
+ getLogger: () => mockLogger,
888
+ getScheduler: () => null,
889
+ getStatus: () => "running",
890
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
891
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
892
+ getStoppedAt: () => null,
893
+ getLastError: () => null,
894
+ getCheckInterval: () => 1000,
895
+ emit: (event, ...args) => errorEmitter.emit(event, ...args),
896
+ getEmitter: () => errorEmitter,
897
+ };
898
+ const errorManager = new DiscordManager(errorContext);
899
+ const mockConnector = new EventEmitter();
900
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
901
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
902
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
903
+ mockConnector.getState = vi.fn().mockReturnValue({
904
+ status: "connected",
905
+ connectedAt: "2024-01-01T00:00:00.000Z",
906
+ disconnectedAt: null,
907
+ reconnectAttempts: 0,
908
+ lastError: null,
909
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
910
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
911
+ messageStats: { received: 0, sent: 0, ignored: 0 },
912
+ });
913
+ mockConnector.agentName = "missing-agent";
914
+ mockConnector.sessionManager = {
915
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
916
+ };
917
+ // @ts-expect-error - accessing private property for testing
918
+ errorManager.connectors.set("missing-agent", mockConnector);
919
+ // @ts-expect-error - accessing private property for testing
920
+ errorManager.initialized = true;
921
+ await errorManager.start();
922
+ // Create a message event with a reply that throws
923
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply threw"));
924
+ const messageEvent = {
925
+ agentName: "missing-agent",
926
+ prompt: "Hello!",
927
+ context: {
928
+ messages: [],
929
+ wasMentioned: true,
930
+ prompt: "Hello!",
931
+ },
932
+ metadata: {
933
+ guildId: "guild1",
934
+ channelId: "channel1",
935
+ messageId: "msg1",
936
+ userId: "user1",
937
+ username: "TestUser",
938
+ wasMentioned: true,
939
+ mode: "mention",
940
+ },
941
+ reply: replyMock,
942
+ startTyping: () => () => { },
943
+ };
944
+ // Emit the message event - this will trigger handleMessage which will fail
945
+ // because agent is not in config, and then try to reply, and that also fails
946
+ mockConnector.emit("message", messageEvent);
947
+ // Wait for async processing
948
+ await new Promise((resolve) => setTimeout(resolve, 50));
949
+ // The catch handler should have caught the error and called handleError
950
+ // which logs the error via discord:error event
951
+ expect(mockLogger.error).toHaveBeenCalled();
952
+ });
953
+ it("handles error events from connector", async () => {
954
+ // Create a mock connector
955
+ const mockConnector = new EventEmitter();
956
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
957
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
958
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
959
+ mockConnector.getState = vi.fn().mockReturnValue({
960
+ status: "connected",
961
+ connectedAt: "2024-01-01T00:00:00.000Z",
962
+ disconnectedAt: null,
963
+ reconnectAttempts: 0,
964
+ lastError: null,
965
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
966
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
967
+ messageStats: { received: 0, sent: 0, ignored: 0 },
968
+ });
969
+ mockConnector.agentName = "test-agent";
970
+ // @ts-expect-error - accessing private property for testing
971
+ manager.connectors.set("test-agent", mockConnector);
972
+ // @ts-expect-error - accessing private property for testing
973
+ manager.initialized = true;
974
+ await manager.start();
975
+ // Emit an error event
976
+ const errorEvent = {
977
+ agentName: "test-agent",
978
+ error: new Error("Test error"),
979
+ };
980
+ mockConnector.emit("error", errorEvent);
981
+ // Wait for async processing
982
+ await new Promise((resolve) => setTimeout(resolve, 10));
983
+ // Should have logged the error
984
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord connector error"));
985
+ });
986
+ it("sends formatted error reply when trigger fails", async () => {
987
+ // Create a mock connector
988
+ const mockConnector = new EventEmitter();
989
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
990
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
991
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
992
+ mockConnector.getState = vi.fn().mockReturnValue({
993
+ status: "connected",
994
+ connectedAt: "2024-01-01T00:00:00.000Z",
995
+ disconnectedAt: null,
996
+ reconnectAttempts: 0,
997
+ lastError: null,
998
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
999
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1000
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1001
+ });
1002
+ mockConnector.agentName = "test-agent";
1003
+ // Make trigger fail
1004
+ triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
1005
+ // @ts-expect-error - accessing private property for testing
1006
+ manager.connectors.set("test-agent", mockConnector);
1007
+ // @ts-expect-error - accessing private property for testing
1008
+ manager.initialized = true;
1009
+ await manager.start();
1010
+ // Create a mock message event
1011
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1012
+ const messageEvent = {
1013
+ agentName: "test-agent",
1014
+ prompt: "Hello bot!",
1015
+ context: {
1016
+ messages: [],
1017
+ wasMentioned: true,
1018
+ prompt: "Hello bot!",
1019
+ },
1020
+ metadata: {
1021
+ guildId: "guild1",
1022
+ channelId: "channel1",
1023
+ messageId: "msg1",
1024
+ userId: "user1",
1025
+ username: "TestUser",
1026
+ wasMentioned: true,
1027
+ mode: "mention",
1028
+ },
1029
+ reply: replyMock,
1030
+ startTyping: () => () => { },
1031
+ };
1032
+ // Emit the message event
1033
+ mockConnector.emit("message", messageEvent);
1034
+ // Wait for async processing
1035
+ await new Promise((resolve) => setTimeout(resolve, 50));
1036
+ // Should have sent a formatted error reply
1037
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("❌ **Error**:"));
1038
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Agent execution failed"));
1039
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("/reset"));
1040
+ });
1041
+ it("handles error reply failure when trigger fails", async () => {
1042
+ // Create a mock connector
1043
+ const mockConnector = new EventEmitter();
1044
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1045
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1046
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1047
+ mockConnector.getState = vi.fn().mockReturnValue({
1048
+ status: "connected",
1049
+ connectedAt: "2024-01-01T00:00:00.000Z",
1050
+ disconnectedAt: null,
1051
+ reconnectAttempts: 0,
1052
+ lastError: null,
1053
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1054
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1055
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1056
+ });
1057
+ mockConnector.agentName = "test-agent";
1058
+ // Make trigger fail
1059
+ triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
1060
+ // @ts-expect-error - accessing private property for testing
1061
+ manager.connectors.set("test-agent", mockConnector);
1062
+ // @ts-expect-error - accessing private property for testing
1063
+ manager.initialized = true;
1064
+ await manager.start();
1065
+ // Create a mock message event with reply that also fails
1066
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply also failed"));
1067
+ const messageEvent = {
1068
+ agentName: "test-agent",
1069
+ prompt: "Hello bot!",
1070
+ context: {
1071
+ messages: [],
1072
+ wasMentioned: true,
1073
+ prompt: "Hello bot!",
1074
+ },
1075
+ metadata: {
1076
+ guildId: "guild1",
1077
+ channelId: "channel1",
1078
+ messageId: "msg1",
1079
+ userId: "user1",
1080
+ username: "TestUser",
1081
+ wasMentioned: true,
1082
+ mode: "mention",
1083
+ },
1084
+ reply: replyMock,
1085
+ startTyping: () => () => { },
1086
+ };
1087
+ // Emit the message event
1088
+ mockConnector.emit("message", messageEvent);
1089
+ // Wait for async processing
1090
+ await new Promise((resolve) => setTimeout(resolve, 50));
1091
+ // Should have logged both errors
1092
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord message handling failed"));
1093
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
1094
+ });
1095
+ it("sends error reply when agent not found", async () => {
1096
+ // Create a mock connector
1097
+ const mockConnector = new EventEmitter();
1098
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1099
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1100
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1101
+ mockConnector.getState = vi.fn().mockReturnValue({
1102
+ status: "connected",
1103
+ connectedAt: "2024-01-01T00:00:00.000Z",
1104
+ disconnectedAt: null,
1105
+ reconnectAttempts: 0,
1106
+ lastError: null,
1107
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1108
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1109
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1110
+ });
1111
+ mockConnector.agentName = "unknown-agent";
1112
+ // @ts-expect-error - accessing private property for testing
1113
+ manager.connectors.set("unknown-agent", mockConnector);
1114
+ // @ts-expect-error - accessing private property for testing
1115
+ manager.initialized = true;
1116
+ await manager.start();
1117
+ // Create a mock message event for an agent not in config
1118
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1119
+ const messageEvent = {
1120
+ agentName: "unknown-agent",
1121
+ prompt: "Hello bot!",
1122
+ context: {
1123
+ messages: [],
1124
+ wasMentioned: true,
1125
+ prompt: "Hello bot!",
1126
+ },
1127
+ metadata: {
1128
+ guildId: "guild1",
1129
+ channelId: "channel1",
1130
+ messageId: "msg1",
1131
+ userId: "user1",
1132
+ username: "TestUser",
1133
+ wasMentioned: true,
1134
+ mode: "mention",
1135
+ },
1136
+ reply: replyMock,
1137
+ startTyping: () => () => { },
1138
+ };
1139
+ // Emit the message event
1140
+ mockConnector.emit("message", messageEvent);
1141
+ // Wait for async processing
1142
+ await new Promise((resolve) => setTimeout(resolve, 50));
1143
+ // Should have sent an error reply
1144
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("not properly configured"));
1145
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
1146
+ });
1147
+ });
1148
+ describe("extractMessageContent", () => {
1149
+ it("extracts direct string content", () => {
1150
+ // @ts-expect-error - accessing private method for testing
1151
+ const result = manager.extractMessageContent({
1152
+ type: "assistant",
1153
+ content: "Direct content",
1154
+ });
1155
+ expect(result).toBe("Direct content");
1156
+ });
1157
+ it("extracts nested message content", () => {
1158
+ // @ts-expect-error - accessing private method for testing
1159
+ const result = manager.extractMessageContent({
1160
+ type: "assistant",
1161
+ message: { content: "Nested content" },
1162
+ });
1163
+ expect(result).toBe("Nested content");
1164
+ });
1165
+ it("extracts text from content blocks", () => {
1166
+ // @ts-expect-error - accessing private method for testing
1167
+ const result = manager.extractMessageContent({
1168
+ type: "assistant",
1169
+ message: {
1170
+ content: [
1171
+ { type: "text", text: "First part" },
1172
+ { type: "text", text: " Second part" },
1173
+ ],
1174
+ },
1175
+ });
1176
+ expect(result).toBe("First part Second part");
1177
+ });
1178
+ it("returns undefined for empty content", () => {
1179
+ // @ts-expect-error - accessing private method for testing
1180
+ const result = manager.extractMessageContent({
1181
+ type: "assistant",
1182
+ });
1183
+ expect(result).toBeUndefined();
1184
+ });
1185
+ it("returns undefined for non-text content blocks", () => {
1186
+ // @ts-expect-error - accessing private method for testing
1187
+ const result = manager.extractMessageContent({
1188
+ type: "assistant",
1189
+ message: {
1190
+ content: [
1191
+ { type: "tool_use", name: "some_tool" },
1192
+ ],
1193
+ },
1194
+ });
1195
+ expect(result).toBeUndefined();
1196
+ });
1197
+ it("returns undefined for empty string content", () => {
1198
+ // @ts-expect-error - accessing private method for testing
1199
+ const result = manager.extractMessageContent({
1200
+ type: "assistant",
1201
+ content: "",
1202
+ });
1203
+ expect(result).toBeUndefined();
1204
+ });
1205
+ it("handles mixed content blocks (text and non-text)", () => {
1206
+ // @ts-expect-error - accessing private method for testing
1207
+ const result = manager.extractMessageContent({
1208
+ type: "assistant",
1209
+ message: {
1210
+ content: [
1211
+ { type: "tool_use", name: "some_tool" },
1212
+ { type: "text", text: "After tool" },
1213
+ ],
1214
+ },
1215
+ });
1216
+ expect(result).toBe("After tool");
1217
+ });
1218
+ it("handles empty content blocks array", () => {
1219
+ // @ts-expect-error - accessing private method for testing
1220
+ const result = manager.extractMessageContent({
1221
+ type: "assistant",
1222
+ message: {
1223
+ content: [],
1224
+ },
1225
+ });
1226
+ expect(result).toBeUndefined();
1227
+ });
1228
+ it("returns undefined for content that is not a string or array", () => {
1229
+ // @ts-expect-error - accessing private method for testing
1230
+ const result = manager.extractMessageContent({
1231
+ type: "assistant",
1232
+ message: {
1233
+ content: { someObject: "value" }, // Not string or array
1234
+ },
1235
+ });
1236
+ expect(result).toBeUndefined();
1237
+ });
1238
+ it("handles content blocks with missing text property", () => {
1239
+ // @ts-expect-error - accessing private method for testing
1240
+ const result = manager.extractMessageContent({
1241
+ type: "assistant",
1242
+ message: {
1243
+ content: [
1244
+ { type: "text" }, // Missing text property
1245
+ ],
1246
+ },
1247
+ });
1248
+ expect(result).toBeUndefined();
1249
+ });
1250
+ it("handles content block with non-string text", () => {
1251
+ // @ts-expect-error - accessing private method for testing
1252
+ const result = manager.extractMessageContent({
1253
+ type: "assistant",
1254
+ message: {
1255
+ content: [
1256
+ { type: "text", text: 123 }, // Non-string text
1257
+ ],
1258
+ },
1259
+ });
1260
+ expect(result).toBeUndefined();
1261
+ });
1262
+ });
1263
+ });
1264
+ describe("DiscordManager session integration", () => {
1265
+ let manager;
1266
+ let mockContext;
1267
+ let triggerMock;
1268
+ let emitterWithTrigger;
1269
+ let mockSessionManager;
1270
+ beforeEach(() => {
1271
+ vi.clearAllMocks();
1272
+ // Create mock session manager
1273
+ mockSessionManager = {
1274
+ agentName: "test-agent",
1275
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "session-123", isNew: false }),
1276
+ touchSession: vi.fn().mockResolvedValue(undefined),
1277
+ clearSession: vi.fn().mockResolvedValue(true),
1278
+ cleanupExpiredSessions: vi.fn().mockResolvedValue(0),
1279
+ getActiveSessionCount: vi.fn().mockResolvedValue(5),
1280
+ };
1281
+ // Create a mock FleetManager (emitter) with trigger method
1282
+ triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123" });
1283
+ emitterWithTrigger = Object.assign(new EventEmitter(), {
1284
+ trigger: triggerMock,
1285
+ });
1286
+ const config = {
1287
+ fleet: { name: "test-fleet" },
1288
+ agents: [
1289
+ createDiscordAgent("test-agent", {
1290
+ bot_token_env: "TEST_BOT_TOKEN",
1291
+ session_expiry_hours: 24,
1292
+ log_level: "standard",
1293
+ guilds: [],
1294
+ }),
1295
+ ],
1296
+ configPath: "/test/herdctl.yaml",
1297
+ configDir: "/test",
1298
+ };
1299
+ mockContext = {
1300
+ getConfig: () => config,
1301
+ getStateDir: () => "/tmp/test-state",
1302
+ getStateDirInfo: () => null,
1303
+ getLogger: () => mockLogger,
1304
+ getScheduler: () => null,
1305
+ getStatus: () => "running",
1306
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1307
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1308
+ getStoppedAt: () => null,
1309
+ getLastError: () => null,
1310
+ getCheckInterval: () => 1000,
1311
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1312
+ getEmitter: () => emitterWithTrigger,
1313
+ };
1314
+ manager = new DiscordManager(mockContext);
1315
+ });
1316
+ it("calls getOrCreateSession on message", async () => {
1317
+ // Create a mock connector with session manager
1318
+ const mockConnector = new EventEmitter();
1319
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1320
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1321
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1322
+ mockConnector.getState = vi.fn().mockReturnValue({
1323
+ status: "connected",
1324
+ connectedAt: "2024-01-01T00:00:00.000Z",
1325
+ disconnectedAt: null,
1326
+ reconnectAttempts: 0,
1327
+ lastError: null,
1328
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1329
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1330
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1331
+ });
1332
+ mockConnector.agentName = "test-agent";
1333
+ mockConnector.sessionManager = mockSessionManager;
1334
+ // @ts-expect-error - accessing private property for testing
1335
+ manager.connectors.set("test-agent", mockConnector);
1336
+ // @ts-expect-error - accessing private property for testing
1337
+ manager.initialized = true;
1338
+ await manager.start();
1339
+ // Create a mock message event
1340
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1341
+ const messageEvent = {
1342
+ agentName: "test-agent",
1343
+ prompt: "Hello bot!",
1344
+ context: {
1345
+ messages: [],
1346
+ wasMentioned: true,
1347
+ prompt: "Hello bot!",
1348
+ },
1349
+ metadata: {
1350
+ guildId: "guild1",
1351
+ channelId: "channel1",
1352
+ messageId: "msg1",
1353
+ userId: "user1",
1354
+ username: "TestUser",
1355
+ wasMentioned: true,
1356
+ mode: "mention",
1357
+ },
1358
+ reply: replyMock,
1359
+ startTyping: () => () => { },
1360
+ };
1361
+ // Emit the message event
1362
+ mockConnector.emit("message", messageEvent);
1363
+ // Wait for async processing
1364
+ await new Promise((resolve) => setTimeout(resolve, 50));
1365
+ // Should have called getOrCreateSession
1366
+ expect(mockSessionManager.getOrCreateSession).toHaveBeenCalledWith("channel1");
1367
+ });
1368
+ it("calls touchSession after successful response", async () => {
1369
+ // Create a mock connector with session manager
1370
+ const mockConnector = new EventEmitter();
1371
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1372
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1373
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1374
+ mockConnector.getState = vi.fn().mockReturnValue({
1375
+ status: "connected",
1376
+ connectedAt: "2024-01-01T00:00:00.000Z",
1377
+ disconnectedAt: null,
1378
+ reconnectAttempts: 0,
1379
+ lastError: null,
1380
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1381
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1382
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1383
+ });
1384
+ mockConnector.agentName = "test-agent";
1385
+ mockConnector.sessionManager = mockSessionManager;
1386
+ // @ts-expect-error - accessing private property for testing
1387
+ manager.connectors.set("test-agent", mockConnector);
1388
+ // @ts-expect-error - accessing private property for testing
1389
+ manager.initialized = true;
1390
+ await manager.start();
1391
+ // Create a mock message event
1392
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1393
+ const messageEvent = {
1394
+ agentName: "test-agent",
1395
+ prompt: "Hello bot!",
1396
+ context: {
1397
+ messages: [],
1398
+ wasMentioned: true,
1399
+ prompt: "Hello bot!",
1400
+ },
1401
+ metadata: {
1402
+ guildId: "guild1",
1403
+ channelId: "channel1",
1404
+ messageId: "msg1",
1405
+ userId: "user1",
1406
+ username: "TestUser",
1407
+ wasMentioned: true,
1408
+ mode: "mention",
1409
+ },
1410
+ reply: replyMock,
1411
+ startTyping: () => () => { },
1412
+ };
1413
+ // Emit the message event
1414
+ mockConnector.emit("message", messageEvent);
1415
+ // Wait for async processing
1416
+ await new Promise((resolve) => setTimeout(resolve, 50));
1417
+ // Should have called touchSession
1418
+ expect(mockSessionManager.touchSession).toHaveBeenCalledWith("channel1");
1419
+ });
1420
+ it("handles session manager errors gracefully", async () => {
1421
+ // Create a mock connector with session manager that fails
1422
+ mockSessionManager.getOrCreateSession.mockRejectedValue(new Error("Session error"));
1423
+ const mockConnector = new EventEmitter();
1424
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1425
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1426
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1427
+ mockConnector.getState = vi.fn().mockReturnValue({
1428
+ status: "connected",
1429
+ connectedAt: "2024-01-01T00:00:00.000Z",
1430
+ disconnectedAt: null,
1431
+ reconnectAttempts: 0,
1432
+ lastError: null,
1433
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1434
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1435
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1436
+ });
1437
+ mockConnector.agentName = "test-agent";
1438
+ mockConnector.sessionManager = mockSessionManager;
1439
+ // @ts-expect-error - accessing private property for testing
1440
+ manager.connectors.set("test-agent", mockConnector);
1441
+ // @ts-expect-error - accessing private property for testing
1442
+ manager.initialized = true;
1443
+ await manager.start();
1444
+ // Create a mock message event
1445
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1446
+ const messageEvent = {
1447
+ agentName: "test-agent",
1448
+ prompt: "Hello bot!",
1449
+ context: {
1450
+ messages: [],
1451
+ wasMentioned: true,
1452
+ prompt: "Hello bot!",
1453
+ },
1454
+ metadata: {
1455
+ guildId: "guild1",
1456
+ channelId: "channel1",
1457
+ messageId: "msg1",
1458
+ userId: "user1",
1459
+ username: "TestUser",
1460
+ wasMentioned: true,
1461
+ mode: "mention",
1462
+ },
1463
+ reply: replyMock,
1464
+ startTyping: () => () => { },
1465
+ };
1466
+ // Emit the message event
1467
+ mockConnector.emit("message", messageEvent);
1468
+ // Wait for async processing
1469
+ await new Promise((resolve) => setTimeout(resolve, 50));
1470
+ // Should have logged a warning but continued processing
1471
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get/create session"));
1472
+ // Should still have called trigger
1473
+ expect(triggerMock).toHaveBeenCalled();
1474
+ });
1475
+ it("handles touchSession errors gracefully", async () => {
1476
+ // Create a mock connector with session manager where touchSession fails
1477
+ mockSessionManager.touchSession.mockRejectedValue(new Error("Touch error"));
1478
+ const mockConnector = new EventEmitter();
1479
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1480
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1481
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1482
+ mockConnector.getState = vi.fn().mockReturnValue({
1483
+ status: "connected",
1484
+ connectedAt: "2024-01-01T00:00:00.000Z",
1485
+ disconnectedAt: null,
1486
+ reconnectAttempts: 0,
1487
+ lastError: null,
1488
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1489
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1490
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1491
+ });
1492
+ mockConnector.agentName = "test-agent";
1493
+ mockConnector.sessionManager = mockSessionManager;
1494
+ // @ts-expect-error - accessing private property for testing
1495
+ manager.connectors.set("test-agent", mockConnector);
1496
+ // @ts-expect-error - accessing private property for testing
1497
+ manager.initialized = true;
1498
+ await manager.start();
1499
+ // Create a mock message event
1500
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1501
+ const messageEvent = {
1502
+ agentName: "test-agent",
1503
+ prompt: "Hello bot!",
1504
+ context: {
1505
+ messages: [],
1506
+ wasMentioned: true,
1507
+ prompt: "Hello bot!",
1508
+ },
1509
+ metadata: {
1510
+ guildId: "guild1",
1511
+ channelId: "channel1",
1512
+ messageId: "msg1",
1513
+ userId: "user1",
1514
+ username: "TestUser",
1515
+ wasMentioned: true,
1516
+ mode: "mention",
1517
+ },
1518
+ reply: replyMock,
1519
+ startTyping: () => () => { },
1520
+ };
1521
+ // Emit the message event
1522
+ mockConnector.emit("message", messageEvent);
1523
+ // Wait for async processing
1524
+ await new Promise((resolve) => setTimeout(resolve, 50));
1525
+ // Should have logged a warning but continued
1526
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to touch session"));
1527
+ // Reply should still have been sent
1528
+ expect(replyMock).toHaveBeenCalled();
1529
+ });
1530
+ it("logs session count on stop", async () => {
1531
+ // Create a mock connector with session manager
1532
+ const mockConnector = new EventEmitter();
1533
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1534
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1535
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1536
+ mockConnector.getState = vi.fn().mockReturnValue({
1537
+ status: "connected",
1538
+ connectedAt: "2024-01-01T00:00:00.000Z",
1539
+ disconnectedAt: null,
1540
+ reconnectAttempts: 0,
1541
+ lastError: null,
1542
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1543
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1544
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1545
+ });
1546
+ mockConnector.agentName = "test-agent";
1547
+ mockConnector.sessionManager = mockSessionManager;
1548
+ // @ts-expect-error - accessing private property for testing
1549
+ manager.connectors.set("test-agent", mockConnector);
1550
+ // @ts-expect-error - accessing private property for testing
1551
+ manager.initialized = true;
1552
+ await manager.stop();
1553
+ // Should have queried session count
1554
+ expect(mockSessionManager.getActiveSessionCount).toHaveBeenCalled();
1555
+ // Should have logged about preserving sessions
1556
+ expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Preserving 5 active session(s)"));
1557
+ });
1558
+ it("handles getActiveSessionCount errors on stop", async () => {
1559
+ // Create a mock connector with session manager that fails
1560
+ mockSessionManager.getActiveSessionCount.mockRejectedValue(new Error("Count error"));
1561
+ const mockConnector = new EventEmitter();
1562
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1563
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1564
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1565
+ mockConnector.getState = vi.fn().mockReturnValue({
1566
+ status: "connected",
1567
+ connectedAt: "2024-01-01T00:00:00.000Z",
1568
+ disconnectedAt: null,
1569
+ reconnectAttempts: 0,
1570
+ lastError: null,
1571
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1572
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1573
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1574
+ });
1575
+ mockConnector.agentName = "test-agent";
1576
+ mockConnector.sessionManager = mockSessionManager;
1577
+ // @ts-expect-error - accessing private property for testing
1578
+ manager.connectors.set("test-agent", mockConnector);
1579
+ // @ts-expect-error - accessing private property for testing
1580
+ manager.initialized = true;
1581
+ await manager.stop();
1582
+ // Should have warned about the error
1583
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get session count"));
1584
+ // Should still disconnect
1585
+ expect(mockConnector.disconnect).toHaveBeenCalled();
1586
+ });
1587
+ it("does not log session preservation when count is 0", async () => {
1588
+ // Create a mock connector with session manager returning 0 sessions
1589
+ mockSessionManager.getActiveSessionCount.mockResolvedValue(0);
1590
+ const mockConnector = new EventEmitter();
1591
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1592
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1593
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1594
+ mockConnector.getState = vi.fn().mockReturnValue({
1595
+ status: "connected",
1596
+ connectedAt: "2024-01-01T00:00:00.000Z",
1597
+ disconnectedAt: null,
1598
+ reconnectAttempts: 0,
1599
+ lastError: null,
1600
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1601
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1602
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1603
+ });
1604
+ mockConnector.agentName = "test-agent";
1605
+ mockConnector.sessionManager = mockSessionManager;
1606
+ // @ts-expect-error - accessing private property for testing
1607
+ manager.connectors.set("test-agent", mockConnector);
1608
+ // @ts-expect-error - accessing private property for testing
1609
+ manager.initialized = true;
1610
+ await manager.stop();
1611
+ // Should NOT have logged about preserving sessions (0 sessions)
1612
+ expect(mockLogger.info).not.toHaveBeenCalledWith(expect.stringContaining("Preserving"));
1613
+ });
1614
+ });
1615
+ describe("DiscordManager lifecycle", () => {
1616
+ beforeEach(() => {
1617
+ vi.clearAllMocks();
1618
+ });
1619
+ it("handles connect failure gracefully", async () => {
1620
+ const mockConnector = new EventEmitter();
1621
+ mockConnector.connect = vi.fn().mockRejectedValue(new Error("Connection failed"));
1622
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1623
+ mockConnector.isConnected = vi.fn().mockReturnValue(false);
1624
+ mockConnector.getState = vi.fn().mockReturnValue({
1625
+ status: "error",
1626
+ connectedAt: null,
1627
+ disconnectedAt: null,
1628
+ reconnectAttempts: 0,
1629
+ lastError: "Connection failed",
1630
+ botUser: null,
1631
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1632
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1633
+ });
1634
+ mockConnector.agentName = "test-agent";
1635
+ mockConnector.sessionManager = {
1636
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1637
+ };
1638
+ const ctx = createMockContext(null);
1639
+ const manager = new DiscordManager(ctx);
1640
+ // @ts-expect-error - accessing private property for testing
1641
+ manager.connectors.set("test-agent", mockConnector);
1642
+ // @ts-expect-error - accessing private property for testing
1643
+ manager.initialized = true;
1644
+ // Should not throw
1645
+ await manager.start();
1646
+ // Should have logged the error
1647
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to connect Discord"));
1648
+ });
1649
+ it("handles disconnect failure gracefully", async () => {
1650
+ const mockConnector = new EventEmitter();
1651
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1652
+ mockConnector.disconnect = vi.fn().mockRejectedValue(new Error("Disconnect failed"));
1653
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1654
+ mockConnector.getState = vi.fn().mockReturnValue({
1655
+ status: "connected",
1656
+ connectedAt: "2024-01-01T00:00:00.000Z",
1657
+ disconnectedAt: null,
1658
+ reconnectAttempts: 0,
1659
+ lastError: null,
1660
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1661
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1662
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1663
+ });
1664
+ mockConnector.agentName = "test-agent";
1665
+ mockConnector.sessionManager = {
1666
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1667
+ };
1668
+ const ctx = createMockContext(null);
1669
+ const manager = new DiscordManager(ctx);
1670
+ // @ts-expect-error - accessing private property for testing
1671
+ manager.connectors.set("test-agent", mockConnector);
1672
+ // @ts-expect-error - accessing private property for testing
1673
+ manager.initialized = true;
1674
+ // Should not throw
1675
+ await manager.stop();
1676
+ // Should have logged the error
1677
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Error disconnecting Discord"));
1678
+ });
1679
+ it("reports correct connected count", async () => {
1680
+ const connectedConnector = new EventEmitter();
1681
+ connectedConnector.connect = vi.fn().mockResolvedValue(undefined);
1682
+ connectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1683
+ connectedConnector.isConnected = vi.fn().mockReturnValue(true);
1684
+ connectedConnector.getState = vi.fn().mockReturnValue({
1685
+ status: "connected",
1686
+ connectedAt: "2024-01-01T00:00:00.000Z",
1687
+ disconnectedAt: null,
1688
+ reconnectAttempts: 0,
1689
+ lastError: null,
1690
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1691
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1692
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1693
+ });
1694
+ connectedConnector.agentName = "connected-agent";
1695
+ connectedConnector.sessionManager = {
1696
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1697
+ };
1698
+ const disconnectedConnector = new EventEmitter();
1699
+ disconnectedConnector.connect = vi.fn().mockRejectedValue(new Error("Failed"));
1700
+ disconnectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1701
+ disconnectedConnector.isConnected = vi.fn().mockReturnValue(false);
1702
+ disconnectedConnector.getState = vi.fn().mockReturnValue({
1703
+ status: "error",
1704
+ connectedAt: null,
1705
+ disconnectedAt: null,
1706
+ reconnectAttempts: 0,
1707
+ lastError: "Failed",
1708
+ botUser: null,
1709
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1710
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1711
+ });
1712
+ disconnectedConnector.agentName = "disconnected-agent";
1713
+ disconnectedConnector.sessionManager = {
1714
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1715
+ };
1716
+ const ctx = createMockContext(null);
1717
+ const manager = new DiscordManager(ctx);
1718
+ // @ts-expect-error - accessing private property for testing
1719
+ manager.connectors.set("connected-agent", connectedConnector);
1720
+ // @ts-expect-error - accessing private property for testing
1721
+ manager.connectors.set("disconnected-agent", disconnectedConnector);
1722
+ // @ts-expect-error - accessing private property for testing
1723
+ manager.initialized = true;
1724
+ await manager.start();
1725
+ // Should report correct counts
1726
+ expect(manager.getConnectedCount()).toBe(1);
1727
+ expect(manager.getConnectorNames()).toEqual(["connected-agent", "disconnected-agent"]);
1728
+ });
1729
+ it("emits discord:message:handled event on successful message handling", async () => {
1730
+ const eventEmitter = new EventEmitter();
1731
+ const emittedEvents = [];
1732
+ // Track emitted events
1733
+ eventEmitter.on("discord:message:handled", (data) => {
1734
+ emittedEvents.push({ event: "discord:message:handled", data });
1735
+ });
1736
+ const triggerMock = vi.fn().mockResolvedValue({ jobId: "job-456" });
1737
+ const emitterWithTrigger = Object.assign(eventEmitter, {
1738
+ trigger: triggerMock,
1739
+ });
1740
+ const config = {
1741
+ fleet: { name: "test-fleet" },
1742
+ agents: [
1743
+ createDiscordAgent("test-agent", {
1744
+ bot_token_env: "TEST_BOT_TOKEN",
1745
+ session_expiry_hours: 24,
1746
+ log_level: "standard",
1747
+ guilds: [],
1748
+ }),
1749
+ ],
1750
+ configPath: "/test/herdctl.yaml",
1751
+ configDir: "/test",
1752
+ };
1753
+ const mockContext = {
1754
+ getConfig: () => config,
1755
+ getStateDir: () => "/tmp/test-state",
1756
+ getStateDirInfo: () => null,
1757
+ getLogger: () => mockLogger,
1758
+ getScheduler: () => null,
1759
+ getStatus: () => "running",
1760
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1761
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1762
+ getStoppedAt: () => null,
1763
+ getLastError: () => null,
1764
+ getCheckInterval: () => 1000,
1765
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1766
+ getEmitter: () => emitterWithTrigger,
1767
+ };
1768
+ const manager = new DiscordManager(mockContext);
1769
+ const mockConnector = new EventEmitter();
1770
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1771
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1772
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1773
+ mockConnector.getState = vi.fn().mockReturnValue({
1774
+ status: "connected",
1775
+ connectedAt: "2024-01-01T00:00:00.000Z",
1776
+ disconnectedAt: null,
1777
+ reconnectAttempts: 0,
1778
+ lastError: null,
1779
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1780
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1781
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1782
+ });
1783
+ mockConnector.agentName = "test-agent";
1784
+ mockConnector.sessionManager = {
1785
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
1786
+ touchSession: vi.fn().mockResolvedValue(undefined),
1787
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1788
+ };
1789
+ // @ts-expect-error - accessing private property for testing
1790
+ manager.connectors.set("test-agent", mockConnector);
1791
+ // @ts-expect-error - accessing private property for testing
1792
+ manager.initialized = true;
1793
+ await manager.start();
1794
+ // Create a mock message event
1795
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1796
+ const messageEvent = {
1797
+ agentName: "test-agent",
1798
+ prompt: "Hello bot!",
1799
+ context: {
1800
+ messages: [],
1801
+ wasMentioned: true,
1802
+ prompt: "Hello bot!",
1803
+ },
1804
+ metadata: {
1805
+ guildId: "guild1",
1806
+ channelId: "channel1",
1807
+ messageId: "msg1",
1808
+ userId: "user1",
1809
+ username: "TestUser",
1810
+ wasMentioned: true,
1811
+ mode: "mention",
1812
+ },
1813
+ reply: replyMock,
1814
+ startTyping: () => () => { },
1815
+ };
1816
+ // Emit the message event
1817
+ mockConnector.emit("message", messageEvent);
1818
+ // Wait for async processing
1819
+ await new Promise((resolve) => setTimeout(resolve, 50));
1820
+ // Should have emitted the handled event
1821
+ expect(emittedEvents.length).toBe(1);
1822
+ expect(emittedEvents[0].event).toBe("discord:message:handled");
1823
+ expect(emittedEvents[0].data).toMatchObject({
1824
+ agentName: "test-agent",
1825
+ channelId: "channel1",
1826
+ messageId: "msg1",
1827
+ jobId: "job-456",
1828
+ });
1829
+ });
1830
+ it("emits discord:message:error event on message handling failure", async () => {
1831
+ const eventEmitter = new EventEmitter();
1832
+ const emittedEvents = [];
1833
+ // Track emitted events
1834
+ eventEmitter.on("discord:message:error", (data) => {
1835
+ emittedEvents.push({ event: "discord:message:error", data });
1836
+ });
1837
+ const triggerMock = vi.fn().mockRejectedValue(new Error("Execution failed"));
1838
+ const emitterWithTrigger = Object.assign(eventEmitter, {
1839
+ trigger: triggerMock,
1840
+ });
1841
+ const config = {
1842
+ fleet: { name: "test-fleet" },
1843
+ agents: [
1844
+ createDiscordAgent("test-agent", {
1845
+ bot_token_env: "TEST_BOT_TOKEN",
1846
+ session_expiry_hours: 24,
1847
+ log_level: "standard",
1848
+ guilds: [],
1849
+ }),
1850
+ ],
1851
+ configPath: "/test/herdctl.yaml",
1852
+ configDir: "/test",
1853
+ };
1854
+ const mockContext = {
1855
+ getConfig: () => config,
1856
+ getStateDir: () => "/tmp/test-state",
1857
+ getStateDirInfo: () => null,
1858
+ getLogger: () => mockLogger,
1859
+ getScheduler: () => null,
1860
+ getStatus: () => "running",
1861
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1862
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1863
+ getStoppedAt: () => null,
1864
+ getLastError: () => null,
1865
+ getCheckInterval: () => 1000,
1866
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1867
+ getEmitter: () => emitterWithTrigger,
1868
+ };
1869
+ const manager = new DiscordManager(mockContext);
1870
+ const mockConnector = new EventEmitter();
1871
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1872
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1873
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1874
+ mockConnector.getState = vi.fn().mockReturnValue({
1875
+ status: "connected",
1876
+ connectedAt: "2024-01-01T00:00:00.000Z",
1877
+ disconnectedAt: null,
1878
+ reconnectAttempts: 0,
1879
+ lastError: null,
1880
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1881
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1882
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1883
+ });
1884
+ mockConnector.agentName = "test-agent";
1885
+ mockConnector.sessionManager = {
1886
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
1887
+ touchSession: vi.fn().mockResolvedValue(undefined),
1888
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1889
+ };
1890
+ // @ts-expect-error - accessing private property for testing
1891
+ manager.connectors.set("test-agent", mockConnector);
1892
+ // @ts-expect-error - accessing private property for testing
1893
+ manager.initialized = true;
1894
+ await manager.start();
1895
+ // Create a mock message event
1896
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1897
+ const messageEvent = {
1898
+ agentName: "test-agent",
1899
+ prompt: "Hello bot!",
1900
+ context: {
1901
+ messages: [],
1902
+ wasMentioned: true,
1903
+ prompt: "Hello bot!",
1904
+ },
1905
+ metadata: {
1906
+ guildId: "guild1",
1907
+ channelId: "channel1",
1908
+ messageId: "msg1",
1909
+ userId: "user1",
1910
+ username: "TestUser",
1911
+ wasMentioned: true,
1912
+ mode: "mention",
1913
+ },
1914
+ reply: replyMock,
1915
+ startTyping: () => () => { },
1916
+ };
1917
+ // Emit the message event
1918
+ mockConnector.emit("message", messageEvent);
1919
+ // Wait for async processing
1920
+ await new Promise((resolve) => setTimeout(resolve, 50));
1921
+ // Should have emitted the error event
1922
+ expect(emittedEvents.length).toBe(1);
1923
+ expect(emittedEvents[0].event).toBe("discord:message:error");
1924
+ expect(emittedEvents[0].data).toMatchObject({
1925
+ agentName: "test-agent",
1926
+ channelId: "channel1",
1927
+ messageId: "msg1",
1928
+ error: "Execution failed",
1929
+ });
1930
+ });
1931
+ it("emits discord:error event on connector error", async () => {
1932
+ const eventEmitter = new EventEmitter();
1933
+ const emittedEvents = [];
1934
+ // Track emitted events
1935
+ eventEmitter.on("discord:error", (data) => {
1936
+ emittedEvents.push({ event: "discord:error", data });
1937
+ });
1938
+ const mockContext = {
1939
+ getConfig: () => null,
1940
+ getStateDir: () => "/tmp/test-state",
1941
+ getStateDirInfo: () => null,
1942
+ getLogger: () => mockLogger,
1943
+ getScheduler: () => null,
1944
+ getStatus: () => "running",
1945
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1946
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1947
+ getStoppedAt: () => null,
1948
+ getLastError: () => null,
1949
+ getCheckInterval: () => 1000,
1950
+ emit: (event, ...args) => eventEmitter.emit(event, ...args),
1951
+ getEmitter: () => eventEmitter,
1952
+ };
1953
+ const manager = new DiscordManager(mockContext);
1954
+ const mockConnector = new EventEmitter();
1955
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1956
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1957
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1958
+ mockConnector.getState = vi.fn().mockReturnValue({
1959
+ status: "connected",
1960
+ connectedAt: "2024-01-01T00:00:00.000Z",
1961
+ disconnectedAt: null,
1962
+ reconnectAttempts: 0,
1963
+ lastError: null,
1964
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1965
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1966
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1967
+ });
1968
+ mockConnector.agentName = "test-agent";
1969
+ mockConnector.sessionManager = {
1970
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1971
+ };
1972
+ // @ts-expect-error - accessing private property for testing
1973
+ manager.connectors.set("test-agent", mockConnector);
1974
+ // @ts-expect-error - accessing private property for testing
1975
+ manager.initialized = true;
1976
+ await manager.start();
1977
+ // Emit error event from connector
1978
+ const errorEvent = {
1979
+ agentName: "test-agent",
1980
+ error: new Error("Connector error"),
1981
+ };
1982
+ mockConnector.emit("error", errorEvent);
1983
+ // Wait for async processing
1984
+ await new Promise((resolve) => setTimeout(resolve, 10));
1985
+ // Should have emitted the discord:error event
1986
+ expect(emittedEvents.length).toBe(1);
1987
+ expect(emittedEvents[0].event).toBe("discord:error");
1988
+ expect(emittedEvents[0].data).toMatchObject({
1989
+ agentName: "test-agent",
1990
+ error: "Connector error",
1991
+ });
1992
+ });
1993
+ it("handles reply failure when agent not found", async () => {
1994
+ const config = {
1995
+ fleet: { name: "test-fleet" },
1996
+ agents: [], // No agents!
1997
+ configPath: "/test/herdctl.yaml",
1998
+ configDir: "/test",
1999
+ };
2000
+ const mockContext = {
2001
+ getConfig: () => config,
2002
+ getStateDir: () => "/tmp/test-state",
2003
+ getStateDirInfo: () => null,
2004
+ getLogger: () => mockLogger,
2005
+ getScheduler: () => null,
2006
+ getStatus: () => "running",
2007
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2008
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2009
+ getStoppedAt: () => null,
2010
+ getLastError: () => null,
2011
+ getCheckInterval: () => 1000,
2012
+ emit: () => true,
2013
+ getEmitter: () => new EventEmitter(),
2014
+ };
2015
+ const manager = new DiscordManager(mockContext);
2016
+ const mockConnector = new EventEmitter();
2017
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2018
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2019
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2020
+ mockConnector.getState = vi.fn().mockReturnValue({
2021
+ status: "connected",
2022
+ connectedAt: "2024-01-01T00:00:00.000Z",
2023
+ disconnectedAt: null,
2024
+ reconnectAttempts: 0,
2025
+ lastError: null,
2026
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2027
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2028
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2029
+ });
2030
+ mockConnector.agentName = "unknown-agent";
2031
+ mockConnector.sessionManager = {
2032
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2033
+ };
2034
+ // @ts-expect-error - accessing private property for testing
2035
+ manager.connectors.set("unknown-agent", mockConnector);
2036
+ // @ts-expect-error - accessing private property for testing
2037
+ manager.initialized = true;
2038
+ await manager.start();
2039
+ // Reply that fails
2040
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply failed"));
2041
+ const messageEvent = {
2042
+ agentName: "unknown-agent",
2043
+ prompt: "Hello bot!",
2044
+ context: {
2045
+ messages: [],
2046
+ wasMentioned: true,
2047
+ prompt: "Hello bot!",
2048
+ },
2049
+ metadata: {
2050
+ guildId: "guild1",
2051
+ channelId: "channel1",
2052
+ messageId: "msg1",
2053
+ userId: "user1",
2054
+ username: "TestUser",
2055
+ wasMentioned: true,
2056
+ mode: "mention",
2057
+ },
2058
+ reply: replyMock,
2059
+ startTyping: () => () => { },
2060
+ };
2061
+ // Emit the message event
2062
+ mockConnector.emit("message", messageEvent);
2063
+ // Wait for async processing
2064
+ await new Promise((resolve) => setTimeout(resolve, 50));
2065
+ // Should have logged both the agent not found error and the reply failure
2066
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
2067
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
2068
+ });
2069
+ it("sends default response when job produces no output", async () => {
2070
+ const eventEmitter = new EventEmitter();
2071
+ // Trigger that returns but doesn't call onMessage
2072
+ const triggerMock = vi.fn().mockImplementation(async () => {
2073
+ return { jobId: "job-789" };
2074
+ });
2075
+ const emitterWithTrigger = Object.assign(eventEmitter, {
2076
+ trigger: triggerMock,
2077
+ });
2078
+ const config = {
2079
+ fleet: { name: "test-fleet" },
2080
+ agents: [
2081
+ createDiscordAgent("test-agent", {
2082
+ bot_token_env: "TEST_BOT_TOKEN",
2083
+ session_expiry_hours: 24,
2084
+ log_level: "standard",
2085
+ guilds: [],
2086
+ }),
2087
+ ],
2088
+ configPath: "/test/herdctl.yaml",
2089
+ configDir: "/test",
2090
+ };
2091
+ const mockContext = {
2092
+ getConfig: () => config,
2093
+ getStateDir: () => "/tmp/test-state",
2094
+ getStateDirInfo: () => null,
2095
+ getLogger: () => mockLogger,
2096
+ getScheduler: () => null,
2097
+ getStatus: () => "running",
2098
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2099
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2100
+ getStoppedAt: () => null,
2101
+ getLastError: () => null,
2102
+ getCheckInterval: () => 1000,
2103
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
2104
+ getEmitter: () => emitterWithTrigger,
2105
+ };
2106
+ const manager = new DiscordManager(mockContext);
2107
+ const mockConnector = new EventEmitter();
2108
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2109
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2110
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2111
+ mockConnector.getState = vi.fn().mockReturnValue({
2112
+ status: "connected",
2113
+ connectedAt: "2024-01-01T00:00:00.000Z",
2114
+ disconnectedAt: null,
2115
+ reconnectAttempts: 0,
2116
+ lastError: null,
2117
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2118
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2119
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2120
+ });
2121
+ mockConnector.agentName = "test-agent";
2122
+ mockConnector.sessionManager = {
2123
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
2124
+ touchSession: vi.fn().mockResolvedValue(undefined),
2125
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2126
+ };
2127
+ // @ts-expect-error - accessing private property for testing
2128
+ manager.connectors.set("test-agent", mockConnector);
2129
+ // @ts-expect-error - accessing private property for testing
2130
+ manager.initialized = true;
2131
+ await manager.start();
2132
+ // Create a mock message event
2133
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2134
+ const messageEvent = {
2135
+ agentName: "test-agent",
2136
+ prompt: "Hello bot!",
2137
+ context: {
2138
+ messages: [],
2139
+ wasMentioned: true,
2140
+ prompt: "Hello bot!",
2141
+ },
2142
+ metadata: {
2143
+ guildId: "guild1",
2144
+ channelId: "channel1",
2145
+ messageId: "msg1",
2146
+ userId: "user1",
2147
+ username: "TestUser",
2148
+ wasMentioned: true,
2149
+ mode: "mention",
2150
+ },
2151
+ reply: replyMock,
2152
+ startTyping: () => () => { },
2153
+ };
2154
+ // Emit the message event
2155
+ mockConnector.emit("message", messageEvent);
2156
+ // Wait for async processing
2157
+ await new Promise((resolve) => setTimeout(resolve, 50));
2158
+ // Should have sent the default "no output" message
2159
+ expect(replyMock).toHaveBeenCalledWith("I've completed the task, but I don't have a specific response to share.");
2160
+ });
2161
+ });
2162
+ //# sourceMappingURL=discord-manager.test.js.map