@herdctl/core 4.1.1 → 5.0.0

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