@herdctl/discord 0.2.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/__tests__/auto-mode-handler.test.js +1 -28
  2. package/dist/__tests__/auto-mode-handler.test.js.map +1 -1
  3. package/dist/__tests__/discord-connector.test.js +1 -0
  4. package/dist/__tests__/discord-connector.test.js.map +1 -1
  5. package/dist/__tests__/error-handler.test.js +1 -1
  6. package/dist/__tests__/error-handler.test.js.map +1 -1
  7. package/dist/__tests__/manager.test.d.ts +8 -0
  8. package/dist/__tests__/manager.test.d.ts.map +1 -0
  9. package/dist/__tests__/manager.test.js +3541 -0
  10. package/dist/__tests__/manager.test.js.map +1 -0
  11. package/dist/auto-mode-handler.d.ts +13 -44
  12. package/dist/auto-mode-handler.d.ts.map +1 -1
  13. package/dist/auto-mode-handler.js +12 -78
  14. package/dist/auto-mode-handler.js.map +1 -1
  15. package/dist/commands/__tests__/command-manager.test.js +1 -0
  16. package/dist/commands/__tests__/command-manager.test.js.map +1 -1
  17. package/dist/commands/__tests__/help.test.js +1 -0
  18. package/dist/commands/__tests__/help.test.js.map +1 -1
  19. package/dist/commands/__tests__/reset.test.js +1 -0
  20. package/dist/commands/__tests__/reset.test.js.map +1 -1
  21. package/dist/commands/__tests__/status.test.js +1 -0
  22. package/dist/commands/__tests__/status.test.js.map +1 -1
  23. package/dist/commands/command-manager.d.ts.map +1 -1
  24. package/dist/commands/command-manager.js.map +1 -1
  25. package/dist/commands/status.d.ts.map +1 -1
  26. package/dist/commands/status.js +1 -54
  27. package/dist/commands/status.js.map +1 -1
  28. package/dist/commands/types.d.ts +3 -3
  29. package/dist/commands/types.d.ts.map +1 -1
  30. package/dist/discord-connector.d.ts +2 -2
  31. package/dist/discord-connector.d.ts.map +1 -1
  32. package/dist/discord-connector.js.map +1 -1
  33. package/dist/error-handler.js +1 -1
  34. package/dist/error-handler.js.map +1 -1
  35. package/dist/index.d.ts +13 -13
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +15 -15
  38. package/dist/index.js.map +1 -1
  39. package/dist/manager.d.ts +195 -0
  40. package/dist/manager.d.ts.map +1 -0
  41. package/dist/manager.js +851 -0
  42. package/dist/manager.js.map +1 -0
  43. package/dist/types.d.ts +2 -2
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/utils/__tests__/formatting.test.js +1 -247
  46. package/dist/utils/__tests__/formatting.test.js.map +1 -1
  47. package/dist/utils/formatting.d.ts +11 -99
  48. package/dist/utils/formatting.d.ts.map +1 -1
  49. package/dist/utils/formatting.js +15 -163
  50. package/dist/utils/formatting.js.map +1 -1
  51. package/dist/utils/index.d.ts +1 -1
  52. package/dist/utils/index.d.ts.map +1 -1
  53. package/dist/utils/index.js +2 -2
  54. package/dist/utils/index.js.map +1 -1
  55. package/package.json +3 -4
  56. package/dist/session-manager/__tests__/errors.test.d.ts +0 -2
  57. package/dist/session-manager/__tests__/errors.test.d.ts.map +0 -1
  58. package/dist/session-manager/__tests__/errors.test.js +0 -124
  59. package/dist/session-manager/__tests__/errors.test.js.map +0 -1
  60. package/dist/session-manager/__tests__/session-manager.test.d.ts +0 -2
  61. package/dist/session-manager/__tests__/session-manager.test.d.ts.map +0 -1
  62. package/dist/session-manager/__tests__/session-manager.test.js +0 -573
  63. package/dist/session-manager/__tests__/session-manager.test.js.map +0 -1
  64. package/dist/session-manager/__tests__/types.test.d.ts +0 -2
  65. package/dist/session-manager/__tests__/types.test.d.ts.map +0 -1
  66. package/dist/session-manager/__tests__/types.test.js +0 -169
  67. package/dist/session-manager/__tests__/types.test.js.map +0 -1
  68. package/dist/session-manager/errors.d.ts +0 -58
  69. package/dist/session-manager/errors.d.ts.map +0 -1
  70. package/dist/session-manager/errors.js +0 -70
  71. package/dist/session-manager/errors.js.map +0 -1
  72. package/dist/session-manager/index.d.ts +0 -11
  73. package/dist/session-manager/index.d.ts.map +0 -1
  74. package/dist/session-manager/index.js +0 -12
  75. package/dist/session-manager/index.js.map +0 -1
  76. package/dist/session-manager/session-manager.d.ts +0 -119
  77. package/dist/session-manager/session-manager.d.ts.map +0 -1
  78. package/dist/session-manager/session-manager.js +0 -383
  79. package/dist/session-manager/session-manager.js.map +0 -1
  80. package/dist/session-manager/types.d.ts +0 -186
  81. package/dist/session-manager/types.d.ts.map +0 -1
  82. package/dist/session-manager/types.js +0 -57
  83. package/dist/session-manager/types.js.map +0 -1
@@ -0,0 +1,3541 @@
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 "../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
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
36
+ };
37
+ }
38
+ // Create a mock agent with Discord config
39
+ function createDiscordAgent(name, discordConfig) {
40
+ return {
41
+ name,
42
+ model: "sonnet",
43
+ runtime: "sdk", // Explicitly set runtime for session validation
44
+ schedules: {},
45
+ chat: {
46
+ discord: discordConfig,
47
+ },
48
+ configPath: "/test/herdctl.yaml",
49
+ };
50
+ }
51
+ // Create a mock agent without Discord config
52
+ function createNonDiscordAgent(name) {
53
+ return {
54
+ name,
55
+ model: "sonnet",
56
+ schedules: {},
57
+ configPath: "/test/herdctl.yaml",
58
+ };
59
+ }
60
+ describe("DiscordManager", () => {
61
+ beforeEach(() => {
62
+ vi.clearAllMocks();
63
+ });
64
+ afterEach(() => {
65
+ vi.restoreAllMocks();
66
+ });
67
+ describe("constructor", () => {
68
+ it("creates instance with context", () => {
69
+ const ctx = createMockContext();
70
+ const manager = new DiscordManager(ctx);
71
+ expect(manager).toBeDefined();
72
+ });
73
+ });
74
+ describe("initialize", () => {
75
+ it("skips initialization when no config is available", async () => {
76
+ const ctx = createMockContext(null);
77
+ const manager = new DiscordManager(ctx);
78
+ await manager.initialize();
79
+ expect(mockLogger.debug).toHaveBeenCalledWith("No config available, skipping Discord initialization");
80
+ expect(manager.getConnectorNames()).toEqual([]);
81
+ });
82
+ it("skips initialization when no agents have Discord configured", async () => {
83
+ const config = {
84
+ fleet: { name: "test-fleet" },
85
+ agents: [
86
+ createNonDiscordAgent("agent1"),
87
+ createNonDiscordAgent("agent2"),
88
+ ],
89
+ configPath: "/test/herdctl.yaml",
90
+ configDir: "/test",
91
+ };
92
+ const ctx = createMockContext(config);
93
+ const manager = new DiscordManager(ctx);
94
+ // Mock the dynamic import to return null (package not installed)
95
+ vi.doMock("@herdctl/discord", () => {
96
+ throw new Error("Package not found");
97
+ });
98
+ await manager.initialize();
99
+ // Should either say "not installed" or "No agents with Discord configured"
100
+ const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
101
+ expect(debugCalls.some((msg) => msg.includes("not installed") ||
102
+ msg.includes("No agents with Discord configured"))).toBe(true);
103
+ });
104
+ it("is idempotent - multiple calls only initialize once", async () => {
105
+ const config = {
106
+ fleet: { name: "test-fleet" },
107
+ agents: [createNonDiscordAgent("agent1")],
108
+ configPath: "/test/herdctl.yaml",
109
+ configDir: "/test",
110
+ };
111
+ const ctx = createMockContext(config);
112
+ const manager = new DiscordManager(ctx);
113
+ await manager.initialize();
114
+ await manager.initialize();
115
+ // The second call should return early without doing anything
116
+ // We can verify by checking the debug logs
117
+ const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
118
+ // First init will log something, second call should not add more logs
119
+ // about initialization because it returns early
120
+ });
121
+ it("warns when bot token environment variable is not set", async () => {
122
+ const discordConfig = {
123
+ bot_token_env: "NONEXISTENT_BOT_TOKEN_VAR",
124
+ session_expiry_hours: 24,
125
+ log_level: "standard",
126
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
127
+ guilds: [],
128
+ };
129
+ const config = {
130
+ fleet: { name: "test-fleet" },
131
+ agents: [createDiscordAgent("agent1", discordConfig)],
132
+ configPath: "/test/herdctl.yaml",
133
+ configDir: "/test",
134
+ };
135
+ const ctx = createMockContext(config);
136
+ const manager = new DiscordManager(ctx);
137
+ // Clear the env var if it exists
138
+ const originalValue = process.env["NONEXISTENT_BOT_TOKEN_VAR"];
139
+ delete process.env["NONEXISTENT_BOT_TOKEN_VAR"];
140
+ await manager.initialize();
141
+ // Restore if it existed
142
+ if (originalValue !== undefined) {
143
+ process.env["NONEXISTENT_BOT_TOKEN_VAR"] = originalValue;
144
+ }
145
+ // The warning should only be logged if the discord package is available
146
+ // If the package is not available, it will log "not installed" first
147
+ const warnCalls = mockLogger.warn.mock.calls;
148
+ const debugCalls = mockLogger.debug.mock.calls;
149
+ // Either the package is not installed (debug log) or the token is missing (warn log)
150
+ const packageNotInstalled = debugCalls.some((call) => call[0].includes("not installed"));
151
+ const tokenMissing = warnCalls.some((call) => call[0].includes("bot token not found"));
152
+ expect(packageNotInstalled || tokenMissing || warnCalls.length === 0).toBe(true);
153
+ });
154
+ });
155
+ describe("start", () => {
156
+ it("logs when no connectors to start", async () => {
157
+ const ctx = createMockContext(null);
158
+ const manager = new DiscordManager(ctx);
159
+ await manager.initialize();
160
+ await manager.start();
161
+ expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to start");
162
+ });
163
+ });
164
+ describe("stop", () => {
165
+ it("logs when no connectors to stop", async () => {
166
+ const ctx = createMockContext(null);
167
+ const manager = new DiscordManager(ctx);
168
+ await manager.initialize();
169
+ await manager.stop();
170
+ expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to stop");
171
+ });
172
+ });
173
+ describe("getConnector", () => {
174
+ it("returns undefined for non-existent agent", () => {
175
+ const ctx = createMockContext(null);
176
+ const manager = new DiscordManager(ctx);
177
+ const connector = manager.getConnector("nonexistent");
178
+ expect(connector).toBeUndefined();
179
+ });
180
+ });
181
+ describe("getConnectorNames", () => {
182
+ it("returns empty array when no connectors", () => {
183
+ const ctx = createMockContext(null);
184
+ const manager = new DiscordManager(ctx);
185
+ expect(manager.getConnectorNames()).toEqual([]);
186
+ });
187
+ });
188
+ describe("getConnectedCount", () => {
189
+ it("returns 0 when no connectors", () => {
190
+ const ctx = createMockContext(null);
191
+ const manager = new DiscordManager(ctx);
192
+ expect(manager.getConnectedCount()).toBe(0);
193
+ });
194
+ });
195
+ describe("hasConnector", () => {
196
+ it("returns false for non-existent agent", () => {
197
+ const ctx = createMockContext(null);
198
+ const manager = new DiscordManager(ctx);
199
+ expect(manager.hasConnector("nonexistent")).toBe(false);
200
+ });
201
+ });
202
+ });
203
+ describe("DiscordConnectorState type", () => {
204
+ it("defines proper connector state structure", () => {
205
+ // This test verifies the type is exported correctly
206
+ const state = {
207
+ status: "disconnected",
208
+ connectedAt: null,
209
+ disconnectedAt: null,
210
+ reconnectAttempts: 0,
211
+ lastError: null,
212
+ botUser: null,
213
+ rateLimits: {
214
+ totalCount: 0,
215
+ lastRateLimitAt: null,
216
+ isRateLimited: false,
217
+ currentResetTime: 0,
218
+ },
219
+ messageStats: {
220
+ received: 0,
221
+ sent: 0,
222
+ ignored: 0,
223
+ },
224
+ };
225
+ expect(state.status).toBe("disconnected");
226
+ expect(state.botUser).toBeNull();
227
+ expect(state.rateLimits.isRateLimited).toBe(false);
228
+ expect(state.messageStats.received).toBe(0);
229
+ });
230
+ it("supports all connection status values", () => {
231
+ const statuses = [
232
+ "disconnected",
233
+ "connecting",
234
+ "connected",
235
+ "reconnecting",
236
+ "disconnecting",
237
+ "error",
238
+ ];
239
+ statuses.forEach((status) => {
240
+ const state = {
241
+ status,
242
+ connectedAt: null,
243
+ disconnectedAt: null,
244
+ reconnectAttempts: 0,
245
+ lastError: null,
246
+ botUser: null,
247
+ rateLimits: {
248
+ totalCount: 0,
249
+ lastRateLimitAt: null,
250
+ isRateLimited: false,
251
+ currentResetTime: 0,
252
+ },
253
+ messageStats: {
254
+ received: 0,
255
+ sent: 0,
256
+ ignored: 0,
257
+ },
258
+ };
259
+ expect(state.status).toBe(status);
260
+ });
261
+ });
262
+ it("supports connected state with bot user", () => {
263
+ const state = {
264
+ status: "connected",
265
+ connectedAt: "2024-01-01T00:00:00.000Z",
266
+ disconnectedAt: null,
267
+ reconnectAttempts: 0,
268
+ lastError: null,
269
+ botUser: {
270
+ id: "123456789",
271
+ username: "TestBot",
272
+ discriminator: "1234",
273
+ },
274
+ rateLimits: {
275
+ totalCount: 5,
276
+ lastRateLimitAt: "2024-01-01T00:01:00.000Z",
277
+ isRateLimited: false,
278
+ currentResetTime: 0,
279
+ },
280
+ messageStats: {
281
+ received: 100,
282
+ sent: 50,
283
+ ignored: 25,
284
+ },
285
+ };
286
+ expect(state.status).toBe("connected");
287
+ expect(state.botUser?.username).toBe("TestBot");
288
+ expect(state.messageStats.received).toBe(100);
289
+ });
290
+ });
291
+ describe("DiscordMessageEvent type", () => {
292
+ it("defines proper message event structure", () => {
293
+ const event = {
294
+ agentName: "test-agent",
295
+ prompt: "Hello, how are you?",
296
+ context: {
297
+ messages: [
298
+ {
299
+ authorId: "user123",
300
+ authorName: "TestUser",
301
+ content: "Hello!",
302
+ isBot: false,
303
+ isSelf: false,
304
+ timestamp: "2024-01-01T00:00:00.000Z",
305
+ messageId: "msg001",
306
+ },
307
+ ],
308
+ wasMentioned: true,
309
+ prompt: "Hello, how are you?",
310
+ },
311
+ metadata: {
312
+ guildId: "guild123",
313
+ channelId: "channel456",
314
+ messageId: "msg789",
315
+ userId: "user123",
316
+ username: "TestUser",
317
+ wasMentioned: true,
318
+ mode: "mention",
319
+ },
320
+ reply: async (content) => {
321
+ console.log("Reply:", content);
322
+ },
323
+ startTyping: () => () => { },
324
+ };
325
+ expect(event.agentName).toBe("test-agent");
326
+ expect(event.prompt).toBe("Hello, how are you?");
327
+ expect(event.metadata.guildId).toBe("guild123");
328
+ expect(event.context.wasMentioned).toBe(true);
329
+ });
330
+ it("supports DM context (null guildId)", () => {
331
+ const event = {
332
+ agentName: "dm-agent",
333
+ prompt: "Private message",
334
+ context: {
335
+ messages: [],
336
+ wasMentioned: false,
337
+ prompt: "Private message",
338
+ },
339
+ metadata: {
340
+ guildId: null,
341
+ channelId: "dm-channel",
342
+ messageId: "dm-msg",
343
+ userId: "user1",
344
+ username: "DMUser",
345
+ wasMentioned: false,
346
+ mode: "auto",
347
+ },
348
+ reply: async () => { },
349
+ startTyping: () => () => { },
350
+ };
351
+ expect(event.metadata.guildId).toBeNull();
352
+ expect(event.metadata.mode).toBe("auto");
353
+ });
354
+ });
355
+ describe("DiscordErrorEvent type", () => {
356
+ it("defines proper error event structure", () => {
357
+ const event = {
358
+ agentName: "test-agent",
359
+ error: new Error("Connection failed"),
360
+ };
361
+ expect(event.agentName).toBe("test-agent");
362
+ expect(event.error.message).toBe("Connection failed");
363
+ });
364
+ });
365
+ // Message splitting behavior is now tested in @herdctl/chat package (message-splitting.test.ts)
366
+ // These tests are skipped since DiscordManager now delegates to the shared utility
367
+ describe.skip("DiscordManager response splitting", () => {
368
+ let manager;
369
+ beforeEach(() => {
370
+ vi.clearAllMocks();
371
+ const ctx = createMockContext(null);
372
+ manager = new DiscordManager(ctx);
373
+ });
374
+ describe("splitResponse", () => {
375
+ it("returns text as-is when under 2000 characters", () => {
376
+ const text = "Hello, this is a short message.";
377
+ const result = manager.splitResponse(text);
378
+ expect(result).toEqual([text]);
379
+ });
380
+ it("returns text as-is when exactly 2000 characters", () => {
381
+ const text = "a".repeat(2000);
382
+ const result = manager.splitResponse(text);
383
+ expect(result).toEqual([text]);
384
+ });
385
+ it("splits text at natural boundaries (newlines)", () => {
386
+ // Create text that's over 2000 chars with newlines
387
+ const line = "This is a line of text.\n";
388
+ const text = line.repeat(100); // About 2400 chars
389
+ const result = manager.splitResponse(text);
390
+ expect(result.length).toBeGreaterThan(1);
391
+ // Each chunk should be under 2000 chars
392
+ result.forEach((chunk) => {
393
+ expect(chunk.length).toBeLessThanOrEqual(2000);
394
+ });
395
+ // Chunks should join back to original
396
+ expect(result.join("")).toBe(text);
397
+ });
398
+ it("splits text at spaces when no newlines available", () => {
399
+ // Create text that's over 2000 chars with spaces but no newlines
400
+ const words = "word ".repeat(500); // About 2500 chars
401
+ const result = manager.splitResponse(words);
402
+ expect(result.length).toBeGreaterThan(1);
403
+ result.forEach((chunk) => {
404
+ expect(chunk.length).toBeLessThanOrEqual(2000);
405
+ });
406
+ });
407
+ it("handles text with no natural break points", () => {
408
+ const text = "a".repeat(3000); // No spaces or newlines
409
+ const result = manager.splitResponse(text);
410
+ expect(result.length).toBe(2);
411
+ expect(result[0].length).toBe(2000);
412
+ expect(result[1].length).toBe(1000);
413
+ });
414
+ it("preserves code blocks when splitting", () => {
415
+ // Create a code block that spans beyond 2000 chars
416
+ const codeBlock = "```typescript\n" + "const x = 1;\n".repeat(200) + "```";
417
+ const result = manager.splitResponse(codeBlock);
418
+ expect(result.length).toBeGreaterThan(1);
419
+ // First chunk should close the code block
420
+ expect(result[0]).toMatch(/```$/);
421
+ // Second chunk should reopen with the same language
422
+ expect(result[1]).toMatch(/^```typescript/);
423
+ });
424
+ it("preserves code blocks with no language specified", () => {
425
+ const codeBlock = "```\n" + "line of code\n".repeat(200) + "```";
426
+ const result = manager.splitResponse(codeBlock);
427
+ expect(result.length).toBeGreaterThan(1);
428
+ // First chunk should close the code block
429
+ expect(result[0]).toMatch(/```$/);
430
+ // Second chunk should reopen (possibly with empty language)
431
+ expect(result[1]).toMatch(/^```/);
432
+ });
433
+ it("handles multiple code blocks", () => {
434
+ const text = "Some text\n```js\nconsole.log('hello');\n```\nMore text\n```python\nprint('hello')\n```";
435
+ const result = manager.splitResponse(text);
436
+ // This should fit in one message
437
+ expect(result).toEqual([text]);
438
+ });
439
+ it("handles empty string", () => {
440
+ const result = manager.splitResponse("");
441
+ expect(result).toEqual([""]);
442
+ });
443
+ it("prefers paragraph breaks over line breaks", () => {
444
+ // Create text with both paragraph and line breaks
445
+ const paragraph1 = "First paragraph. ".repeat(50) + "\n\n";
446
+ const paragraph2 = "Second paragraph. ".repeat(50);
447
+ const text = paragraph1 + paragraph2;
448
+ if (text.length > 2000) {
449
+ const result = manager.splitResponse(text);
450
+ // Should split at the paragraph break
451
+ expect(result[0]).toMatch(/\n\n$/);
452
+ }
453
+ });
454
+ it("handles code block that opens and closes within split region", () => {
455
+ // Create text where a code block opens and then closes before split point
456
+ // This tests the code path where insideBlock becomes false after closing
457
+ const text = "Some intro text\n```js\nconst x = 1;\n```\nMore text here " + "padding ".repeat(250);
458
+ const result = manager.splitResponse(text);
459
+ expect(result.length).toBeGreaterThanOrEqual(1);
460
+ // Should not break inside code block since it's closed
461
+ result.forEach((chunk) => {
462
+ expect(chunk.length).toBeLessThanOrEqual(2000);
463
+ });
464
+ });
465
+ it("handles code block analysis when initially inside but closes on re-analysis", () => {
466
+ // Create text where initial analysis shows inside block at 2000 chars,
467
+ // but when we find a natural break and re-analyze, the block is closed
468
+ // This exercises the code path at line 727 where actualState.insideBlock is false
469
+ const codeBlock = "```js\nshort code\n```";
470
+ const paddingToReachSplit = "x".repeat(1900 - codeBlock.length);
471
+ const moreContent = " ".repeat(50) + "y".repeat(200); // Add space for split and more content
472
+ const text = codeBlock + paddingToReachSplit + moreContent;
473
+ const result = manager.splitResponse(text);
474
+ expect(result.length).toBeGreaterThanOrEqual(1);
475
+ result.forEach((chunk) => {
476
+ expect(chunk.length).toBeLessThanOrEqual(2000);
477
+ });
478
+ });
479
+ it("handles multiple code blocks opening and closing", () => {
480
+ // Multiple code blocks that open and close
481
+ const text = "```js\ncode1\n```\n" + "text ".repeat(100) + "\n```py\ncode2\n```\n" + "more ".repeat(200);
482
+ const result = manager.splitResponse(text);
483
+ expect(result.length).toBeGreaterThanOrEqual(1);
484
+ result.forEach((chunk) => {
485
+ expect(chunk.length).toBeLessThanOrEqual(2000);
486
+ });
487
+ });
488
+ it("splits at paragraph break when within 500 chars of split point", () => {
489
+ // Create text where paragraph break is close enough to the split point to be used
490
+ // Need text > 2000 chars with a paragraph break in the last 500 chars before 2000
491
+ const part1 = "a".repeat(1600);
492
+ const part2 = "\n\n"; // paragraph break
493
+ const part3 = "b".repeat(600); // Pushes us over 2000
494
+ const text = part1 + part2 + part3;
495
+ const result = manager.splitResponse(text);
496
+ expect(result.length).toBe(2);
497
+ // First chunk should end at paragraph break
498
+ expect(result[0]).toBe(part1 + part2);
499
+ expect(result[1]).toBe(part3);
500
+ });
501
+ it("falls back to newline when paragraph break is too far from split point", () => {
502
+ // Create text where paragraph break is too far but newline is close
503
+ const part1 = "a".repeat(1000);
504
+ const part2 = "\n\n"; // paragraph break too early
505
+ const part3 = "b".repeat(800);
506
+ const part4 = "\n"; // newline close to split point
507
+ const part5 = "c".repeat(400);
508
+ const text = part1 + part2 + part3 + part4 + part5;
509
+ const result = manager.splitResponse(text);
510
+ expect(result.length).toBeGreaterThanOrEqual(1);
511
+ result.forEach((chunk) => {
512
+ expect(chunk.length).toBeLessThanOrEqual(2000);
513
+ });
514
+ });
515
+ it("handles text just slightly over 2000 chars", () => {
516
+ const text = "a".repeat(2001);
517
+ const result = manager.splitResponse(text);
518
+ expect(result.length).toBe(2);
519
+ expect(result[0].length).toBe(2000);
520
+ expect(result[1].length).toBe(1);
521
+ });
522
+ });
523
+ describe("formatErrorMessage", () => {
524
+ it("formats error with message and guidance", () => {
525
+ const error = new Error("Something went wrong");
526
+ const result = manager.formatErrorMessage(error);
527
+ expect(result).toContain("❌ **Error**:");
528
+ expect(result).toContain("Something went wrong");
529
+ expect(result).toContain("/reset");
530
+ expect(result).toContain("Please try again");
531
+ });
532
+ it("handles errors with special characters", () => {
533
+ const error = new Error("Error with `code` and *markdown*");
534
+ const result = manager.formatErrorMessage(error);
535
+ expect(result).toContain("Error with `code` and *markdown*");
536
+ });
537
+ });
538
+ describe("sendResponse", () => {
539
+ it("sends single message for short content", async () => {
540
+ const replyMock = vi.fn().mockResolvedValue(undefined);
541
+ await manager.sendResponse(replyMock, "Short message");
542
+ expect(replyMock).toHaveBeenCalledTimes(1);
543
+ expect(replyMock).toHaveBeenCalledWith("Short message");
544
+ });
545
+ it("sends multiple messages for long content", async () => {
546
+ const replyMock = vi.fn().mockResolvedValue(undefined);
547
+ const longText = "word ".repeat(500); // About 2500 chars
548
+ await manager.sendResponse(replyMock, longText);
549
+ expect(replyMock).toHaveBeenCalledTimes(2);
550
+ });
551
+ it("sends messages in order", async () => {
552
+ const calls = [];
553
+ const replyMock = vi.fn().mockImplementation(async (content) => {
554
+ calls.push(content);
555
+ });
556
+ const text = "First part.\n" + "x".repeat(2000) + "\nLast part.";
557
+ await manager.sendResponse(replyMock, text);
558
+ // Verify order by checking first call starts with "First"
559
+ expect(calls[0]).toMatch(/^First/);
560
+ });
561
+ });
562
+ });
563
+ // Message handling tests are skipped pending refactor to work with the new architecture
564
+ // The new DiscordManager uses this.ctx.trigger() directly instead of emitter.trigger
565
+ // and delegates message extraction/splitting to @herdctl/chat
566
+ describe.skip("DiscordManager message handling", () => {
567
+ let manager;
568
+ let mockContext;
569
+ let triggerMock;
570
+ let emitterWithTrigger;
571
+ beforeEach(() => {
572
+ vi.clearAllMocks();
573
+ // Create a mock FleetManager (emitter) with trigger method
574
+ triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123" });
575
+ emitterWithTrigger = Object.assign(new EventEmitter(), {
576
+ trigger: triggerMock,
577
+ });
578
+ const config = {
579
+ fleet: { name: "test-fleet" },
580
+ agents: [
581
+ createDiscordAgent("test-agent", {
582
+ bot_token_env: "TEST_BOT_TOKEN",
583
+ session_expiry_hours: 24,
584
+ log_level: "standard",
585
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
586
+ guilds: [],
587
+ }),
588
+ ],
589
+ configPath: "/test/herdctl.yaml",
590
+ configDir: "/test",
591
+ };
592
+ mockContext = {
593
+ getConfig: () => config,
594
+ getStateDir: () => "/tmp/test-state",
595
+ getStateDirInfo: () => null,
596
+ getLogger: () => mockLogger,
597
+ getScheduler: () => null,
598
+ getStatus: () => "running",
599
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
600
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
601
+ getStoppedAt: () => null,
602
+ getLastError: () => null,
603
+ getCheckInterval: () => 1000,
604
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
605
+ getEmitter: () => emitterWithTrigger,
606
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
607
+ };
608
+ manager = new DiscordManager(mockContext);
609
+ });
610
+ describe("start with mock connector", () => {
611
+ it("subscribes to connector events when starting", async () => {
612
+ // Create a mock connector that supports event handling
613
+ const mockConnector = new EventEmitter();
614
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
615
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
616
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
617
+ mockConnector.getState = vi.fn().mockReturnValue({
618
+ status: "connected",
619
+ connectedAt: "2024-01-01T00:00:00.000Z",
620
+ disconnectedAt: null,
621
+ reconnectAttempts: 0,
622
+ lastError: null,
623
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
624
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
625
+ messageStats: { received: 0, sent: 0, ignored: 0 },
626
+ });
627
+ mockConnector.agentName = "test-agent";
628
+ // Access private connectors map to inject mock
629
+ // @ts-expect-error - accessing private property for testing
630
+ manager.connectors.set("test-agent", mockConnector);
631
+ // @ts-expect-error - accessing private property for testing
632
+ manager.initialized = true;
633
+ await manager.start();
634
+ expect(mockConnector.connect).toHaveBeenCalled();
635
+ // Verify event listeners were attached
636
+ expect(mockConnector.listenerCount("message")).toBeGreaterThan(0);
637
+ expect(mockConnector.listenerCount("error")).toBeGreaterThan(0);
638
+ });
639
+ it("handles message events from connector", async () => {
640
+ // Create a mock connector
641
+ const mockConnector = new EventEmitter();
642
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
643
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
644
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
645
+ mockConnector.getState = vi.fn().mockReturnValue({
646
+ status: "connected",
647
+ connectedAt: "2024-01-01T00:00:00.000Z",
648
+ disconnectedAt: null,
649
+ reconnectAttempts: 0,
650
+ lastError: null,
651
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
652
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
653
+ messageStats: { received: 0, sent: 0, ignored: 0 },
654
+ });
655
+ mockConnector.agentName = "test-agent";
656
+ // @ts-expect-error - accessing private property for testing
657
+ manager.connectors.set("test-agent", mockConnector);
658
+ // @ts-expect-error - accessing private property for testing
659
+ manager.initialized = true;
660
+ await manager.start();
661
+ // Create a mock message event
662
+ const replyMock = vi.fn().mockResolvedValue(undefined);
663
+ const messageEvent = {
664
+ agentName: "test-agent",
665
+ prompt: "Hello bot!",
666
+ context: {
667
+ messages: [],
668
+ wasMentioned: true,
669
+ prompt: "Hello bot!",
670
+ },
671
+ metadata: {
672
+ guildId: "guild1",
673
+ channelId: "channel1",
674
+ messageId: "msg1",
675
+ userId: "user1",
676
+ username: "TestUser",
677
+ wasMentioned: true,
678
+ mode: "mention",
679
+ },
680
+ reply: replyMock,
681
+ startTyping: () => () => { },
682
+ };
683
+ // Emit the message event
684
+ mockConnector.emit("message", messageEvent);
685
+ // Wait for async processing
686
+ await new Promise((resolve) => setTimeout(resolve, 50));
687
+ // Should have called trigger
688
+ expect(triggerMock).toHaveBeenCalledWith("test-agent", undefined, expect.objectContaining({
689
+ prompt: "Hello bot!",
690
+ }));
691
+ });
692
+ it("streams each assistant message immediately via onMessage callback", async () => {
693
+ // Create trigger mock that invokes onMessage callback with streaming content
694
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
695
+ // Simulate streaming messages from the agent - each is sent immediately
696
+ if (options?.onMessage) {
697
+ await options.onMessage({ type: "assistant", content: "Hello! " });
698
+ await options.onMessage({ type: "assistant", content: "How can I help you today?" });
699
+ // Non-assistant message should be ignored
700
+ await options.onMessage({ type: "system", content: "System message" });
701
+ }
702
+ return { jobId: "streaming-job-123" };
703
+ });
704
+ const streamingEmitter = Object.assign(new EventEmitter(), {
705
+ trigger: customTriggerMock,
706
+ });
707
+ const streamingConfig = {
708
+ fleet: { name: "test-fleet" },
709
+ agents: [
710
+ createDiscordAgent("streaming-agent", {
711
+ bot_token_env: "TEST_BOT_TOKEN",
712
+ session_expiry_hours: 24,
713
+ log_level: "standard",
714
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
715
+ guilds: [],
716
+ }),
717
+ ],
718
+ configPath: "/test/herdctl.yaml",
719
+ configDir: "/test",
720
+ };
721
+ const streamingContext = {
722
+ getConfig: () => streamingConfig,
723
+ getStateDir: () => "/tmp/test-state",
724
+ getStateDirInfo: () => null,
725
+ getLogger: () => mockLogger,
726
+ getScheduler: () => null,
727
+ getStatus: () => "running",
728
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
729
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
730
+ getStoppedAt: () => null,
731
+ getLastError: () => null,
732
+ getCheckInterval: () => 1000,
733
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
734
+ getEmitter: () => streamingEmitter,
735
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
736
+ };
737
+ const streamingManager = new DiscordManager(streamingContext);
738
+ const mockConnector = new EventEmitter();
739
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
740
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
741
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
742
+ mockConnector.getState = vi.fn().mockReturnValue({
743
+ status: "connected",
744
+ connectedAt: "2024-01-01T00:00:00.000Z",
745
+ disconnectedAt: null,
746
+ reconnectAttempts: 0,
747
+ lastError: null,
748
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
749
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
750
+ messageStats: { received: 0, sent: 0, ignored: 0 },
751
+ });
752
+ mockConnector.agentName = "streaming-agent";
753
+ mockConnector.sessionManager = {
754
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
755
+ getSession: vi.fn().mockResolvedValue({ sessionId: "s1", lastMessageAt: new Date().toISOString() }),
756
+ setSession: vi.fn().mockResolvedValue(undefined),
757
+ touchSession: vi.fn().mockResolvedValue(undefined),
758
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
759
+ };
760
+ // @ts-expect-error - accessing private property for testing
761
+ streamingManager.connectors.set("streaming-agent", mockConnector);
762
+ // @ts-expect-error - accessing private property for testing
763
+ streamingManager.initialized = true;
764
+ await streamingManager.start();
765
+ // Create a mock message event
766
+ const replyMock = vi.fn().mockResolvedValue(undefined);
767
+ const messageEvent = {
768
+ agentName: "streaming-agent",
769
+ prompt: "Hello bot!",
770
+ context: {
771
+ messages: [],
772
+ wasMentioned: true,
773
+ prompt: "Hello bot!",
774
+ },
775
+ metadata: {
776
+ guildId: "guild1",
777
+ channelId: "channel1",
778
+ messageId: "msg1",
779
+ userId: "user1",
780
+ username: "TestUser",
781
+ wasMentioned: true,
782
+ mode: "mention",
783
+ },
784
+ reply: replyMock,
785
+ startTyping: () => () => { },
786
+ };
787
+ // Emit the message event
788
+ mockConnector.emit("message", messageEvent);
789
+ // Wait for async processing (includes rate limiting delays between messages)
790
+ await new Promise((resolve) => setTimeout(resolve, 2500));
791
+ // Should have sent each message immediately (streaming behavior)
792
+ expect(replyMock).toHaveBeenCalledTimes(2);
793
+ expect(replyMock).toHaveBeenNthCalledWith(1, "Hello!");
794
+ expect(replyMock).toHaveBeenNthCalledWith(2, "How can I help you today?");
795
+ });
796
+ it("sends long streaming response with splitResponse", async () => {
797
+ // Create trigger mock that produces a long response
798
+ const longResponse = "This is a very long response. ".repeat(100); // About 3100 chars
799
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
800
+ if (options?.onMessage) {
801
+ await options.onMessage({ type: "assistant", content: longResponse });
802
+ }
803
+ return { jobId: "long-job-123" };
804
+ });
805
+ const streamingEmitter = Object.assign(new EventEmitter(), {
806
+ trigger: customTriggerMock,
807
+ });
808
+ const streamingConfig = {
809
+ fleet: { name: "test-fleet" },
810
+ agents: [
811
+ createDiscordAgent("long-agent", {
812
+ bot_token_env: "TEST_BOT_TOKEN",
813
+ session_expiry_hours: 24,
814
+ log_level: "standard",
815
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
816
+ guilds: [],
817
+ }),
818
+ ],
819
+ configPath: "/test/herdctl.yaml",
820
+ configDir: "/test",
821
+ };
822
+ const streamingContext = {
823
+ getConfig: () => streamingConfig,
824
+ getStateDir: () => "/tmp/test-state",
825
+ getStateDirInfo: () => null,
826
+ getLogger: () => mockLogger,
827
+ getScheduler: () => null,
828
+ getStatus: () => "running",
829
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
830
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
831
+ getStoppedAt: () => null,
832
+ getLastError: () => null,
833
+ getCheckInterval: () => 1000,
834
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
835
+ getEmitter: () => streamingEmitter,
836
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
837
+ };
838
+ const streamingManager = new DiscordManager(streamingContext);
839
+ const mockConnector = new EventEmitter();
840
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
841
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
842
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
843
+ mockConnector.getState = vi.fn().mockReturnValue({
844
+ status: "connected",
845
+ connectedAt: "2024-01-01T00:00:00.000Z",
846
+ disconnectedAt: null,
847
+ reconnectAttempts: 0,
848
+ lastError: null,
849
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
850
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
851
+ messageStats: { received: 0, sent: 0, ignored: 0 },
852
+ });
853
+ mockConnector.agentName = "long-agent";
854
+ mockConnector.sessionManager = {
855
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
856
+ getSession: vi.fn().mockResolvedValue({ sessionId: "s1", lastMessageAt: new Date().toISOString() }),
857
+ setSession: vi.fn().mockResolvedValue(undefined),
858
+ touchSession: vi.fn().mockResolvedValue(undefined),
859
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
860
+ };
861
+ // @ts-expect-error - accessing private property for testing
862
+ streamingManager.connectors.set("long-agent", mockConnector);
863
+ // @ts-expect-error - accessing private property for testing
864
+ streamingManager.initialized = true;
865
+ await streamingManager.start();
866
+ // Create a mock message event
867
+ const replyMock = vi.fn().mockResolvedValue(undefined);
868
+ const messageEvent = {
869
+ agentName: "long-agent",
870
+ prompt: "Hello bot!",
871
+ context: {
872
+ messages: [],
873
+ wasMentioned: true,
874
+ prompt: "Hello bot!",
875
+ },
876
+ metadata: {
877
+ guildId: "guild1",
878
+ channelId: "channel1",
879
+ messageId: "msg1",
880
+ userId: "user1",
881
+ username: "TestUser",
882
+ wasMentioned: true,
883
+ mode: "mention",
884
+ },
885
+ reply: replyMock,
886
+ startTyping: () => () => { },
887
+ };
888
+ // Emit the message event
889
+ mockConnector.emit("message", messageEvent);
890
+ // Wait for async processing (includes delay between split chunks)
891
+ await new Promise((resolve) => setTimeout(resolve, 1500));
892
+ // Should have sent multiple messages (split response)
893
+ expect(replyMock).toHaveBeenCalledTimes(2);
894
+ });
895
+ it("streams tool results from user messages to Discord", async () => {
896
+ // Create trigger mock that sends assistant message with tool_use, then user message with tool_result
897
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
898
+ if (options?.onMessage) {
899
+ // Claude decides to use Bash tool
900
+ await options.onMessage({
901
+ type: "assistant",
902
+ message: {
903
+ content: [
904
+ { type: "text", text: "Let me check that for you." },
905
+ { type: "tool_use", name: "Bash", id: "tool-1", input: { command: "ls -la /tmp" } },
906
+ ],
907
+ },
908
+ });
909
+ // Tool result comes back as a user message
910
+ await options.onMessage({
911
+ type: "user",
912
+ message: {
913
+ content: [
914
+ {
915
+ type: "tool_result",
916
+ tool_use_id: "tool-1",
917
+ content: "total 48\ndrwxr-xr-x 5 user staff 160 Jan 20 10:00 .",
918
+ },
919
+ ],
920
+ },
921
+ });
922
+ // Claude sends final response
923
+ await options.onMessage({
924
+ type: "assistant",
925
+ message: {
926
+ content: [
927
+ { type: "text", text: "Here are the files in /tmp." },
928
+ ],
929
+ },
930
+ });
931
+ }
932
+ return { jobId: "tool-job-123", success: true };
933
+ });
934
+ const streamingEmitter = Object.assign(new EventEmitter(), {
935
+ trigger: customTriggerMock,
936
+ });
937
+ const streamingConfig = {
938
+ fleet: { name: "test-fleet" },
939
+ agents: [
940
+ createDiscordAgent("tool-agent", {
941
+ bot_token_env: "TEST_BOT_TOKEN",
942
+ session_expiry_hours: 24,
943
+ log_level: "standard",
944
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
945
+ guilds: [],
946
+ }),
947
+ ],
948
+ configPath: "/test/herdctl.yaml",
949
+ configDir: "/test",
950
+ };
951
+ const streamingContext = {
952
+ getConfig: () => streamingConfig,
953
+ getStateDir: () => "/tmp/test-state",
954
+ getStateDirInfo: () => null,
955
+ getLogger: () => mockLogger,
956
+ getScheduler: () => null,
957
+ getStatus: () => "running",
958
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
959
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
960
+ getStoppedAt: () => null,
961
+ getLastError: () => null,
962
+ getCheckInterval: () => 1000,
963
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
964
+ getEmitter: () => streamingEmitter,
965
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
966
+ };
967
+ const streamingManager = new DiscordManager(streamingContext);
968
+ const mockConnector = new EventEmitter();
969
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
970
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
971
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
972
+ mockConnector.getState = vi.fn().mockReturnValue({
973
+ status: "connected",
974
+ connectedAt: "2024-01-01T00:00:00.000Z",
975
+ disconnectedAt: null,
976
+ reconnectAttempts: 0,
977
+ lastError: null,
978
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
979
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
980
+ messageStats: { received: 0, sent: 0, ignored: 0 },
981
+ });
982
+ mockConnector.agentName = "tool-agent";
983
+ mockConnector.sessionManager = {
984
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
985
+ getSession: vi.fn().mockResolvedValue(null),
986
+ setSession: vi.fn().mockResolvedValue(undefined),
987
+ touchSession: vi.fn().mockResolvedValue(undefined),
988
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
989
+ };
990
+ // @ts-expect-error - accessing private property for testing
991
+ streamingManager.connectors.set("tool-agent", mockConnector);
992
+ // @ts-expect-error - accessing private property for testing
993
+ streamingManager.initialized = true;
994
+ await streamingManager.start();
995
+ const replyMock = vi.fn().mockResolvedValue(undefined);
996
+ const messageEvent = {
997
+ agentName: "tool-agent",
998
+ prompt: "List files in /tmp",
999
+ context: {
1000
+ messages: [],
1001
+ wasMentioned: true,
1002
+ prompt: "List files in /tmp",
1003
+ },
1004
+ metadata: {
1005
+ guildId: "guild1",
1006
+ channelId: "channel1",
1007
+ messageId: "msg1",
1008
+ userId: "user1",
1009
+ username: "TestUser",
1010
+ wasMentioned: true,
1011
+ mode: "mention",
1012
+ },
1013
+ reply: replyMock,
1014
+ startTyping: () => () => { },
1015
+ };
1016
+ mockConnector.emit("message", messageEvent);
1017
+ // Wait for async processing (includes rate limiting delays)
1018
+ await new Promise((resolve) => setTimeout(resolve, 5000));
1019
+ // Should have sent: text response, tool result embed, final text response
1020
+ expect(replyMock).toHaveBeenCalledTimes(3);
1021
+ // First: the text part of the assistant message
1022
+ expect(replyMock).toHaveBeenNthCalledWith(1, "Let me check that for you.");
1023
+ // Second: tool result as a Discord embed
1024
+ const embedCall = replyMock.mock.calls[1][0];
1025
+ expect(embedCall).toHaveProperty("embeds");
1026
+ expect(embedCall.embeds).toHaveLength(1);
1027
+ const embed = embedCall.embeds[0];
1028
+ expect(embed.title).toContain("Bash");
1029
+ expect(embed.description).toContain("ls -la /tmp");
1030
+ expect(embed.color).toBe(0x5865f2); // blurple (not error)
1031
+ // Should have Duration and Output fields plus the Result field
1032
+ expect(embed.fields).toBeDefined();
1033
+ const fieldNames = embed.fields.map((f) => f.name);
1034
+ expect(fieldNames).toContain("Duration");
1035
+ expect(fieldNames).toContain("Output");
1036
+ expect(fieldNames).toContain("Result");
1037
+ // Third: final assistant response
1038
+ expect(replyMock).toHaveBeenNthCalledWith(3, "Here are the files in /tmp.");
1039
+ }, 10000);
1040
+ it("streams tool results from top-level tool_use_result", async () => {
1041
+ // Test the alternative SDK format where tool_use_result is at the top level
1042
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
1043
+ if (options?.onMessage) {
1044
+ await options.onMessage({
1045
+ type: "user",
1046
+ tool_use_result: "output from tool execution",
1047
+ });
1048
+ }
1049
+ return { jobId: "tool-job-456", success: true };
1050
+ });
1051
+ const streamingEmitter = Object.assign(new EventEmitter(), {
1052
+ trigger: customTriggerMock,
1053
+ });
1054
+ const streamingConfig = {
1055
+ fleet: { name: "test-fleet" },
1056
+ agents: [
1057
+ createDiscordAgent("tool-agent-2", {
1058
+ bot_token_env: "TEST_BOT_TOKEN",
1059
+ session_expiry_hours: 24,
1060
+ log_level: "standard",
1061
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
1062
+ guilds: [],
1063
+ }),
1064
+ ],
1065
+ configPath: "/test/herdctl.yaml",
1066
+ configDir: "/test",
1067
+ };
1068
+ const streamingContext = {
1069
+ getConfig: () => streamingConfig,
1070
+ getStateDir: () => "/tmp/test-state",
1071
+ getStateDirInfo: () => null,
1072
+ getLogger: () => mockLogger,
1073
+ getScheduler: () => null,
1074
+ getStatus: () => "running",
1075
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1076
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1077
+ getStoppedAt: () => null,
1078
+ getLastError: () => null,
1079
+ getCheckInterval: () => 1000,
1080
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
1081
+ getEmitter: () => streamingEmitter,
1082
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
1083
+ };
1084
+ const streamingManager = new DiscordManager(streamingContext);
1085
+ const mockConnector = new EventEmitter();
1086
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1087
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1088
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1089
+ mockConnector.getState = vi.fn().mockReturnValue({
1090
+ status: "connected",
1091
+ connectedAt: "2024-01-01T00:00:00.000Z",
1092
+ disconnectedAt: null,
1093
+ reconnectAttempts: 0,
1094
+ lastError: null,
1095
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1096
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1097
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1098
+ });
1099
+ mockConnector.agentName = "tool-agent-2";
1100
+ mockConnector.sessionManager = {
1101
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
1102
+ getSession: vi.fn().mockResolvedValue(null),
1103
+ setSession: vi.fn().mockResolvedValue(undefined),
1104
+ touchSession: vi.fn().mockResolvedValue(undefined),
1105
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1106
+ };
1107
+ // @ts-expect-error - accessing private property for testing
1108
+ streamingManager.connectors.set("tool-agent-2", mockConnector);
1109
+ // @ts-expect-error - accessing private property for testing
1110
+ streamingManager.initialized = true;
1111
+ await streamingManager.start();
1112
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1113
+ const messageEvent = {
1114
+ agentName: "tool-agent-2",
1115
+ prompt: "Run something",
1116
+ context: {
1117
+ messages: [],
1118
+ wasMentioned: true,
1119
+ prompt: "Run something",
1120
+ },
1121
+ metadata: {
1122
+ guildId: "guild1",
1123
+ channelId: "channel1",
1124
+ messageId: "msg1",
1125
+ userId: "user1",
1126
+ username: "TestUser",
1127
+ wasMentioned: true,
1128
+ mode: "mention",
1129
+ },
1130
+ reply: replyMock,
1131
+ startTyping: () => () => { },
1132
+ };
1133
+ mockConnector.emit("message", messageEvent);
1134
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1135
+ // Should have sent the tool result as an embed
1136
+ expect(replyMock).toHaveBeenCalledTimes(1);
1137
+ const embedCall = replyMock.mock.calls[0][0];
1138
+ expect(embedCall).toHaveProperty("embeds");
1139
+ expect(embedCall.embeds).toHaveLength(1);
1140
+ const embed = embedCall.embeds[0];
1141
+ // No matching tool_use, so title falls back to "Tool"
1142
+ expect(embed.title).toContain("Tool");
1143
+ // Should have Output field and Result field
1144
+ expect(embed.fields).toBeDefined();
1145
+ const resultField = embed.fields.find((f) => f.name === "Result");
1146
+ expect(resultField).toBeDefined();
1147
+ expect(resultField.value).toContain("output from tool execution");
1148
+ });
1149
+ it("handles message handler rejection via catch handler", async () => {
1150
+ // This tests the .catch(error => this.handleError()) path in start()
1151
+ // when handleMessage throws an error that propagates to the catch handler
1152
+ // Create a config with no agents to trigger the "agent not found" error path
1153
+ const emptyConfig = {
1154
+ fleet: { name: "test-fleet" },
1155
+ agents: [], // No agents!
1156
+ configPath: "/test/herdctl.yaml",
1157
+ configDir: "/test",
1158
+ };
1159
+ const errorEmitter = new EventEmitter();
1160
+ const errorContext = {
1161
+ getConfig: () => emptyConfig,
1162
+ getStateDir: () => "/tmp/test-state",
1163
+ getStateDirInfo: () => null,
1164
+ getLogger: () => mockLogger,
1165
+ getScheduler: () => null,
1166
+ getStatus: () => "running",
1167
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1168
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1169
+ getStoppedAt: () => null,
1170
+ getLastError: () => null,
1171
+ getCheckInterval: () => 1000,
1172
+ emit: (event, ...args) => errorEmitter.emit(event, ...args),
1173
+ getEmitter: () => errorEmitter,
1174
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
1175
+ };
1176
+ const errorManager = new DiscordManager(errorContext);
1177
+ const mockConnector = new EventEmitter();
1178
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1179
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1180
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1181
+ mockConnector.getState = vi.fn().mockReturnValue({
1182
+ status: "connected",
1183
+ connectedAt: "2024-01-01T00:00:00.000Z",
1184
+ disconnectedAt: null,
1185
+ reconnectAttempts: 0,
1186
+ lastError: null,
1187
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1188
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1189
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1190
+ });
1191
+ mockConnector.agentName = "missing-agent";
1192
+ mockConnector.sessionManager = {
1193
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
1194
+ };
1195
+ // @ts-expect-error - accessing private property for testing
1196
+ errorManager.connectors.set("missing-agent", mockConnector);
1197
+ // @ts-expect-error - accessing private property for testing
1198
+ errorManager.initialized = true;
1199
+ await errorManager.start();
1200
+ // Create a message event with a reply that throws
1201
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply threw"));
1202
+ const messageEvent = {
1203
+ agentName: "missing-agent",
1204
+ prompt: "Hello!",
1205
+ context: {
1206
+ messages: [],
1207
+ wasMentioned: true,
1208
+ prompt: "Hello!",
1209
+ },
1210
+ metadata: {
1211
+ guildId: "guild1",
1212
+ channelId: "channel1",
1213
+ messageId: "msg1",
1214
+ userId: "user1",
1215
+ username: "TestUser",
1216
+ wasMentioned: true,
1217
+ mode: "mention",
1218
+ },
1219
+ reply: replyMock,
1220
+ startTyping: () => () => { },
1221
+ };
1222
+ // Emit the message event - this will trigger handleMessage which will fail
1223
+ // because agent is not in config, and then try to reply, and that also fails
1224
+ mockConnector.emit("message", messageEvent);
1225
+ // Wait for async processing
1226
+ await new Promise((resolve) => setTimeout(resolve, 50));
1227
+ // The catch handler should have caught the error and called handleError
1228
+ // which logs the error via discord:error event
1229
+ expect(mockLogger.error).toHaveBeenCalled();
1230
+ });
1231
+ it("handles error events from connector", async () => {
1232
+ // Create a mock connector
1233
+ const mockConnector = new EventEmitter();
1234
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1235
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1236
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1237
+ mockConnector.getState = vi.fn().mockReturnValue({
1238
+ status: "connected",
1239
+ connectedAt: "2024-01-01T00:00:00.000Z",
1240
+ disconnectedAt: null,
1241
+ reconnectAttempts: 0,
1242
+ lastError: null,
1243
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1244
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1245
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1246
+ });
1247
+ mockConnector.agentName = "test-agent";
1248
+ // @ts-expect-error - accessing private property for testing
1249
+ manager.connectors.set("test-agent", mockConnector);
1250
+ // @ts-expect-error - accessing private property for testing
1251
+ manager.initialized = true;
1252
+ await manager.start();
1253
+ // Emit an error event
1254
+ const errorEvent = {
1255
+ agentName: "test-agent",
1256
+ error: new Error("Test error"),
1257
+ };
1258
+ mockConnector.emit("error", errorEvent);
1259
+ // Wait for async processing
1260
+ await new Promise((resolve) => setTimeout(resolve, 10));
1261
+ // Should have logged the error
1262
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord connector error"));
1263
+ });
1264
+ it("sends formatted error reply when trigger fails", async () => {
1265
+ // Create a mock connector
1266
+ const mockConnector = new EventEmitter();
1267
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1268
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1269
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1270
+ mockConnector.getState = vi.fn().mockReturnValue({
1271
+ status: "connected",
1272
+ connectedAt: "2024-01-01T00:00:00.000Z",
1273
+ disconnectedAt: null,
1274
+ reconnectAttempts: 0,
1275
+ lastError: null,
1276
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1277
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1278
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1279
+ });
1280
+ mockConnector.agentName = "test-agent";
1281
+ // Make trigger fail
1282
+ triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
1283
+ // @ts-expect-error - accessing private property for testing
1284
+ manager.connectors.set("test-agent", mockConnector);
1285
+ // @ts-expect-error - accessing private property for testing
1286
+ manager.initialized = true;
1287
+ await manager.start();
1288
+ // Create a mock message event
1289
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1290
+ const messageEvent = {
1291
+ agentName: "test-agent",
1292
+ prompt: "Hello bot!",
1293
+ context: {
1294
+ messages: [],
1295
+ wasMentioned: true,
1296
+ prompt: "Hello bot!",
1297
+ },
1298
+ metadata: {
1299
+ guildId: "guild1",
1300
+ channelId: "channel1",
1301
+ messageId: "msg1",
1302
+ userId: "user1",
1303
+ username: "TestUser",
1304
+ wasMentioned: true,
1305
+ mode: "mention",
1306
+ },
1307
+ reply: replyMock,
1308
+ startTyping: () => () => { },
1309
+ };
1310
+ // Emit the message event
1311
+ mockConnector.emit("message", messageEvent);
1312
+ // Wait for async processing
1313
+ await new Promise((resolve) => setTimeout(resolve, 50));
1314
+ // Should have sent a formatted error reply
1315
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("❌ **Error**:"));
1316
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Agent execution failed"));
1317
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("/reset"));
1318
+ });
1319
+ it("handles error reply failure when trigger fails", async () => {
1320
+ // Create a mock connector
1321
+ const mockConnector = new EventEmitter();
1322
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1323
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1324
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1325
+ mockConnector.getState = vi.fn().mockReturnValue({
1326
+ status: "connected",
1327
+ connectedAt: "2024-01-01T00:00:00.000Z",
1328
+ disconnectedAt: null,
1329
+ reconnectAttempts: 0,
1330
+ lastError: null,
1331
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1332
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1333
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1334
+ });
1335
+ mockConnector.agentName = "test-agent";
1336
+ // Make trigger fail
1337
+ triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
1338
+ // @ts-expect-error - accessing private property for testing
1339
+ manager.connectors.set("test-agent", mockConnector);
1340
+ // @ts-expect-error - accessing private property for testing
1341
+ manager.initialized = true;
1342
+ await manager.start();
1343
+ // Create a mock message event with reply that also fails
1344
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply also failed"));
1345
+ const messageEvent = {
1346
+ agentName: "test-agent",
1347
+ prompt: "Hello bot!",
1348
+ context: {
1349
+ messages: [],
1350
+ wasMentioned: true,
1351
+ prompt: "Hello bot!",
1352
+ },
1353
+ metadata: {
1354
+ guildId: "guild1",
1355
+ channelId: "channel1",
1356
+ messageId: "msg1",
1357
+ userId: "user1",
1358
+ username: "TestUser",
1359
+ wasMentioned: true,
1360
+ mode: "mention",
1361
+ },
1362
+ reply: replyMock,
1363
+ startTyping: () => () => { },
1364
+ };
1365
+ // Emit the message event
1366
+ mockConnector.emit("message", messageEvent);
1367
+ // Wait for async processing
1368
+ await new Promise((resolve) => setTimeout(resolve, 50));
1369
+ // Should have logged both errors
1370
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord message handling failed"));
1371
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
1372
+ });
1373
+ it("sends error reply when agent not found", async () => {
1374
+ // Create a mock connector
1375
+ const mockConnector = new EventEmitter();
1376
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1377
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1378
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1379
+ mockConnector.getState = vi.fn().mockReturnValue({
1380
+ status: "connected",
1381
+ connectedAt: "2024-01-01T00:00:00.000Z",
1382
+ disconnectedAt: null,
1383
+ reconnectAttempts: 0,
1384
+ lastError: null,
1385
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1386
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1387
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1388
+ });
1389
+ mockConnector.agentName = "unknown-agent";
1390
+ // @ts-expect-error - accessing private property for testing
1391
+ manager.connectors.set("unknown-agent", mockConnector);
1392
+ // @ts-expect-error - accessing private property for testing
1393
+ manager.initialized = true;
1394
+ await manager.start();
1395
+ // Create a mock message event for an agent not in config
1396
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1397
+ const messageEvent = {
1398
+ agentName: "unknown-agent",
1399
+ prompt: "Hello bot!",
1400
+ context: {
1401
+ messages: [],
1402
+ wasMentioned: true,
1403
+ prompt: "Hello bot!",
1404
+ },
1405
+ metadata: {
1406
+ guildId: "guild1",
1407
+ channelId: "channel1",
1408
+ messageId: "msg1",
1409
+ userId: "user1",
1410
+ username: "TestUser",
1411
+ wasMentioned: true,
1412
+ mode: "mention",
1413
+ },
1414
+ reply: replyMock,
1415
+ startTyping: () => () => { },
1416
+ };
1417
+ // Emit the message event
1418
+ mockConnector.emit("message", messageEvent);
1419
+ // Wait for async processing
1420
+ await new Promise((resolve) => setTimeout(resolve, 50));
1421
+ // Should have sent an error reply
1422
+ expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("not properly configured"));
1423
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
1424
+ });
1425
+ });
1426
+ describe("extractMessageContent", () => {
1427
+ it("extracts direct string content", () => {
1428
+ // @ts-expect-error - accessing private method for testing
1429
+ const result = manager.extractMessageContent({
1430
+ type: "assistant",
1431
+ content: "Direct content",
1432
+ });
1433
+ expect(result).toBe("Direct content");
1434
+ });
1435
+ it("extracts nested message content", () => {
1436
+ // @ts-expect-error - accessing private method for testing
1437
+ const result = manager.extractMessageContent({
1438
+ type: "assistant",
1439
+ message: { content: "Nested content" },
1440
+ });
1441
+ expect(result).toBe("Nested content");
1442
+ });
1443
+ it("extracts text from content blocks", () => {
1444
+ // @ts-expect-error - accessing private method for testing
1445
+ const result = manager.extractMessageContent({
1446
+ type: "assistant",
1447
+ message: {
1448
+ content: [
1449
+ { type: "text", text: "First part" },
1450
+ { type: "text", text: " Second part" },
1451
+ ],
1452
+ },
1453
+ });
1454
+ expect(result).toBe("First part Second part");
1455
+ });
1456
+ it("returns undefined for empty content", () => {
1457
+ // @ts-expect-error - accessing private method for testing
1458
+ const result = manager.extractMessageContent({
1459
+ type: "assistant",
1460
+ });
1461
+ expect(result).toBeUndefined();
1462
+ });
1463
+ it("returns undefined for non-text content blocks", () => {
1464
+ // @ts-expect-error - accessing private method for testing
1465
+ const result = manager.extractMessageContent({
1466
+ type: "assistant",
1467
+ message: {
1468
+ content: [
1469
+ { type: "tool_use", name: "some_tool" },
1470
+ ],
1471
+ },
1472
+ });
1473
+ expect(result).toBeUndefined();
1474
+ });
1475
+ it("returns undefined for empty string content", () => {
1476
+ // @ts-expect-error - accessing private method for testing
1477
+ const result = manager.extractMessageContent({
1478
+ type: "assistant",
1479
+ content: "",
1480
+ });
1481
+ expect(result).toBeUndefined();
1482
+ });
1483
+ it("handles mixed content blocks (text and non-text)", () => {
1484
+ // @ts-expect-error - accessing private method for testing
1485
+ const result = manager.extractMessageContent({
1486
+ type: "assistant",
1487
+ message: {
1488
+ content: [
1489
+ { type: "tool_use", name: "some_tool" },
1490
+ { type: "text", text: "After tool" },
1491
+ ],
1492
+ },
1493
+ });
1494
+ expect(result).toBe("After tool");
1495
+ });
1496
+ it("handles empty content blocks array", () => {
1497
+ // @ts-expect-error - accessing private method for testing
1498
+ const result = manager.extractMessageContent({
1499
+ type: "assistant",
1500
+ message: {
1501
+ content: [],
1502
+ },
1503
+ });
1504
+ expect(result).toBeUndefined();
1505
+ });
1506
+ it("returns undefined for content that is not a string or array", () => {
1507
+ // @ts-expect-error - accessing private method for testing
1508
+ const result = manager.extractMessageContent({
1509
+ type: "assistant",
1510
+ message: {
1511
+ content: { someObject: "value" }, // Not string or array
1512
+ },
1513
+ });
1514
+ expect(result).toBeUndefined();
1515
+ });
1516
+ it("handles content blocks with missing text property", () => {
1517
+ // @ts-expect-error - accessing private method for testing
1518
+ const result = manager.extractMessageContent({
1519
+ type: "assistant",
1520
+ message: {
1521
+ content: [
1522
+ { type: "text" }, // Missing text property
1523
+ ],
1524
+ },
1525
+ });
1526
+ expect(result).toBeUndefined();
1527
+ });
1528
+ it("handles content block with non-string text", () => {
1529
+ // @ts-expect-error - accessing private method for testing
1530
+ const result = manager.extractMessageContent({
1531
+ type: "assistant",
1532
+ message: {
1533
+ content: [
1534
+ { type: "text", text: 123 }, // Non-string text
1535
+ ],
1536
+ },
1537
+ });
1538
+ expect(result).toBeUndefined();
1539
+ });
1540
+ });
1541
+ describe("extractToolUseBlocks", () => {
1542
+ it("extracts tool_use blocks with id from assistant message content", () => {
1543
+ // @ts-expect-error - accessing private method for testing
1544
+ const result = manager.extractToolUseBlocks({
1545
+ type: "assistant",
1546
+ message: {
1547
+ content: [
1548
+ { type: "text", text: "Let me run that command." },
1549
+ { type: "tool_use", name: "Bash", id: "tool-1", input: { command: "ls -la" } },
1550
+ ],
1551
+ },
1552
+ });
1553
+ expect(result).toHaveLength(1);
1554
+ expect(result[0].name).toBe("Bash");
1555
+ expect(result[0].id).toBe("tool-1");
1556
+ expect(result[0].input).toEqual({ command: "ls -la" });
1557
+ });
1558
+ it("extracts multiple tool_use blocks", () => {
1559
+ // @ts-expect-error - accessing private method for testing
1560
+ const result = manager.extractToolUseBlocks({
1561
+ type: "assistant",
1562
+ message: {
1563
+ content: [
1564
+ { type: "tool_use", name: "Read", id: "tool-1", input: { file_path: "/tmp/a.txt" } },
1565
+ { type: "text", text: "Reading file..." },
1566
+ { type: "tool_use", name: "Bash", id: "tool-2", input: { command: "cat /tmp/a.txt" } },
1567
+ ],
1568
+ },
1569
+ });
1570
+ expect(result).toHaveLength(2);
1571
+ expect(result[0].name).toBe("Read");
1572
+ expect(result[0].id).toBe("tool-1");
1573
+ expect(result[1].name).toBe("Bash");
1574
+ expect(result[1].id).toBe("tool-2");
1575
+ });
1576
+ it("handles tool_use blocks without id", () => {
1577
+ // @ts-expect-error - accessing private method for testing
1578
+ const result = manager.extractToolUseBlocks({
1579
+ type: "assistant",
1580
+ message: {
1581
+ content: [
1582
+ { type: "tool_use", name: "some_tool" },
1583
+ ],
1584
+ },
1585
+ });
1586
+ expect(result).toHaveLength(1);
1587
+ expect(result[0].name).toBe("some_tool");
1588
+ expect(result[0].id).toBeUndefined();
1589
+ });
1590
+ it("returns empty array when no tool_use blocks", () => {
1591
+ // @ts-expect-error - accessing private method for testing
1592
+ const result = manager.extractToolUseBlocks({
1593
+ type: "assistant",
1594
+ message: {
1595
+ content: [
1596
+ { type: "text", text: "Just a text response" },
1597
+ ],
1598
+ },
1599
+ });
1600
+ expect(result).toHaveLength(0);
1601
+ });
1602
+ it("returns empty array when content is not an array", () => {
1603
+ // @ts-expect-error - accessing private method for testing
1604
+ const result = manager.extractToolUseBlocks({
1605
+ type: "assistant",
1606
+ message: { content: "string content" },
1607
+ });
1608
+ expect(result).toHaveLength(0);
1609
+ });
1610
+ it("returns empty array when no message field", () => {
1611
+ // @ts-expect-error - accessing private method for testing
1612
+ const result = manager.extractToolUseBlocks({
1613
+ type: "assistant",
1614
+ });
1615
+ expect(result).toHaveLength(0);
1616
+ });
1617
+ });
1618
+ describe("getToolInputSummary", () => {
1619
+ it("returns command for Bash tool", () => {
1620
+ // @ts-expect-error - accessing private method for testing
1621
+ const result = manager.getToolInputSummary("Bash", { command: "ls -la" });
1622
+ expect(result).toBe("ls -la");
1623
+ });
1624
+ it("truncates long Bash commands", () => {
1625
+ const longCommand = "echo " + "a".repeat(250);
1626
+ // @ts-expect-error - accessing private method for testing
1627
+ const result = manager.getToolInputSummary("Bash", { command: longCommand });
1628
+ expect(result).toContain("...");
1629
+ expect(result.length).toBeLessThanOrEqual(203); // 200 + "..."
1630
+ });
1631
+ it("returns file path for Read tool", () => {
1632
+ // @ts-expect-error - accessing private method for testing
1633
+ const result = manager.getToolInputSummary("Read", { file_path: "/src/index.ts" });
1634
+ expect(result).toBe("/src/index.ts");
1635
+ });
1636
+ it("returns file path for Write tool", () => {
1637
+ // @ts-expect-error - accessing private method for testing
1638
+ const result = manager.getToolInputSummary("Write", { file_path: "/src/output.ts" });
1639
+ expect(result).toBe("/src/output.ts");
1640
+ });
1641
+ it("returns file path for Edit tool", () => {
1642
+ // @ts-expect-error - accessing private method for testing
1643
+ const result = manager.getToolInputSummary("Edit", { file_path: "/src/utils.ts" });
1644
+ expect(result).toBe("/src/utils.ts");
1645
+ });
1646
+ it("returns pattern for Glob/Grep tools", () => {
1647
+ // @ts-expect-error - accessing private method for testing
1648
+ const result = manager.getToolInputSummary("Grep", { pattern: "TODO" });
1649
+ expect(result).toBe("TODO");
1650
+ });
1651
+ it("returns query for WebSearch", () => {
1652
+ // @ts-expect-error - accessing private method for testing
1653
+ const result = manager.getToolInputSummary("WebSearch", { query: "discord embeds" });
1654
+ expect(result).toBe("discord embeds");
1655
+ });
1656
+ it("returns undefined for unknown tools", () => {
1657
+ // @ts-expect-error - accessing private method for testing
1658
+ const result = manager.getToolInputSummary("SomeCustomTool", { data: "value" });
1659
+ expect(result).toBeUndefined();
1660
+ });
1661
+ it("returns undefined for Bash without command", () => {
1662
+ // @ts-expect-error - accessing private method for testing
1663
+ const result = manager.getToolInputSummary("Bash", {});
1664
+ expect(result).toBeUndefined();
1665
+ });
1666
+ });
1667
+ describe("extractToolResults", () => {
1668
+ it("extracts tool result from top-level tool_use_result string", () => {
1669
+ // @ts-expect-error - accessing private method for testing
1670
+ const results = manager.extractToolResults({
1671
+ type: "user",
1672
+ tool_use_result: "file1.txt\nfile2.txt\nfile3.txt",
1673
+ });
1674
+ expect(results).toHaveLength(1);
1675
+ expect(results[0].output).toBe("file1.txt\nfile2.txt\nfile3.txt");
1676
+ expect(results[0].isError).toBe(false);
1677
+ });
1678
+ it("extracts tool result from tool_use_result object with content string", () => {
1679
+ // @ts-expect-error - accessing private method for testing
1680
+ const results = manager.extractToolResults({
1681
+ type: "user",
1682
+ tool_use_result: {
1683
+ content: "Command output here",
1684
+ is_error: false,
1685
+ },
1686
+ });
1687
+ expect(results).toHaveLength(1);
1688
+ expect(results[0].output).toBe("Command output here");
1689
+ expect(results[0].isError).toBe(false);
1690
+ });
1691
+ it("extracts error tool result from tool_use_result object", () => {
1692
+ // @ts-expect-error - accessing private method for testing
1693
+ const results = manager.extractToolResults({
1694
+ type: "user",
1695
+ tool_use_result: {
1696
+ content: "Permission denied",
1697
+ is_error: true,
1698
+ },
1699
+ });
1700
+ expect(results).toHaveLength(1);
1701
+ expect(results[0].output).toBe("Permission denied");
1702
+ expect(results[0].isError).toBe(true);
1703
+ });
1704
+ it("extracts tool results with tool_use_id from content blocks", () => {
1705
+ // @ts-expect-error - accessing private method for testing
1706
+ const results = manager.extractToolResults({
1707
+ type: "user",
1708
+ message: {
1709
+ content: [
1710
+ {
1711
+ type: "tool_result",
1712
+ tool_use_id: "tool-1",
1713
+ content: "total 48\ndrwxr-xr-x 5 user staff 160 Jan 20 10:00 .",
1714
+ },
1715
+ ],
1716
+ },
1717
+ });
1718
+ expect(results).toHaveLength(1);
1719
+ expect(results[0].output).toContain("total 48");
1720
+ expect(results[0].isError).toBe(false);
1721
+ expect(results[0].toolUseId).toBe("tool-1");
1722
+ });
1723
+ it("extracts error tool result from content blocks", () => {
1724
+ // @ts-expect-error - accessing private method for testing
1725
+ const results = manager.extractToolResults({
1726
+ type: "user",
1727
+ message: {
1728
+ content: [
1729
+ {
1730
+ type: "tool_result",
1731
+ tool_use_id: "tool-1",
1732
+ content: "bash: command not found: foo",
1733
+ is_error: true,
1734
+ },
1735
+ ],
1736
+ },
1737
+ });
1738
+ expect(results).toHaveLength(1);
1739
+ expect(results[0].isError).toBe(true);
1740
+ expect(results[0].toolUseId).toBe("tool-1");
1741
+ });
1742
+ it("extracts tool results with nested content blocks", () => {
1743
+ // @ts-expect-error - accessing private method for testing
1744
+ const results = manager.extractToolResults({
1745
+ type: "user",
1746
+ message: {
1747
+ content: [
1748
+ {
1749
+ type: "tool_result",
1750
+ tool_use_id: "tool-1",
1751
+ content: [
1752
+ { type: "text", text: "Line 1 of output" },
1753
+ { type: "text", text: "Line 2 of output" },
1754
+ ],
1755
+ },
1756
+ ],
1757
+ },
1758
+ });
1759
+ expect(results).toHaveLength(1);
1760
+ expect(results[0].output).toBe("Line 1 of output\nLine 2 of output");
1761
+ });
1762
+ it("extracts multiple tool results with different IDs", () => {
1763
+ // @ts-expect-error - accessing private method for testing
1764
+ const results = manager.extractToolResults({
1765
+ type: "user",
1766
+ message: {
1767
+ content: [
1768
+ {
1769
+ type: "tool_result",
1770
+ tool_use_id: "tool-1",
1771
+ content: "Result 1",
1772
+ },
1773
+ {
1774
+ type: "tool_result",
1775
+ tool_use_id: "tool-2",
1776
+ content: "Result 2",
1777
+ },
1778
+ ],
1779
+ },
1780
+ });
1781
+ expect(results).toHaveLength(2);
1782
+ expect(results[0].output).toBe("Result 1");
1783
+ expect(results[0].toolUseId).toBe("tool-1");
1784
+ expect(results[1].output).toBe("Result 2");
1785
+ expect(results[1].toolUseId).toBe("tool-2");
1786
+ });
1787
+ it("returns empty array for user message without tool results", () => {
1788
+ // @ts-expect-error - accessing private method for testing
1789
+ const results = manager.extractToolResults({
1790
+ type: "user",
1791
+ message: {
1792
+ content: [
1793
+ { type: "text", text: "Just a regular user message" },
1794
+ ],
1795
+ },
1796
+ });
1797
+ expect(results).toHaveLength(0);
1798
+ });
1799
+ it("returns empty array for user message without content", () => {
1800
+ // @ts-expect-error - accessing private method for testing
1801
+ const results = manager.extractToolResults({
1802
+ type: "user",
1803
+ });
1804
+ expect(results).toHaveLength(0);
1805
+ });
1806
+ });
1807
+ describe("buildToolEmbed", () => {
1808
+ it("builds embed with tool_use info and result", () => {
1809
+ // @ts-expect-error - accessing private method for testing
1810
+ const embed = manager.buildToolEmbed({ name: "Bash", input: { command: "ls -la" }, startTime: Date.now() - 1200 }, { output: "file1.txt\nfile2.txt", isError: false });
1811
+ expect(embed.title).toContain("Bash");
1812
+ expect(embed.description).toContain("ls -la");
1813
+ expect(embed.color).toBe(0x5865f2);
1814
+ expect(embed.fields).toBeDefined();
1815
+ const fieldNames = embed.fields.map(f => f.name);
1816
+ expect(fieldNames).toContain("Duration");
1817
+ expect(fieldNames).toContain("Output");
1818
+ expect(fieldNames).toContain("Result");
1819
+ // Result field should contain the output in a code block
1820
+ const resultField = embed.fields.find(f => f.name === "Result");
1821
+ expect(resultField.value).toContain("file1.txt");
1822
+ });
1823
+ it("builds embed with error color for error results", () => {
1824
+ // @ts-expect-error - accessing private method for testing
1825
+ const embed = manager.buildToolEmbed({ name: "Bash", input: { command: "rm -rf /" }, startTime: Date.now() - 500 }, { output: "Permission denied", isError: true });
1826
+ expect(embed.color).toBe(0xef4444);
1827
+ const errorField = embed.fields.find(f => f.name === "Error");
1828
+ expect(errorField).toBeDefined();
1829
+ expect(errorField.value).toContain("Permission denied");
1830
+ });
1831
+ it("builds embed without tool_use info (fallback)", () => {
1832
+ // @ts-expect-error - accessing private method for testing
1833
+ const embed = manager.buildToolEmbed(null, { output: "some output", isError: false });
1834
+ expect(embed.title).toContain("Tool");
1835
+ expect(embed.description).toBeUndefined();
1836
+ // No Duration field when no tool_use info
1837
+ const fieldNames = embed.fields.map(f => f.name);
1838
+ expect(fieldNames).not.toContain("Duration");
1839
+ expect(fieldNames).toContain("Output");
1840
+ });
1841
+ it("truncates long output in embed field", () => {
1842
+ const longOutput = "x".repeat(2000);
1843
+ // @ts-expect-error - accessing private method for testing
1844
+ const embed = manager.buildToolEmbed({ name: "Bash", input: { command: "cat bigfile" }, startTime: Date.now() - 100 }, { output: longOutput, isError: false });
1845
+ const resultField = embed.fields.find(f => f.name === "Result");
1846
+ expect(resultField).toBeDefined();
1847
+ expect(resultField.value).toContain("chars total");
1848
+ // Total field value should fit in Discord embed field limit (1024)
1849
+ expect(resultField.value.length).toBeLessThanOrEqual(1024);
1850
+ });
1851
+ it("formats output length with k suffix for large outputs", () => {
1852
+ const output = "x".repeat(1500);
1853
+ // @ts-expect-error - accessing private method for testing
1854
+ const embed = manager.buildToolEmbed({ name: "Read", input: { file_path: "/big.txt" }, startTime: Date.now() - 200 }, { output, isError: false });
1855
+ const outputField = embed.fields.find(f => f.name === "Output");
1856
+ expect(outputField.value).toContain("k chars");
1857
+ });
1858
+ it("shows Read tool with file path in description", () => {
1859
+ // @ts-expect-error - accessing private method for testing
1860
+ const embed = manager.buildToolEmbed({ name: "Read", input: { file_path: "/src/index.ts" }, startTime: Date.now() - 50 }, { output: "file contents", isError: false });
1861
+ expect(embed.title).toContain("Read");
1862
+ expect(embed.description).toContain("/src/index.ts");
1863
+ });
1864
+ it("omits Result field for empty output", () => {
1865
+ // @ts-expect-error - accessing private method for testing
1866
+ const embed = manager.buildToolEmbed({ name: "Bash", input: { command: "true" }, startTime: Date.now() - 10 }, { output: " \n\n ", isError: false });
1867
+ const fieldNames = embed.fields.map(f => f.name);
1868
+ expect(fieldNames).not.toContain("Result");
1869
+ });
1870
+ });
1871
+ });
1872
+ // Session integration tests are skipped pending refactor to work with the new architecture
1873
+ // These tests rely on the message handling infrastructure which has been refactored
1874
+ describe.skip("DiscordManager session integration", () => {
1875
+ let manager;
1876
+ let mockContext;
1877
+ let triggerMock;
1878
+ let emitterWithTrigger;
1879
+ let mockSessionManager;
1880
+ beforeEach(() => {
1881
+ vi.clearAllMocks();
1882
+ // Create mock session manager
1883
+ mockSessionManager = {
1884
+ agentName: "test-agent",
1885
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "session-123", isNew: false }),
1886
+ getSession: vi.fn().mockResolvedValue({ sessionId: "session-123", lastMessageAt: new Date().toISOString() }),
1887
+ setSession: vi.fn().mockResolvedValue(undefined),
1888
+ touchSession: vi.fn().mockResolvedValue(undefined),
1889
+ clearSession: vi.fn().mockResolvedValue(true),
1890
+ cleanupExpiredSessions: vi.fn().mockResolvedValue(0),
1891
+ getActiveSessionCount: vi.fn().mockResolvedValue(5),
1892
+ };
1893
+ // Create a mock FleetManager (emitter) with trigger method
1894
+ triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123", success: true, sessionId: "sdk-session-456" });
1895
+ emitterWithTrigger = Object.assign(new EventEmitter(), {
1896
+ trigger: triggerMock,
1897
+ });
1898
+ const config = {
1899
+ fleet: { name: "test-fleet" },
1900
+ agents: [
1901
+ createDiscordAgent("test-agent", {
1902
+ bot_token_env: "TEST_BOT_TOKEN",
1903
+ session_expiry_hours: 24,
1904
+ log_level: "standard",
1905
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
1906
+ guilds: [],
1907
+ }),
1908
+ ],
1909
+ configPath: "/test/herdctl.yaml",
1910
+ configDir: "/test",
1911
+ };
1912
+ mockContext = {
1913
+ getConfig: () => config,
1914
+ getStateDir: () => "/tmp/test-state",
1915
+ getStateDirInfo: () => null,
1916
+ getLogger: () => mockLogger,
1917
+ getScheduler: () => null,
1918
+ getStatus: () => "running",
1919
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1920
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
1921
+ getStoppedAt: () => null,
1922
+ getLastError: () => null,
1923
+ getCheckInterval: () => 1000,
1924
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1925
+ getEmitter: () => emitterWithTrigger,
1926
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
1927
+ };
1928
+ manager = new DiscordManager(mockContext);
1929
+ });
1930
+ it("calls getSession on message to check for existing session", async () => {
1931
+ // Create a mock connector with session manager
1932
+ const mockConnector = new EventEmitter();
1933
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1934
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1935
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1936
+ mockConnector.getState = vi.fn().mockReturnValue({
1937
+ status: "connected",
1938
+ connectedAt: "2024-01-01T00:00:00.000Z",
1939
+ disconnectedAt: null,
1940
+ reconnectAttempts: 0,
1941
+ lastError: null,
1942
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1943
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1944
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1945
+ });
1946
+ mockConnector.agentName = "test-agent";
1947
+ mockConnector.sessionManager = mockSessionManager;
1948
+ // @ts-expect-error - accessing private property for testing
1949
+ manager.connectors.set("test-agent", mockConnector);
1950
+ // @ts-expect-error - accessing private property for testing
1951
+ manager.initialized = true;
1952
+ await manager.start();
1953
+ // Create a mock message event
1954
+ const replyMock = vi.fn().mockResolvedValue(undefined);
1955
+ const messageEvent = {
1956
+ agentName: "test-agent",
1957
+ prompt: "Hello bot!",
1958
+ context: {
1959
+ messages: [],
1960
+ wasMentioned: true,
1961
+ prompt: "Hello bot!",
1962
+ },
1963
+ metadata: {
1964
+ guildId: "guild1",
1965
+ channelId: "channel1",
1966
+ messageId: "msg1",
1967
+ userId: "user1",
1968
+ username: "TestUser",
1969
+ wasMentioned: true,
1970
+ mode: "mention",
1971
+ },
1972
+ reply: replyMock,
1973
+ startTyping: () => () => { },
1974
+ };
1975
+ // Emit the message event
1976
+ mockConnector.emit("message", messageEvent);
1977
+ // Wait for async processing
1978
+ await new Promise((resolve) => setTimeout(resolve, 50));
1979
+ // Should have called getSession to check for existing session
1980
+ expect(mockSessionManager.getSession).toHaveBeenCalledWith("channel1");
1981
+ });
1982
+ it("calls setSession after successful response with SDK session ID", async () => {
1983
+ // Create a mock connector with session manager
1984
+ const mockConnector = new EventEmitter();
1985
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1986
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1987
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
1988
+ mockConnector.getState = vi.fn().mockReturnValue({
1989
+ status: "connected",
1990
+ connectedAt: "2024-01-01T00:00:00.000Z",
1991
+ disconnectedAt: null,
1992
+ reconnectAttempts: 0,
1993
+ lastError: null,
1994
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1995
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1996
+ messageStats: { received: 0, sent: 0, ignored: 0 },
1997
+ });
1998
+ mockConnector.agentName = "test-agent";
1999
+ mockConnector.sessionManager = mockSessionManager;
2000
+ // @ts-expect-error - accessing private property for testing
2001
+ manager.connectors.set("test-agent", mockConnector);
2002
+ // @ts-expect-error - accessing private property for testing
2003
+ manager.initialized = true;
2004
+ await manager.start();
2005
+ // Create a mock message event
2006
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2007
+ const messageEvent = {
2008
+ agentName: "test-agent",
2009
+ prompt: "Hello bot!",
2010
+ context: {
2011
+ messages: [],
2012
+ wasMentioned: true,
2013
+ prompt: "Hello bot!",
2014
+ },
2015
+ metadata: {
2016
+ guildId: "guild1",
2017
+ channelId: "channel1",
2018
+ messageId: "msg1",
2019
+ userId: "user1",
2020
+ username: "TestUser",
2021
+ wasMentioned: true,
2022
+ mode: "mention",
2023
+ },
2024
+ reply: replyMock,
2025
+ startTyping: () => () => { },
2026
+ };
2027
+ // Emit the message event
2028
+ mockConnector.emit("message", messageEvent);
2029
+ // Wait for async processing
2030
+ await new Promise((resolve) => setTimeout(resolve, 50));
2031
+ // Should have called setSession with the SDK session ID from trigger result
2032
+ expect(mockSessionManager.setSession).toHaveBeenCalledWith("channel1", "sdk-session-456");
2033
+ });
2034
+ it("handles getSession errors gracefully", async () => {
2035
+ // Create a mock connector with session manager where getSession fails
2036
+ mockSessionManager.getSession.mockRejectedValue(new Error("Session error"));
2037
+ const mockConnector = new EventEmitter();
2038
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2039
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2040
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2041
+ mockConnector.getState = vi.fn().mockReturnValue({
2042
+ status: "connected",
2043
+ connectedAt: "2024-01-01T00:00:00.000Z",
2044
+ disconnectedAt: null,
2045
+ reconnectAttempts: 0,
2046
+ lastError: null,
2047
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2048
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2049
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2050
+ });
2051
+ mockConnector.agentName = "test-agent";
2052
+ mockConnector.sessionManager = mockSessionManager;
2053
+ // @ts-expect-error - accessing private property for testing
2054
+ manager.connectors.set("test-agent", mockConnector);
2055
+ // @ts-expect-error - accessing private property for testing
2056
+ manager.initialized = true;
2057
+ await manager.start();
2058
+ // Create a mock message event
2059
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2060
+ const messageEvent = {
2061
+ agentName: "test-agent",
2062
+ prompt: "Hello bot!",
2063
+ context: {
2064
+ messages: [],
2065
+ wasMentioned: true,
2066
+ prompt: "Hello bot!",
2067
+ },
2068
+ metadata: {
2069
+ guildId: "guild1",
2070
+ channelId: "channel1",
2071
+ messageId: "msg1",
2072
+ userId: "user1",
2073
+ username: "TestUser",
2074
+ wasMentioned: true,
2075
+ mode: "mention",
2076
+ },
2077
+ reply: replyMock,
2078
+ startTyping: () => () => { },
2079
+ };
2080
+ // Emit the message event
2081
+ mockConnector.emit("message", messageEvent);
2082
+ // Wait for async processing
2083
+ await new Promise((resolve) => setTimeout(resolve, 50));
2084
+ // Should have logged a warning but continued processing
2085
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get session"));
2086
+ // Should still have called trigger
2087
+ expect(triggerMock).toHaveBeenCalled();
2088
+ });
2089
+ it("handles setSession errors gracefully", async () => {
2090
+ // Create a mock connector with session manager where setSession fails
2091
+ mockSessionManager.setSession.mockRejectedValue(new Error("Session storage error"));
2092
+ const mockConnector = new EventEmitter();
2093
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2094
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2095
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2096
+ mockConnector.getState = vi.fn().mockReturnValue({
2097
+ status: "connected",
2098
+ connectedAt: "2024-01-01T00:00:00.000Z",
2099
+ disconnectedAt: null,
2100
+ reconnectAttempts: 0,
2101
+ lastError: null,
2102
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2103
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2104
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2105
+ });
2106
+ mockConnector.agentName = "test-agent";
2107
+ mockConnector.sessionManager = mockSessionManager;
2108
+ // @ts-expect-error - accessing private property for testing
2109
+ manager.connectors.set("test-agent", mockConnector);
2110
+ // @ts-expect-error - accessing private property for testing
2111
+ manager.initialized = true;
2112
+ await manager.start();
2113
+ // Create a mock message event
2114
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2115
+ const messageEvent = {
2116
+ agentName: "test-agent",
2117
+ prompt: "Hello bot!",
2118
+ context: {
2119
+ messages: [],
2120
+ wasMentioned: true,
2121
+ prompt: "Hello bot!",
2122
+ },
2123
+ metadata: {
2124
+ guildId: "guild1",
2125
+ channelId: "channel1",
2126
+ messageId: "msg1",
2127
+ userId: "user1",
2128
+ username: "TestUser",
2129
+ wasMentioned: true,
2130
+ mode: "mention",
2131
+ },
2132
+ reply: replyMock,
2133
+ startTyping: () => () => { },
2134
+ };
2135
+ // Emit the message event
2136
+ mockConnector.emit("message", messageEvent);
2137
+ // Wait for async processing
2138
+ await new Promise((resolve) => setTimeout(resolve, 50));
2139
+ // Should have logged a warning but continued
2140
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store session"));
2141
+ // Reply should still have been sent
2142
+ expect(replyMock).toHaveBeenCalled();
2143
+ });
2144
+ it("logs session count on stop", async () => {
2145
+ // Create a mock connector with session manager
2146
+ const mockConnector = new EventEmitter();
2147
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2148
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2149
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2150
+ mockConnector.getState = vi.fn().mockReturnValue({
2151
+ status: "connected",
2152
+ connectedAt: "2024-01-01T00:00:00.000Z",
2153
+ disconnectedAt: null,
2154
+ reconnectAttempts: 0,
2155
+ lastError: null,
2156
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2157
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2158
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2159
+ });
2160
+ mockConnector.agentName = "test-agent";
2161
+ mockConnector.sessionManager = mockSessionManager;
2162
+ // @ts-expect-error - accessing private property for testing
2163
+ manager.connectors.set("test-agent", mockConnector);
2164
+ // @ts-expect-error - accessing private property for testing
2165
+ manager.initialized = true;
2166
+ await manager.stop();
2167
+ // Should have queried session count
2168
+ expect(mockSessionManager.getActiveSessionCount).toHaveBeenCalled();
2169
+ // Should have logged about preserving sessions
2170
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Preserving 5 active session(s)"));
2171
+ });
2172
+ it("handles getActiveSessionCount errors on stop", async () => {
2173
+ // Create a mock connector with session manager that fails
2174
+ mockSessionManager.getActiveSessionCount.mockRejectedValue(new Error("Count error"));
2175
+ const mockConnector = new EventEmitter();
2176
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2177
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2178
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2179
+ mockConnector.getState = vi.fn().mockReturnValue({
2180
+ status: "connected",
2181
+ connectedAt: "2024-01-01T00:00:00.000Z",
2182
+ disconnectedAt: null,
2183
+ reconnectAttempts: 0,
2184
+ lastError: null,
2185
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2186
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2187
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2188
+ });
2189
+ mockConnector.agentName = "test-agent";
2190
+ mockConnector.sessionManager = mockSessionManager;
2191
+ // @ts-expect-error - accessing private property for testing
2192
+ manager.connectors.set("test-agent", mockConnector);
2193
+ // @ts-expect-error - accessing private property for testing
2194
+ manager.initialized = true;
2195
+ await manager.stop();
2196
+ // Should have warned about the error
2197
+ expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get session count"));
2198
+ // Should still disconnect
2199
+ expect(mockConnector.disconnect).toHaveBeenCalled();
2200
+ });
2201
+ it("does not log session preservation when count is 0", async () => {
2202
+ // Create a mock connector with session manager returning 0 sessions
2203
+ mockSessionManager.getActiveSessionCount.mockResolvedValue(0);
2204
+ const mockConnector = new EventEmitter();
2205
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2206
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2207
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2208
+ mockConnector.getState = vi.fn().mockReturnValue({
2209
+ status: "connected",
2210
+ connectedAt: "2024-01-01T00:00:00.000Z",
2211
+ disconnectedAt: null,
2212
+ reconnectAttempts: 0,
2213
+ lastError: null,
2214
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2215
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2216
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2217
+ });
2218
+ mockConnector.agentName = "test-agent";
2219
+ mockConnector.sessionManager = mockSessionManager;
2220
+ // @ts-expect-error - accessing private property for testing
2221
+ manager.connectors.set("test-agent", mockConnector);
2222
+ // @ts-expect-error - accessing private property for testing
2223
+ manager.initialized = true;
2224
+ await manager.stop();
2225
+ // Should NOT have logged about preserving sessions (0 sessions)
2226
+ expect(mockLogger.debug).not.toHaveBeenCalledWith(expect.stringContaining("Preserving"));
2227
+ });
2228
+ });
2229
+ // Lifecycle tests are skipped pending refactor to work with the new architecture
2230
+ // These tests rely on the message handling infrastructure which has been refactored
2231
+ describe.skip("DiscordManager lifecycle", () => {
2232
+ beforeEach(() => {
2233
+ vi.clearAllMocks();
2234
+ });
2235
+ it("handles connect failure gracefully", async () => {
2236
+ const mockConnector = new EventEmitter();
2237
+ mockConnector.connect = vi.fn().mockRejectedValue(new Error("Connection failed"));
2238
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2239
+ mockConnector.isConnected = vi.fn().mockReturnValue(false);
2240
+ mockConnector.getState = vi.fn().mockReturnValue({
2241
+ status: "error",
2242
+ connectedAt: null,
2243
+ disconnectedAt: null,
2244
+ reconnectAttempts: 0,
2245
+ lastError: "Connection failed",
2246
+ botUser: null,
2247
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2248
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2249
+ });
2250
+ mockConnector.agentName = "test-agent";
2251
+ mockConnector.sessionManager = {
2252
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2253
+ };
2254
+ const ctx = createMockContext(null);
2255
+ const manager = new DiscordManager(ctx);
2256
+ // @ts-expect-error - accessing private property for testing
2257
+ manager.connectors.set("test-agent", mockConnector);
2258
+ // @ts-expect-error - accessing private property for testing
2259
+ manager.initialized = true;
2260
+ // Should not throw
2261
+ await manager.start();
2262
+ // Should have logged the error
2263
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to connect Discord"));
2264
+ });
2265
+ it("handles disconnect failure gracefully", async () => {
2266
+ const mockConnector = new EventEmitter();
2267
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2268
+ mockConnector.disconnect = vi.fn().mockRejectedValue(new Error("Disconnect failed"));
2269
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2270
+ mockConnector.getState = vi.fn().mockReturnValue({
2271
+ status: "connected",
2272
+ connectedAt: "2024-01-01T00:00:00.000Z",
2273
+ disconnectedAt: null,
2274
+ reconnectAttempts: 0,
2275
+ lastError: null,
2276
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2277
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2278
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2279
+ });
2280
+ mockConnector.agentName = "test-agent";
2281
+ mockConnector.sessionManager = {
2282
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2283
+ };
2284
+ const ctx = createMockContext(null);
2285
+ const manager = new DiscordManager(ctx);
2286
+ // @ts-expect-error - accessing private property for testing
2287
+ manager.connectors.set("test-agent", mockConnector);
2288
+ // @ts-expect-error - accessing private property for testing
2289
+ manager.initialized = true;
2290
+ // Should not throw
2291
+ await manager.stop();
2292
+ // Should have logged the error
2293
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Error disconnecting Discord"));
2294
+ });
2295
+ it("reports correct connected count", async () => {
2296
+ const connectedConnector = new EventEmitter();
2297
+ connectedConnector.connect = vi.fn().mockResolvedValue(undefined);
2298
+ connectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2299
+ connectedConnector.isConnected = vi.fn().mockReturnValue(true);
2300
+ connectedConnector.getState = vi.fn().mockReturnValue({
2301
+ status: "connected",
2302
+ connectedAt: "2024-01-01T00:00:00.000Z",
2303
+ disconnectedAt: null,
2304
+ reconnectAttempts: 0,
2305
+ lastError: null,
2306
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2307
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2308
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2309
+ });
2310
+ connectedConnector.agentName = "connected-agent";
2311
+ connectedConnector.sessionManager = {
2312
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2313
+ };
2314
+ const disconnectedConnector = new EventEmitter();
2315
+ disconnectedConnector.connect = vi.fn().mockRejectedValue(new Error("Failed"));
2316
+ disconnectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2317
+ disconnectedConnector.isConnected = vi.fn().mockReturnValue(false);
2318
+ disconnectedConnector.getState = vi.fn().mockReturnValue({
2319
+ status: "error",
2320
+ connectedAt: null,
2321
+ disconnectedAt: null,
2322
+ reconnectAttempts: 0,
2323
+ lastError: "Failed",
2324
+ botUser: null,
2325
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2326
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2327
+ });
2328
+ disconnectedConnector.agentName = "disconnected-agent";
2329
+ disconnectedConnector.sessionManager = {
2330
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2331
+ };
2332
+ const ctx = createMockContext(null);
2333
+ const manager = new DiscordManager(ctx);
2334
+ // @ts-expect-error - accessing private property for testing
2335
+ manager.connectors.set("connected-agent", connectedConnector);
2336
+ // @ts-expect-error - accessing private property for testing
2337
+ manager.connectors.set("disconnected-agent", disconnectedConnector);
2338
+ // @ts-expect-error - accessing private property for testing
2339
+ manager.initialized = true;
2340
+ await manager.start();
2341
+ // Should report correct counts
2342
+ expect(manager.getConnectedCount()).toBe(1);
2343
+ expect(manager.getConnectorNames()).toEqual(["connected-agent", "disconnected-agent"]);
2344
+ });
2345
+ it("emits discord:message:handled event on successful message handling", async () => {
2346
+ const eventEmitter = new EventEmitter();
2347
+ const emittedEvents = [];
2348
+ // Track emitted events
2349
+ eventEmitter.on("discord:message:handled", (data) => {
2350
+ emittedEvents.push({ event: "discord:message:handled", data });
2351
+ });
2352
+ const triggerMock = vi.fn().mockResolvedValue({ jobId: "job-456" });
2353
+ const emitterWithTrigger = Object.assign(eventEmitter, {
2354
+ trigger: triggerMock,
2355
+ });
2356
+ const config = {
2357
+ fleet: { name: "test-fleet" },
2358
+ agents: [
2359
+ createDiscordAgent("test-agent", {
2360
+ bot_token_env: "TEST_BOT_TOKEN",
2361
+ session_expiry_hours: 24,
2362
+ log_level: "standard",
2363
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
2364
+ guilds: [],
2365
+ }),
2366
+ ],
2367
+ configPath: "/test/herdctl.yaml",
2368
+ configDir: "/test",
2369
+ };
2370
+ const mockContext = {
2371
+ getConfig: () => config,
2372
+ getStateDir: () => "/tmp/test-state",
2373
+ getStateDirInfo: () => null,
2374
+ getLogger: () => mockLogger,
2375
+ getScheduler: () => null,
2376
+ getStatus: () => "running",
2377
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2378
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2379
+ getStoppedAt: () => null,
2380
+ getLastError: () => null,
2381
+ getCheckInterval: () => 1000,
2382
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
2383
+ getEmitter: () => emitterWithTrigger,
2384
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2385
+ };
2386
+ const manager = new DiscordManager(mockContext);
2387
+ const mockConnector = new EventEmitter();
2388
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2389
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2390
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2391
+ mockConnector.getState = vi.fn().mockReturnValue({
2392
+ status: "connected",
2393
+ connectedAt: "2024-01-01T00:00:00.000Z",
2394
+ disconnectedAt: null,
2395
+ reconnectAttempts: 0,
2396
+ lastError: null,
2397
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2398
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2399
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2400
+ });
2401
+ mockConnector.agentName = "test-agent";
2402
+ mockConnector.sessionManager = {
2403
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
2404
+ touchSession: vi.fn().mockResolvedValue(undefined),
2405
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2406
+ };
2407
+ // @ts-expect-error - accessing private property for testing
2408
+ manager.connectors.set("test-agent", mockConnector);
2409
+ // @ts-expect-error - accessing private property for testing
2410
+ manager.initialized = true;
2411
+ await manager.start();
2412
+ // Create a mock message event
2413
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2414
+ const messageEvent = {
2415
+ agentName: "test-agent",
2416
+ prompt: "Hello bot!",
2417
+ context: {
2418
+ messages: [],
2419
+ wasMentioned: true,
2420
+ prompt: "Hello bot!",
2421
+ },
2422
+ metadata: {
2423
+ guildId: "guild1",
2424
+ channelId: "channel1",
2425
+ messageId: "msg1",
2426
+ userId: "user1",
2427
+ username: "TestUser",
2428
+ wasMentioned: true,
2429
+ mode: "mention",
2430
+ },
2431
+ reply: replyMock,
2432
+ startTyping: () => () => { },
2433
+ };
2434
+ // Emit the message event
2435
+ mockConnector.emit("message", messageEvent);
2436
+ // Wait for async processing
2437
+ await new Promise((resolve) => setTimeout(resolve, 50));
2438
+ // Should have emitted the handled event
2439
+ expect(emittedEvents.length).toBe(1);
2440
+ expect(emittedEvents[0].event).toBe("discord:message:handled");
2441
+ expect(emittedEvents[0].data).toMatchObject({
2442
+ agentName: "test-agent",
2443
+ channelId: "channel1",
2444
+ messageId: "msg1",
2445
+ jobId: "job-456",
2446
+ });
2447
+ });
2448
+ it("emits discord:message:error event on message handling failure", async () => {
2449
+ const eventEmitter = new EventEmitter();
2450
+ const emittedEvents = [];
2451
+ // Track emitted events
2452
+ eventEmitter.on("discord:message:error", (data) => {
2453
+ emittedEvents.push({ event: "discord:message:error", data });
2454
+ });
2455
+ const triggerMock = vi.fn().mockRejectedValue(new Error("Execution failed"));
2456
+ const emitterWithTrigger = Object.assign(eventEmitter, {
2457
+ trigger: triggerMock,
2458
+ });
2459
+ const config = {
2460
+ fleet: { name: "test-fleet" },
2461
+ agents: [
2462
+ createDiscordAgent("test-agent", {
2463
+ bot_token_env: "TEST_BOT_TOKEN",
2464
+ session_expiry_hours: 24,
2465
+ log_level: "standard",
2466
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
2467
+ guilds: [],
2468
+ }),
2469
+ ],
2470
+ configPath: "/test/herdctl.yaml",
2471
+ configDir: "/test",
2472
+ };
2473
+ const mockContext = {
2474
+ getConfig: () => config,
2475
+ getStateDir: () => "/tmp/test-state",
2476
+ getStateDirInfo: () => null,
2477
+ getLogger: () => mockLogger,
2478
+ getScheduler: () => null,
2479
+ getStatus: () => "running",
2480
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2481
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2482
+ getStoppedAt: () => null,
2483
+ getLastError: () => null,
2484
+ getCheckInterval: () => 1000,
2485
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
2486
+ getEmitter: () => emitterWithTrigger,
2487
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2488
+ };
2489
+ const manager = new DiscordManager(mockContext);
2490
+ const mockConnector = new EventEmitter();
2491
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2492
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2493
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2494
+ mockConnector.getState = vi.fn().mockReturnValue({
2495
+ status: "connected",
2496
+ connectedAt: "2024-01-01T00:00:00.000Z",
2497
+ disconnectedAt: null,
2498
+ reconnectAttempts: 0,
2499
+ lastError: null,
2500
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2501
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2502
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2503
+ });
2504
+ mockConnector.agentName = "test-agent";
2505
+ mockConnector.sessionManager = {
2506
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
2507
+ touchSession: vi.fn().mockResolvedValue(undefined),
2508
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2509
+ };
2510
+ // @ts-expect-error - accessing private property for testing
2511
+ manager.connectors.set("test-agent", mockConnector);
2512
+ // @ts-expect-error - accessing private property for testing
2513
+ manager.initialized = true;
2514
+ await manager.start();
2515
+ // Create a mock message event
2516
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2517
+ const messageEvent = {
2518
+ agentName: "test-agent",
2519
+ prompt: "Hello bot!",
2520
+ context: {
2521
+ messages: [],
2522
+ wasMentioned: true,
2523
+ prompt: "Hello bot!",
2524
+ },
2525
+ metadata: {
2526
+ guildId: "guild1",
2527
+ channelId: "channel1",
2528
+ messageId: "msg1",
2529
+ userId: "user1",
2530
+ username: "TestUser",
2531
+ wasMentioned: true,
2532
+ mode: "mention",
2533
+ },
2534
+ reply: replyMock,
2535
+ startTyping: () => () => { },
2536
+ };
2537
+ // Emit the message event
2538
+ mockConnector.emit("message", messageEvent);
2539
+ // Wait for async processing
2540
+ await new Promise((resolve) => setTimeout(resolve, 50));
2541
+ // Should have emitted the error event
2542
+ expect(emittedEvents.length).toBe(1);
2543
+ expect(emittedEvents[0].event).toBe("discord:message:error");
2544
+ expect(emittedEvents[0].data).toMatchObject({
2545
+ agentName: "test-agent",
2546
+ channelId: "channel1",
2547
+ messageId: "msg1",
2548
+ error: "Execution failed",
2549
+ });
2550
+ });
2551
+ it("emits discord:error event on connector error", async () => {
2552
+ const eventEmitter = new EventEmitter();
2553
+ const emittedEvents = [];
2554
+ // Track emitted events
2555
+ eventEmitter.on("discord:error", (data) => {
2556
+ emittedEvents.push({ event: "discord:error", data });
2557
+ });
2558
+ const mockContext = {
2559
+ getConfig: () => null,
2560
+ getStateDir: () => "/tmp/test-state",
2561
+ getStateDirInfo: () => null,
2562
+ getLogger: () => mockLogger,
2563
+ getScheduler: () => null,
2564
+ getStatus: () => "running",
2565
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2566
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2567
+ getStoppedAt: () => null,
2568
+ getLastError: () => null,
2569
+ getCheckInterval: () => 1000,
2570
+ emit: (event, ...args) => eventEmitter.emit(event, ...args),
2571
+ getEmitter: () => eventEmitter,
2572
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2573
+ };
2574
+ const manager = new DiscordManager(mockContext);
2575
+ const mockConnector = new EventEmitter();
2576
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2577
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2578
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2579
+ mockConnector.getState = vi.fn().mockReturnValue({
2580
+ status: "connected",
2581
+ connectedAt: "2024-01-01T00:00:00.000Z",
2582
+ disconnectedAt: null,
2583
+ reconnectAttempts: 0,
2584
+ lastError: null,
2585
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2586
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2587
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2588
+ });
2589
+ mockConnector.agentName = "test-agent";
2590
+ mockConnector.sessionManager = {
2591
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2592
+ };
2593
+ // @ts-expect-error - accessing private property for testing
2594
+ manager.connectors.set("test-agent", mockConnector);
2595
+ // @ts-expect-error - accessing private property for testing
2596
+ manager.initialized = true;
2597
+ await manager.start();
2598
+ // Emit error event from connector
2599
+ const errorEvent = {
2600
+ agentName: "test-agent",
2601
+ error: new Error("Connector error"),
2602
+ };
2603
+ mockConnector.emit("error", errorEvent);
2604
+ // Wait for async processing
2605
+ await new Promise((resolve) => setTimeout(resolve, 10));
2606
+ // Should have emitted the discord:error event
2607
+ expect(emittedEvents.length).toBe(1);
2608
+ expect(emittedEvents[0].event).toBe("discord:error");
2609
+ expect(emittedEvents[0].data).toMatchObject({
2610
+ agentName: "test-agent",
2611
+ error: "Connector error",
2612
+ });
2613
+ });
2614
+ it("handles reply failure when agent not found", async () => {
2615
+ const config = {
2616
+ fleet: { name: "test-fleet" },
2617
+ agents: [], // No agents!
2618
+ configPath: "/test/herdctl.yaml",
2619
+ configDir: "/test",
2620
+ };
2621
+ const mockContext = {
2622
+ getConfig: () => config,
2623
+ getStateDir: () => "/tmp/test-state",
2624
+ getStateDirInfo: () => null,
2625
+ getLogger: () => mockLogger,
2626
+ getScheduler: () => null,
2627
+ getStatus: () => "running",
2628
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2629
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2630
+ getStoppedAt: () => null,
2631
+ getLastError: () => null,
2632
+ getCheckInterval: () => 1000,
2633
+ emit: () => true,
2634
+ getEmitter: () => new EventEmitter(),
2635
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2636
+ };
2637
+ const manager = new DiscordManager(mockContext);
2638
+ const mockConnector = new EventEmitter();
2639
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2640
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2641
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2642
+ mockConnector.getState = vi.fn().mockReturnValue({
2643
+ status: "connected",
2644
+ connectedAt: "2024-01-01T00:00:00.000Z",
2645
+ disconnectedAt: null,
2646
+ reconnectAttempts: 0,
2647
+ lastError: null,
2648
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2649
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2650
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2651
+ });
2652
+ mockConnector.agentName = "unknown-agent";
2653
+ mockConnector.sessionManager = {
2654
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2655
+ };
2656
+ // @ts-expect-error - accessing private property for testing
2657
+ manager.connectors.set("unknown-agent", mockConnector);
2658
+ // @ts-expect-error - accessing private property for testing
2659
+ manager.initialized = true;
2660
+ await manager.start();
2661
+ // Reply that fails
2662
+ const replyMock = vi.fn().mockRejectedValue(new Error("Reply failed"));
2663
+ const messageEvent = {
2664
+ agentName: "unknown-agent",
2665
+ prompt: "Hello bot!",
2666
+ context: {
2667
+ messages: [],
2668
+ wasMentioned: true,
2669
+ prompt: "Hello bot!",
2670
+ },
2671
+ metadata: {
2672
+ guildId: "guild1",
2673
+ channelId: "channel1",
2674
+ messageId: "msg1",
2675
+ userId: "user1",
2676
+ username: "TestUser",
2677
+ wasMentioned: true,
2678
+ mode: "mention",
2679
+ },
2680
+ reply: replyMock,
2681
+ startTyping: () => () => { },
2682
+ };
2683
+ // Emit the message event
2684
+ mockConnector.emit("message", messageEvent);
2685
+ // Wait for async processing
2686
+ await new Promise((resolve) => setTimeout(resolve, 50));
2687
+ // Should have logged both the agent not found error and the reply failure
2688
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
2689
+ expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
2690
+ });
2691
+ it("sends default response when job produces no output", async () => {
2692
+ const eventEmitter = new EventEmitter();
2693
+ // Trigger that returns a successful result but doesn't send any messages
2694
+ const triggerMock = vi.fn().mockImplementation(async () => {
2695
+ return {
2696
+ jobId: "job-789",
2697
+ agentName: "test-agent",
2698
+ scheduleName: null,
2699
+ startedAt: new Date().toISOString(),
2700
+ success: true,
2701
+ };
2702
+ });
2703
+ const emitterWithTrigger = Object.assign(eventEmitter, {
2704
+ trigger: triggerMock,
2705
+ });
2706
+ const config = {
2707
+ fleet: { name: "test-fleet" },
2708
+ agents: [
2709
+ createDiscordAgent("test-agent", {
2710
+ bot_token_env: "TEST_BOT_TOKEN",
2711
+ session_expiry_hours: 24,
2712
+ log_level: "standard",
2713
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
2714
+ guilds: [],
2715
+ }),
2716
+ ],
2717
+ configPath: "/test/herdctl.yaml",
2718
+ configDir: "/test",
2719
+ };
2720
+ const mockContext = {
2721
+ getConfig: () => config,
2722
+ getStateDir: () => "/tmp/test-state",
2723
+ getStateDirInfo: () => null,
2724
+ getLogger: () => mockLogger,
2725
+ getScheduler: () => null,
2726
+ getStatus: () => "running",
2727
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2728
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2729
+ getStoppedAt: () => null,
2730
+ getLastError: () => null,
2731
+ getCheckInterval: () => 1000,
2732
+ emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
2733
+ getEmitter: () => emitterWithTrigger,
2734
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2735
+ };
2736
+ const manager = new DiscordManager(mockContext);
2737
+ const mockConnector = new EventEmitter();
2738
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2739
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2740
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2741
+ mockConnector.getState = vi.fn().mockReturnValue({
2742
+ status: "connected",
2743
+ connectedAt: "2024-01-01T00:00:00.000Z",
2744
+ disconnectedAt: null,
2745
+ reconnectAttempts: 0,
2746
+ lastError: null,
2747
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2748
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2749
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2750
+ });
2751
+ mockConnector.agentName = "test-agent";
2752
+ mockConnector.sessionManager = {
2753
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
2754
+ touchSession: vi.fn().mockResolvedValue(undefined),
2755
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2756
+ };
2757
+ // @ts-expect-error - accessing private property for testing
2758
+ manager.connectors.set("test-agent", mockConnector);
2759
+ // @ts-expect-error - accessing private property for testing
2760
+ manager.initialized = true;
2761
+ await manager.start();
2762
+ // Create a mock message event
2763
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2764
+ const messageEvent = {
2765
+ agentName: "test-agent",
2766
+ prompt: "Hello bot!",
2767
+ context: {
2768
+ messages: [],
2769
+ wasMentioned: true,
2770
+ prompt: "Hello bot!",
2771
+ },
2772
+ metadata: {
2773
+ guildId: "guild1",
2774
+ channelId: "channel1",
2775
+ messageId: "msg1",
2776
+ userId: "user1",
2777
+ username: "TestUser",
2778
+ wasMentioned: true,
2779
+ mode: "mention",
2780
+ },
2781
+ reply: replyMock,
2782
+ startTyping: () => () => { },
2783
+ };
2784
+ // Emit the message event
2785
+ mockConnector.emit("message", messageEvent);
2786
+ // Wait for async processing
2787
+ await new Promise((resolve) => setTimeout(resolve, 50));
2788
+ // Should have sent the default "no output" message
2789
+ expect(replyMock).toHaveBeenCalledWith("I've completed the task, but I don't have a specific response to share.");
2790
+ });
2791
+ });
2792
+ // Output configuration tests are skipped pending refactor to work with the new architecture
2793
+ // These tests rely on the message handling infrastructure which has been refactored
2794
+ describe.skip("DiscordManager output configuration", () => {
2795
+ beforeEach(() => {
2796
+ vi.clearAllMocks();
2797
+ });
2798
+ afterEach(() => {
2799
+ vi.restoreAllMocks();
2800
+ });
2801
+ it("does not send tool result embeds when tool_results is disabled", async () => {
2802
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
2803
+ if (options?.onMessage) {
2804
+ // Claude decides to use Bash tool
2805
+ await options.onMessage({
2806
+ type: "assistant",
2807
+ message: {
2808
+ content: [
2809
+ { type: "text", text: "Let me run that." },
2810
+ { type: "tool_use", name: "Bash", id: "tool-1", input: { command: "ls" } },
2811
+ ],
2812
+ },
2813
+ });
2814
+ // Tool result
2815
+ await options.onMessage({
2816
+ type: "user",
2817
+ message: {
2818
+ content: [
2819
+ { type: "tool_result", tool_use_id: "tool-1", content: "file1.txt\nfile2.txt" },
2820
+ ],
2821
+ },
2822
+ });
2823
+ // Final response
2824
+ await options.onMessage({
2825
+ type: "assistant",
2826
+ message: {
2827
+ content: [{ type: "text", text: "Done!" }],
2828
+ },
2829
+ });
2830
+ }
2831
+ return { jobId: "job-123", success: true };
2832
+ });
2833
+ const streamingEmitter = Object.assign(new EventEmitter(), {
2834
+ trigger: customTriggerMock,
2835
+ });
2836
+ // Agent with tool_results disabled
2837
+ const config = {
2838
+ fleet: { name: "test-fleet" },
2839
+ agents: [
2840
+ {
2841
+ name: "no-tool-results-agent",
2842
+ model: "sonnet",
2843
+ runtime: "sdk",
2844
+ schedules: {},
2845
+ chat: {
2846
+ discord: {
2847
+ bot_token_env: "TEST_TOKEN",
2848
+ session_expiry_hours: 24,
2849
+ log_level: "standard",
2850
+ output: {
2851
+ tool_results: false,
2852
+ tool_result_max_length: 900,
2853
+ system_status: true,
2854
+ result_summary: false,
2855
+ errors: true,
2856
+ },
2857
+ guilds: [],
2858
+ },
2859
+ },
2860
+ configPath: "/test/herdctl.yaml",
2861
+ },
2862
+ ],
2863
+ configPath: "/test/herdctl.yaml",
2864
+ configDir: "/test",
2865
+ };
2866
+ const mockContext = {
2867
+ getConfig: () => config,
2868
+ getStateDir: () => "/tmp/test-state",
2869
+ getStateDirInfo: () => null,
2870
+ getLogger: () => mockLogger,
2871
+ getScheduler: () => null,
2872
+ getStatus: () => "running",
2873
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2874
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2875
+ getStoppedAt: () => null,
2876
+ getLastError: () => null,
2877
+ getCheckInterval: () => 1000,
2878
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
2879
+ getEmitter: () => streamingEmitter,
2880
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2881
+ };
2882
+ const manager = new DiscordManager(mockContext);
2883
+ const mockConnector = new EventEmitter();
2884
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
2885
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
2886
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
2887
+ mockConnector.getState = vi.fn().mockReturnValue({
2888
+ status: "connected",
2889
+ connectedAt: "2024-01-01T00:00:00.000Z",
2890
+ disconnectedAt: null,
2891
+ reconnectAttempts: 0,
2892
+ lastError: null,
2893
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
2894
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
2895
+ messageStats: { received: 0, sent: 0, ignored: 0 },
2896
+ });
2897
+ mockConnector.agentName = "no-tool-results-agent";
2898
+ mockConnector.sessionManager = {
2899
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
2900
+ getSession: vi.fn().mockResolvedValue(null),
2901
+ setSession: vi.fn().mockResolvedValue(undefined),
2902
+ touchSession: vi.fn().mockResolvedValue(undefined),
2903
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
2904
+ };
2905
+ // @ts-expect-error - accessing private property for testing
2906
+ manager.connectors.set("no-tool-results-agent", mockConnector);
2907
+ // @ts-expect-error - accessing private property for testing
2908
+ manager.initialized = true;
2909
+ await manager.start();
2910
+ const replyMock = vi.fn().mockResolvedValue(undefined);
2911
+ const messageEvent = {
2912
+ agentName: "no-tool-results-agent",
2913
+ prompt: "List files",
2914
+ context: { messages: [], wasMentioned: true, prompt: "List files" },
2915
+ metadata: {
2916
+ guildId: "guild1",
2917
+ channelId: "channel1",
2918
+ messageId: "msg1",
2919
+ userId: "user1",
2920
+ username: "TestUser",
2921
+ wasMentioned: true,
2922
+ mode: "mention",
2923
+ },
2924
+ reply: replyMock,
2925
+ startTyping: () => () => { },
2926
+ };
2927
+ mockConnector.emit("message", messageEvent);
2928
+ await new Promise((resolve) => setTimeout(resolve, 3000));
2929
+ // Should have sent text responses but NO embed payloads
2930
+ expect(replyMock).toHaveBeenCalledWith("Let me run that.");
2931
+ expect(replyMock).toHaveBeenCalledWith("Done!");
2932
+ // Should NOT have sent any embeds for tool results
2933
+ const embedCalls = replyMock.mock.calls.filter((call) => {
2934
+ const payload = call[0];
2935
+ return typeof payload === "object" && payload !== null && "embeds" in payload;
2936
+ });
2937
+ expect(embedCalls.length).toBe(0);
2938
+ }, 10000);
2939
+ it("sends system status embed when system_status is enabled", async () => {
2940
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
2941
+ if (options?.onMessage) {
2942
+ // Send a system status message
2943
+ await options.onMessage({
2944
+ type: "system",
2945
+ subtype: "status",
2946
+ status: "compacting",
2947
+ });
2948
+ }
2949
+ return { jobId: "job-123", success: true };
2950
+ });
2951
+ const streamingEmitter = Object.assign(new EventEmitter(), {
2952
+ trigger: customTriggerMock,
2953
+ });
2954
+ const config = {
2955
+ fleet: { name: "test-fleet" },
2956
+ agents: [
2957
+ {
2958
+ name: "system-status-agent",
2959
+ model: "sonnet",
2960
+ runtime: "sdk",
2961
+ schedules: {},
2962
+ chat: {
2963
+ discord: {
2964
+ bot_token_env: "TEST_TOKEN",
2965
+ session_expiry_hours: 24,
2966
+ log_level: "standard",
2967
+ output: {
2968
+ tool_results: true,
2969
+ tool_result_max_length: 900,
2970
+ system_status: true,
2971
+ result_summary: false,
2972
+ errors: true,
2973
+ },
2974
+ guilds: [],
2975
+ },
2976
+ },
2977
+ configPath: "/test/herdctl.yaml",
2978
+ },
2979
+ ],
2980
+ configPath: "/test/herdctl.yaml",
2981
+ configDir: "/test",
2982
+ };
2983
+ const mockContext = {
2984
+ getConfig: () => config,
2985
+ getStateDir: () => "/tmp/test-state",
2986
+ getStateDirInfo: () => null,
2987
+ getLogger: () => mockLogger,
2988
+ getScheduler: () => null,
2989
+ getStatus: () => "running",
2990
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
2991
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
2992
+ getStoppedAt: () => null,
2993
+ getLastError: () => null,
2994
+ getCheckInterval: () => 1000,
2995
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
2996
+ getEmitter: () => streamingEmitter,
2997
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
2998
+ };
2999
+ const manager = new DiscordManager(mockContext);
3000
+ const mockConnector = new EventEmitter();
3001
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
3002
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
3003
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
3004
+ mockConnector.getState = vi.fn().mockReturnValue({
3005
+ status: "connected",
3006
+ connectedAt: "2024-01-01T00:00:00.000Z",
3007
+ disconnectedAt: null,
3008
+ reconnectAttempts: 0,
3009
+ lastError: null,
3010
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
3011
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
3012
+ messageStats: { received: 0, sent: 0, ignored: 0 },
3013
+ });
3014
+ mockConnector.agentName = "system-status-agent";
3015
+ mockConnector.sessionManager = {
3016
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
3017
+ getSession: vi.fn().mockResolvedValue(null),
3018
+ setSession: vi.fn().mockResolvedValue(undefined),
3019
+ touchSession: vi.fn().mockResolvedValue(undefined),
3020
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
3021
+ };
3022
+ // @ts-expect-error - accessing private property for testing
3023
+ manager.connectors.set("system-status-agent", mockConnector);
3024
+ // @ts-expect-error - accessing private property for testing
3025
+ manager.initialized = true;
3026
+ await manager.start();
3027
+ const replyMock = vi.fn().mockResolvedValue(undefined);
3028
+ const messageEvent = {
3029
+ agentName: "system-status-agent",
3030
+ prompt: "Hello",
3031
+ context: { messages: [], wasMentioned: true, prompt: "Hello" },
3032
+ metadata: {
3033
+ guildId: "guild1",
3034
+ channelId: "channel1",
3035
+ messageId: "msg1",
3036
+ userId: "user1",
3037
+ username: "TestUser",
3038
+ wasMentioned: true,
3039
+ mode: "mention",
3040
+ },
3041
+ reply: replyMock,
3042
+ startTyping: () => () => { },
3043
+ };
3044
+ mockConnector.emit("message", messageEvent);
3045
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3046
+ // Should have sent a system status embed
3047
+ const embedCalls = replyMock.mock.calls.filter((call) => {
3048
+ const payload = call[0];
3049
+ return typeof payload === "object" && payload !== null && "embeds" in payload;
3050
+ });
3051
+ expect(embedCalls.length).toBe(1);
3052
+ const embed = embedCalls[0][0].embeds[0];
3053
+ expect(embed.title).toContain("System");
3054
+ expect(embed.description).toContain("Compacting context");
3055
+ }, 10000);
3056
+ it("does not send system status embed when system_status is disabled", async () => {
3057
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
3058
+ if (options?.onMessage) {
3059
+ await options.onMessage({
3060
+ type: "system",
3061
+ subtype: "status",
3062
+ status: "compacting",
3063
+ });
3064
+ }
3065
+ return { jobId: "job-123", success: true };
3066
+ });
3067
+ const streamingEmitter = Object.assign(new EventEmitter(), {
3068
+ trigger: customTriggerMock,
3069
+ });
3070
+ const config = {
3071
+ fleet: { name: "test-fleet" },
3072
+ agents: [
3073
+ {
3074
+ name: "no-system-status-agent",
3075
+ model: "sonnet",
3076
+ runtime: "sdk",
3077
+ schedules: {},
3078
+ chat: {
3079
+ discord: {
3080
+ bot_token_env: "TEST_TOKEN",
3081
+ session_expiry_hours: 24,
3082
+ log_level: "standard",
3083
+ output: {
3084
+ tool_results: true,
3085
+ tool_result_max_length: 900,
3086
+ system_status: false,
3087
+ result_summary: false,
3088
+ errors: true,
3089
+ },
3090
+ guilds: [],
3091
+ },
3092
+ },
3093
+ configPath: "/test/herdctl.yaml",
3094
+ },
3095
+ ],
3096
+ configPath: "/test/herdctl.yaml",
3097
+ configDir: "/test",
3098
+ };
3099
+ const mockContext = {
3100
+ getConfig: () => config,
3101
+ getStateDir: () => "/tmp/test-state",
3102
+ getStateDirInfo: () => null,
3103
+ getLogger: () => mockLogger,
3104
+ getScheduler: () => null,
3105
+ getStatus: () => "running",
3106
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
3107
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
3108
+ getStoppedAt: () => null,
3109
+ getLastError: () => null,
3110
+ getCheckInterval: () => 1000,
3111
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
3112
+ getEmitter: () => streamingEmitter,
3113
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
3114
+ };
3115
+ const manager = new DiscordManager(mockContext);
3116
+ const mockConnector = new EventEmitter();
3117
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
3118
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
3119
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
3120
+ mockConnector.getState = vi.fn().mockReturnValue({
3121
+ status: "connected",
3122
+ connectedAt: "2024-01-01T00:00:00.000Z",
3123
+ disconnectedAt: null,
3124
+ reconnectAttempts: 0,
3125
+ lastError: null,
3126
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
3127
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
3128
+ messageStats: { received: 0, sent: 0, ignored: 0 },
3129
+ });
3130
+ mockConnector.agentName = "no-system-status-agent";
3131
+ mockConnector.sessionManager = {
3132
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
3133
+ getSession: vi.fn().mockResolvedValue(null),
3134
+ setSession: vi.fn().mockResolvedValue(undefined),
3135
+ touchSession: vi.fn().mockResolvedValue(undefined),
3136
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
3137
+ };
3138
+ // @ts-expect-error - accessing private property for testing
3139
+ manager.connectors.set("no-system-status-agent", mockConnector);
3140
+ // @ts-expect-error - accessing private property for testing
3141
+ manager.initialized = true;
3142
+ await manager.start();
3143
+ const replyMock = vi.fn().mockResolvedValue(undefined);
3144
+ const messageEvent = {
3145
+ agentName: "no-system-status-agent",
3146
+ prompt: "Hello",
3147
+ context: { messages: [], wasMentioned: true, prompt: "Hello" },
3148
+ metadata: {
3149
+ guildId: "guild1",
3150
+ channelId: "channel1",
3151
+ messageId: "msg1",
3152
+ userId: "user1",
3153
+ username: "TestUser",
3154
+ wasMentioned: true,
3155
+ mode: "mention",
3156
+ },
3157
+ reply: replyMock,
3158
+ startTyping: () => () => { },
3159
+ };
3160
+ mockConnector.emit("message", messageEvent);
3161
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3162
+ // Should NOT have sent any embeds
3163
+ const embedCalls = replyMock.mock.calls.filter((call) => {
3164
+ const payload = call[0];
3165
+ return typeof payload === "object" && payload !== null && "embeds" in payload;
3166
+ });
3167
+ expect(embedCalls.length).toBe(0);
3168
+ }, 10000);
3169
+ it("sends result summary embed when result_summary is enabled", async () => {
3170
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
3171
+ if (options?.onMessage) {
3172
+ await options.onMessage({
3173
+ type: "result",
3174
+ subtype: "success",
3175
+ is_error: false,
3176
+ duration_ms: 5000,
3177
+ total_cost_usd: 0.0123,
3178
+ num_turns: 3,
3179
+ usage: { input_tokens: 1000, output_tokens: 500 },
3180
+ });
3181
+ }
3182
+ return { jobId: "job-123", success: true };
3183
+ });
3184
+ const streamingEmitter = Object.assign(new EventEmitter(), {
3185
+ trigger: customTriggerMock,
3186
+ });
3187
+ const config = {
3188
+ fleet: { name: "test-fleet" },
3189
+ agents: [
3190
+ {
3191
+ name: "result-summary-agent",
3192
+ model: "sonnet",
3193
+ runtime: "sdk",
3194
+ schedules: {},
3195
+ chat: {
3196
+ discord: {
3197
+ bot_token_env: "TEST_TOKEN",
3198
+ session_expiry_hours: 24,
3199
+ log_level: "standard",
3200
+ output: {
3201
+ tool_results: true,
3202
+ tool_result_max_length: 900,
3203
+ system_status: true,
3204
+ result_summary: true,
3205
+ errors: true,
3206
+ },
3207
+ guilds: [],
3208
+ },
3209
+ },
3210
+ configPath: "/test/herdctl.yaml",
3211
+ },
3212
+ ],
3213
+ configPath: "/test/herdctl.yaml",
3214
+ configDir: "/test",
3215
+ };
3216
+ const mockContext = {
3217
+ getConfig: () => config,
3218
+ getStateDir: () => "/tmp/test-state",
3219
+ getStateDirInfo: () => null,
3220
+ getLogger: () => mockLogger,
3221
+ getScheduler: () => null,
3222
+ getStatus: () => "running",
3223
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
3224
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
3225
+ getStoppedAt: () => null,
3226
+ getLastError: () => null,
3227
+ getCheckInterval: () => 1000,
3228
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
3229
+ getEmitter: () => streamingEmitter,
3230
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
3231
+ };
3232
+ const manager = new DiscordManager(mockContext);
3233
+ const mockConnector = new EventEmitter();
3234
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
3235
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
3236
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
3237
+ mockConnector.getState = vi.fn().mockReturnValue({
3238
+ status: "connected",
3239
+ connectedAt: "2024-01-01T00:00:00.000Z",
3240
+ disconnectedAt: null,
3241
+ reconnectAttempts: 0,
3242
+ lastError: null,
3243
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
3244
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
3245
+ messageStats: { received: 0, sent: 0, ignored: 0 },
3246
+ });
3247
+ mockConnector.agentName = "result-summary-agent";
3248
+ mockConnector.sessionManager = {
3249
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
3250
+ getSession: vi.fn().mockResolvedValue(null),
3251
+ setSession: vi.fn().mockResolvedValue(undefined),
3252
+ touchSession: vi.fn().mockResolvedValue(undefined),
3253
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
3254
+ };
3255
+ // @ts-expect-error - accessing private property for testing
3256
+ manager.connectors.set("result-summary-agent", mockConnector);
3257
+ // @ts-expect-error - accessing private property for testing
3258
+ manager.initialized = true;
3259
+ await manager.start();
3260
+ const replyMock = vi.fn().mockResolvedValue(undefined);
3261
+ const messageEvent = {
3262
+ agentName: "result-summary-agent",
3263
+ prompt: "Hello",
3264
+ context: { messages: [], wasMentioned: true, prompt: "Hello" },
3265
+ metadata: {
3266
+ guildId: "guild1",
3267
+ channelId: "channel1",
3268
+ messageId: "msg1",
3269
+ userId: "user1",
3270
+ username: "TestUser",
3271
+ wasMentioned: true,
3272
+ mode: "mention",
3273
+ },
3274
+ reply: replyMock,
3275
+ startTyping: () => () => { },
3276
+ };
3277
+ mockConnector.emit("message", messageEvent);
3278
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3279
+ // Should have sent a result summary embed
3280
+ const embedCalls = replyMock.mock.calls.filter((call) => {
3281
+ const payload = call[0];
3282
+ return typeof payload === "object" && payload !== null && "embeds" in payload;
3283
+ });
3284
+ expect(embedCalls.length).toBe(1);
3285
+ const embed = embedCalls[0][0].embeds[0];
3286
+ expect(embed.title).toContain("Task Complete");
3287
+ expect(embed.fields).toBeDefined();
3288
+ const fieldNames = embed.fields.map((f) => f.name);
3289
+ expect(fieldNames).toContain("Duration");
3290
+ expect(fieldNames).toContain("Turns");
3291
+ expect(fieldNames).toContain("Cost");
3292
+ expect(fieldNames).toContain("Tokens");
3293
+ }, 10000);
3294
+ it("sends error embed for SDK error messages", async () => {
3295
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
3296
+ if (options?.onMessage) {
3297
+ await options.onMessage({
3298
+ type: "error",
3299
+ content: "Something went wrong",
3300
+ });
3301
+ }
3302
+ return { jobId: "job-123", success: false };
3303
+ });
3304
+ const streamingEmitter = Object.assign(new EventEmitter(), {
3305
+ trigger: customTriggerMock,
3306
+ });
3307
+ const config = {
3308
+ fleet: { name: "test-fleet" },
3309
+ agents: [
3310
+ {
3311
+ name: "error-agent",
3312
+ model: "sonnet",
3313
+ runtime: "sdk",
3314
+ schedules: {},
3315
+ chat: {
3316
+ discord: {
3317
+ bot_token_env: "TEST_TOKEN",
3318
+ session_expiry_hours: 24,
3319
+ log_level: "standard",
3320
+ output: {
3321
+ tool_results: true,
3322
+ tool_result_max_length: 900,
3323
+ system_status: true,
3324
+ result_summary: false,
3325
+ errors: true,
3326
+ },
3327
+ guilds: [],
3328
+ },
3329
+ },
3330
+ configPath: "/test/herdctl.yaml",
3331
+ },
3332
+ ],
3333
+ configPath: "/test/herdctl.yaml",
3334
+ configDir: "/test",
3335
+ };
3336
+ const mockContext = {
3337
+ getConfig: () => config,
3338
+ getStateDir: () => "/tmp/test-state",
3339
+ getStateDirInfo: () => null,
3340
+ getLogger: () => mockLogger,
3341
+ getScheduler: () => null,
3342
+ getStatus: () => "running",
3343
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
3344
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
3345
+ getStoppedAt: () => null,
3346
+ getLastError: () => null,
3347
+ getCheckInterval: () => 1000,
3348
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
3349
+ getEmitter: () => streamingEmitter,
3350
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
3351
+ };
3352
+ const manager = new DiscordManager(mockContext);
3353
+ const mockConnector = new EventEmitter();
3354
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
3355
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
3356
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
3357
+ mockConnector.getState = vi.fn().mockReturnValue({
3358
+ status: "connected",
3359
+ connectedAt: "2024-01-01T00:00:00.000Z",
3360
+ disconnectedAt: null,
3361
+ reconnectAttempts: 0,
3362
+ lastError: null,
3363
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
3364
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
3365
+ messageStats: { received: 0, sent: 0, ignored: 0 },
3366
+ });
3367
+ mockConnector.agentName = "error-agent";
3368
+ mockConnector.sessionManager = {
3369
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
3370
+ getSession: vi.fn().mockResolvedValue(null),
3371
+ setSession: vi.fn().mockResolvedValue(undefined),
3372
+ touchSession: vi.fn().mockResolvedValue(undefined),
3373
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
3374
+ };
3375
+ // @ts-expect-error - accessing private property for testing
3376
+ manager.connectors.set("error-agent", mockConnector);
3377
+ // @ts-expect-error - accessing private property for testing
3378
+ manager.initialized = true;
3379
+ await manager.start();
3380
+ const replyMock = vi.fn().mockResolvedValue(undefined);
3381
+ const messageEvent = {
3382
+ agentName: "error-agent",
3383
+ prompt: "Hello",
3384
+ context: { messages: [], wasMentioned: true, prompt: "Hello" },
3385
+ metadata: {
3386
+ guildId: "guild1",
3387
+ channelId: "channel1",
3388
+ messageId: "msg1",
3389
+ userId: "user1",
3390
+ username: "TestUser",
3391
+ wasMentioned: true,
3392
+ mode: "mention",
3393
+ },
3394
+ reply: replyMock,
3395
+ startTyping: () => () => { },
3396
+ };
3397
+ mockConnector.emit("message", messageEvent);
3398
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3399
+ // Should have sent an error embed
3400
+ const embedCalls = replyMock.mock.calls.filter((call) => {
3401
+ const payload = call[0];
3402
+ return typeof payload === "object" && payload !== null && "embeds" in payload;
3403
+ });
3404
+ expect(embedCalls.length).toBe(1);
3405
+ const embed = embedCalls[0][0].embeds[0];
3406
+ expect(embed.title).toContain("Error");
3407
+ expect(embed.description).toBe("Something went wrong");
3408
+ }, 10000);
3409
+ it("does not send error embed when errors is disabled", async () => {
3410
+ const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
3411
+ if (options?.onMessage) {
3412
+ await options.onMessage({
3413
+ type: "error",
3414
+ content: "Something went wrong",
3415
+ });
3416
+ }
3417
+ return { jobId: "job-123", success: false };
3418
+ });
3419
+ const streamingEmitter = Object.assign(new EventEmitter(), {
3420
+ trigger: customTriggerMock,
3421
+ });
3422
+ const config = {
3423
+ fleet: { name: "test-fleet" },
3424
+ agents: [
3425
+ {
3426
+ name: "no-errors-agent",
3427
+ model: "sonnet",
3428
+ runtime: "sdk",
3429
+ schedules: {},
3430
+ chat: {
3431
+ discord: {
3432
+ bot_token_env: "TEST_TOKEN",
3433
+ session_expiry_hours: 24,
3434
+ log_level: "standard",
3435
+ output: {
3436
+ tool_results: true,
3437
+ tool_result_max_length: 900,
3438
+ system_status: true,
3439
+ result_summary: false,
3440
+ errors: false,
3441
+ },
3442
+ guilds: [],
3443
+ },
3444
+ },
3445
+ configPath: "/test/herdctl.yaml",
3446
+ },
3447
+ ],
3448
+ configPath: "/test/herdctl.yaml",
3449
+ configDir: "/test",
3450
+ };
3451
+ const mockContext = {
3452
+ getConfig: () => config,
3453
+ getStateDir: () => "/tmp/test-state",
3454
+ getStateDirInfo: () => null,
3455
+ getLogger: () => mockLogger,
3456
+ getScheduler: () => null,
3457
+ getStatus: () => "running",
3458
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
3459
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
3460
+ getStoppedAt: () => null,
3461
+ getLastError: () => null,
3462
+ getCheckInterval: () => 1000,
3463
+ emit: (event, ...args) => streamingEmitter.emit(event, ...args),
3464
+ getEmitter: () => streamingEmitter,
3465
+ trigger: vi.fn().mockResolvedValue({ jobId: "test-job", agentName: "test", scheduleName: null, startedAt: new Date().toISOString(), success: true }),
3466
+ };
3467
+ const manager = new DiscordManager(mockContext);
3468
+ const mockConnector = new EventEmitter();
3469
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
3470
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
3471
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
3472
+ mockConnector.getState = vi.fn().mockReturnValue({
3473
+ status: "connected",
3474
+ connectedAt: "2024-01-01T00:00:00.000Z",
3475
+ disconnectedAt: null,
3476
+ reconnectAttempts: 0,
3477
+ lastError: null,
3478
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
3479
+ rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
3480
+ messageStats: { received: 0, sent: 0, ignored: 0 },
3481
+ });
3482
+ mockConnector.agentName = "no-errors-agent";
3483
+ mockConnector.sessionManager = {
3484
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
3485
+ getSession: vi.fn().mockResolvedValue(null),
3486
+ setSession: vi.fn().mockResolvedValue(undefined),
3487
+ touchSession: vi.fn().mockResolvedValue(undefined),
3488
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
3489
+ };
3490
+ // @ts-expect-error - accessing private property for testing
3491
+ manager.connectors.set("no-errors-agent", mockConnector);
3492
+ // @ts-expect-error - accessing private property for testing
3493
+ manager.initialized = true;
3494
+ await manager.start();
3495
+ const replyMock = vi.fn().mockResolvedValue(undefined);
3496
+ const messageEvent = {
3497
+ agentName: "no-errors-agent",
3498
+ prompt: "Hello",
3499
+ context: { messages: [], wasMentioned: true, prompt: "Hello" },
3500
+ metadata: {
3501
+ guildId: "guild1",
3502
+ channelId: "channel1",
3503
+ messageId: "msg1",
3504
+ userId: "user1",
3505
+ username: "TestUser",
3506
+ wasMentioned: true,
3507
+ mode: "mention",
3508
+ },
3509
+ reply: replyMock,
3510
+ startTyping: () => () => { },
3511
+ };
3512
+ mockConnector.emit("message", messageEvent);
3513
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3514
+ // Should NOT have sent any embeds
3515
+ const embedCalls = replyMock.mock.calls.filter((call) => {
3516
+ const payload = call[0];
3517
+ return typeof payload === "object" && payload !== null && "embeds" in payload;
3518
+ });
3519
+ expect(embedCalls.length).toBe(0);
3520
+ }, 10000);
3521
+ describe("buildToolEmbed with custom maxOutputChars", () => {
3522
+ it("respects custom maxOutputChars parameter", () => {
3523
+ const ctx = createMockContext(null);
3524
+ const manager = new DiscordManager(ctx);
3525
+ // Long output that exceeds both custom and default limits
3526
+ const longOutput = "x".repeat(600);
3527
+ // @ts-expect-error - accessing private method for testing
3528
+ const embed = manager.buildToolEmbed({ name: "Bash", input: { command: "cat bigfile" }, startTime: Date.now() - 100 }, { output: longOutput, isError: false }, 400 // Custom max length
3529
+ );
3530
+ const resultField = embed.fields.find((f) => f.name === "Result");
3531
+ expect(resultField).toBeDefined();
3532
+ // The result should be truncated, showing "chars total" suffix
3533
+ expect(resultField.value).toContain("chars total");
3534
+ // Verify the output starts with the truncated content (400 x's)
3535
+ expect(resultField.value).toContain("x".repeat(400));
3536
+ // Verify it does NOT contain the full output (600 x's)
3537
+ expect(resultField.value).not.toContain("x".repeat(600));
3538
+ });
3539
+ });
3540
+ });
3541
+ //# sourceMappingURL=manager.test.js.map