@herdctl/discord 1.1.0 → 1.2.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 (134) hide show
  1. package/README.md +14 -1
  2. package/dist/__tests__/attachments.test.d.ts +8 -0
  3. package/dist/__tests__/attachments.test.d.ts.map +1 -0
  4. package/dist/__tests__/attachments.test.js +439 -0
  5. package/dist/__tests__/attachments.test.js.map +1 -0
  6. package/dist/__tests__/discord-connector.test.js +4 -1
  7. package/dist/__tests__/discord-connector.test.js.map +1 -1
  8. package/dist/__tests__/embeds.test.d.ts +2 -0
  9. package/dist/__tests__/embeds.test.d.ts.map +1 -0
  10. package/dist/__tests__/embeds.test.js +47 -0
  11. package/dist/__tests__/embeds.test.js.map +1 -0
  12. package/dist/__tests__/logger.test.js +4 -1
  13. package/dist/__tests__/logger.test.js.map +1 -1
  14. package/dist/__tests__/manager.test.js +1193 -28
  15. package/dist/__tests__/manager.test.js.map +1 -1
  16. package/dist/__tests__/message-normalizer.test.d.ts +2 -0
  17. package/dist/__tests__/message-normalizer.test.d.ts.map +1 -0
  18. package/dist/__tests__/message-normalizer.test.js +83 -0
  19. package/dist/__tests__/message-normalizer.test.js.map +1 -0
  20. package/dist/__tests__/runtime-parity.test.d.ts +2 -0
  21. package/dist/__tests__/runtime-parity.test.d.ts.map +1 -0
  22. package/dist/__tests__/runtime-parity.test.js +157 -0
  23. package/dist/__tests__/runtime-parity.test.js.map +1 -0
  24. package/dist/auto-mode-handler.d.ts.map +1 -1
  25. package/dist/auto-mode-handler.js +9 -0
  26. package/dist/auto-mode-handler.js.map +1 -1
  27. package/dist/commands/__tests__/command-manager.test.js +63 -3
  28. package/dist/commands/__tests__/command-manager.test.js.map +1 -1
  29. package/dist/commands/__tests__/extended-commands.test.d.ts +2 -0
  30. package/dist/commands/__tests__/extended-commands.test.d.ts.map +1 -0
  31. package/dist/commands/__tests__/extended-commands.test.js +159 -0
  32. package/dist/commands/__tests__/extended-commands.test.js.map +1 -0
  33. package/dist/commands/__tests__/help.test.js +5 -6
  34. package/dist/commands/__tests__/help.test.js.map +1 -1
  35. package/dist/commands/__tests__/reset.test.js +14 -6
  36. package/dist/commands/__tests__/reset.test.js.map +1 -1
  37. package/dist/commands/__tests__/status.test.js +27 -25
  38. package/dist/commands/__tests__/status.test.js.map +1 -1
  39. package/dist/commands/cancel.d.ts +3 -0
  40. package/dist/commands/cancel.d.ts.map +1 -0
  41. package/dist/commands/cancel.js +7 -0
  42. package/dist/commands/cancel.js.map +1 -0
  43. package/dist/commands/command-manager.d.ts +4 -1
  44. package/dist/commands/command-manager.d.ts.map +1 -1
  45. package/dist/commands/command-manager.js +65 -3
  46. package/dist/commands/command-manager.js.map +1 -1
  47. package/dist/commands/config.d.ts +3 -0
  48. package/dist/commands/config.d.ts.map +1 -0
  49. package/dist/commands/config.js +33 -0
  50. package/dist/commands/config.js.map +1 -0
  51. package/dist/commands/help.d.ts +1 -1
  52. package/dist/commands/help.d.ts.map +1 -1
  53. package/dist/commands/help.js +26 -12
  54. package/dist/commands/help.js.map +1 -1
  55. package/dist/commands/index.d.ts +12 -1
  56. package/dist/commands/index.d.ts.map +1 -1
  57. package/dist/commands/index.js +12 -1
  58. package/dist/commands/index.js.map +1 -1
  59. package/dist/commands/new.d.ts +3 -0
  60. package/dist/commands/new.d.ts.map +1 -0
  61. package/dist/commands/new.js +22 -0
  62. package/dist/commands/new.js.map +1 -0
  63. package/dist/commands/ping.d.ts +3 -0
  64. package/dist/commands/ping.d.ts.map +1 -0
  65. package/dist/commands/ping.js +22 -0
  66. package/dist/commands/ping.js.map +1 -0
  67. package/dist/commands/reset.d.ts +1 -1
  68. package/dist/commands/reset.d.ts.map +1 -1
  69. package/dist/commands/reset.js +13 -13
  70. package/dist/commands/reset.js.map +1 -1
  71. package/dist/commands/retry.d.ts +3 -0
  72. package/dist/commands/retry.d.ts.map +1 -0
  73. package/dist/commands/retry.js +25 -0
  74. package/dist/commands/retry.js.map +1 -0
  75. package/dist/commands/session.d.ts +3 -0
  76. package/dist/commands/session.d.ts.map +1 -0
  77. package/dist/commands/session.js +47 -0
  78. package/dist/commands/session.js.map +1 -0
  79. package/dist/commands/skill.d.ts +3 -0
  80. package/dist/commands/skill.d.ts.map +1 -0
  81. package/dist/commands/skill.js +44 -0
  82. package/dist/commands/skill.js.map +1 -0
  83. package/dist/commands/skills.d.ts +3 -0
  84. package/dist/commands/skills.d.ts.map +1 -0
  85. package/dist/commands/skills.js +30 -0
  86. package/dist/commands/skills.js.map +1 -0
  87. package/dist/commands/status.d.ts +1 -1
  88. package/dist/commands/status.d.ts.map +1 -1
  89. package/dist/commands/status.js +25 -18
  90. package/dist/commands/status.js.map +1 -1
  91. package/dist/commands/stop.d.ts +3 -0
  92. package/dist/commands/stop.d.ts.map +1 -0
  93. package/dist/commands/stop.js +25 -0
  94. package/dist/commands/stop.js.map +1 -0
  95. package/dist/commands/tools.d.ts +3 -0
  96. package/dist/commands/tools.d.ts.map +1 -0
  97. package/dist/commands/tools.js +30 -0
  98. package/dist/commands/tools.js.map +1 -0
  99. package/dist/commands/types.d.ts +71 -1
  100. package/dist/commands/types.d.ts.map +1 -1
  101. package/dist/commands/usage.d.ts +3 -0
  102. package/dist/commands/usage.d.ts.map +1 -0
  103. package/dist/commands/usage.js +58 -0
  104. package/dist/commands/usage.js.map +1 -0
  105. package/dist/discord-connector.d.ts +10 -1
  106. package/dist/discord-connector.d.ts.map +1 -1
  107. package/dist/discord-connector.js +153 -8
  108. package/dist/discord-connector.js.map +1 -1
  109. package/dist/embeds.d.ts +47 -0
  110. package/dist/embeds.d.ts.map +1 -0
  111. package/dist/embeds.js +121 -0
  112. package/dist/embeds.js.map +1 -0
  113. package/dist/index.d.ts +6 -2
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +3 -1
  116. package/dist/index.js.map +1 -1
  117. package/dist/manager.d.ts +53 -24
  118. package/dist/manager.d.ts.map +1 -1
  119. package/dist/manager.js +1031 -217
  120. package/dist/manager.js.map +1 -1
  121. package/dist/mention-handler.d.ts.map +1 -1
  122. package/dist/mention-handler.js +27 -0
  123. package/dist/mention-handler.js.map +1 -1
  124. package/dist/message-normalizer.d.ts +40 -0
  125. package/dist/message-normalizer.d.ts.map +1 -0
  126. package/dist/message-normalizer.js +99 -0
  127. package/dist/message-normalizer.js.map +1 -0
  128. package/dist/types.d.ts +80 -3
  129. package/dist/types.d.ts.map +1 -1
  130. package/dist/voice-transcriber.d.ts +31 -0
  131. package/dist/voice-transcriber.d.ts.map +1 -0
  132. package/dist/voice-transcriber.js +44 -0
  133. package/dist/voice-transcriber.js.map +1 -0
  134. package/package.json +3 -3
@@ -133,9 +133,12 @@ describe("DiscordManager", () => {
133
133
  tool_results: true,
134
134
  tool_result_max_length: 900,
135
135
  system_status: true,
136
- result_summary: false,
136
+ result_summary: true,
137
137
  typing_indicator: true,
138
138
  errors: true,
139
+ acknowledge_emoji: "eyes",
140
+ assistant_messages: "answers",
141
+ progress_indicator: true,
139
142
  },
140
143
  guilds: [],
141
144
  };
@@ -183,6 +186,140 @@ describe("DiscordManager", () => {
183
186
  expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to stop");
184
187
  });
185
188
  });
189
+ describe("retry channel run controls", () => {
190
+ it("routes retry through the normal handleMessage pipeline", async () => {
191
+ const ctx = createMockContext(null);
192
+ const manager = new DiscordManager(ctx);
193
+ const managerAny = manager;
194
+ const mockSend = vi.fn().mockResolvedValue({
195
+ edit: vi.fn().mockResolvedValue(undefined),
196
+ delete: vi.fn().mockResolvedValue(undefined),
197
+ });
198
+ const mockChannel = {
199
+ isTextBased: () => true,
200
+ isDMBased: () => false,
201
+ guildId: "guild-1",
202
+ send: mockSend,
203
+ };
204
+ managerAny.connectors = new Map([
205
+ [
206
+ "agent-1",
207
+ {
208
+ client: {
209
+ isReady: () => true,
210
+ channels: { fetch: vi.fn().mockResolvedValue(mockChannel) },
211
+ },
212
+ },
213
+ ],
214
+ ]);
215
+ managerAny.lastPromptByChannel.set("agent-1:channel-1", "retry prompt");
216
+ const handleMessageSpy = vi
217
+ .spyOn(managerAny, "handleMessage")
218
+ .mockResolvedValue(undefined);
219
+ const result = await managerAny.retryChannelRun("agent-1", "channel-1");
220
+ await new Promise((resolve) => setTimeout(resolve, 0));
221
+ expect(result.success).toBe(true);
222
+ expect(handleMessageSpy).toHaveBeenCalledTimes(1);
223
+ const [, event] = handleMessageSpy.mock.calls[0];
224
+ expect(event.prompt).toBe("retry prompt");
225
+ expect(event.metadata.channelId).toBe("channel-1");
226
+ });
227
+ it("catches background retry failures and posts an error message", async () => {
228
+ const ctx = createMockContext(null);
229
+ const manager = new DiscordManager(ctx);
230
+ const managerAny = manager;
231
+ const mockSend = vi.fn().mockResolvedValue({
232
+ edit: vi.fn().mockResolvedValue(undefined),
233
+ delete: vi.fn().mockResolvedValue(undefined),
234
+ });
235
+ const mockChannel = {
236
+ isTextBased: () => true,
237
+ isDMBased: () => false,
238
+ guildId: "guild-1",
239
+ send: mockSend,
240
+ };
241
+ managerAny.connectors = new Map([
242
+ [
243
+ "agent-1",
244
+ {
245
+ client: {
246
+ isReady: () => true,
247
+ channels: { fetch: vi.fn().mockResolvedValue(mockChannel) },
248
+ },
249
+ },
250
+ ],
251
+ ]);
252
+ managerAny.lastPromptByChannel.set("agent-1:channel-1", "retry prompt");
253
+ vi.spyOn(managerAny, "handleMessage").mockRejectedValue(new Error("retry boom"));
254
+ const result = await managerAny.retryChannelRun("agent-1", "channel-1");
255
+ await new Promise((resolve) => setTimeout(resolve, 0));
256
+ expect(result.success).toBe(true);
257
+ expect(mockSend).toHaveBeenCalled();
258
+ });
259
+ });
260
+ describe("skill discovery and validation", () => {
261
+ it("uses explicit chat.discord.skills when working_directory is not set", async () => {
262
+ const discordConfig = {
263
+ bot_token_env: "TEST_TOKEN",
264
+ session_expiry_hours: 24,
265
+ log_level: "standard",
266
+ output: {
267
+ tool_results: true,
268
+ tool_result_max_length: 900,
269
+ system_status: true,
270
+ result_summary: true,
271
+ typing_indicator: true,
272
+ errors: true,
273
+ acknowledge_emoji: "eyes",
274
+ assistant_messages: "answers",
275
+ progress_indicator: true,
276
+ },
277
+ guilds: [{ id: "g1" }],
278
+ skills: [{ name: "pdf", description: "Work with PDFs" }],
279
+ };
280
+ const config = {
281
+ fleet: { name: "test-fleet" },
282
+ agents: [createDiscordAgent("agent1", discordConfig)],
283
+ configPath: "/test/herdctl.yaml",
284
+ configDir: "/test",
285
+ };
286
+ const manager = new DiscordManager(createMockContext(config));
287
+ const managerAny = manager;
288
+ const skills = await managerAny.discoverAgentSkills(config.agents[0]);
289
+ expect(skills.map((s) => s.name)).toContain("pdf");
290
+ });
291
+ it("rejects unknown /skill before attempting execution", async () => {
292
+ const discordConfig = {
293
+ bot_token_env: "TEST_TOKEN",
294
+ session_expiry_hours: 24,
295
+ log_level: "standard",
296
+ output: {
297
+ tool_results: true,
298
+ tool_result_max_length: 900,
299
+ system_status: true,
300
+ result_summary: true,
301
+ typing_indicator: true,
302
+ errors: true,
303
+ acknowledge_emoji: "eyes",
304
+ assistant_messages: "answers",
305
+ progress_indicator: true,
306
+ },
307
+ guilds: [{ id: "g1" }],
308
+ skills: [{ name: "pdf" }],
309
+ };
310
+ const config = {
311
+ fleet: { name: "test-fleet" },
312
+ agents: [createDiscordAgent("agent1", discordConfig)],
313
+ configPath: "/test/herdctl.yaml",
314
+ configDir: "/test",
315
+ };
316
+ const manager = new DiscordManager(createMockContext(config));
317
+ const managerAny = manager;
318
+ const result = await managerAny.runChannelSkill("agent1", "channel-1", "nonexistent");
319
+ expect(result.success).toBe(false);
320
+ expect(result.message).toContain("Unknown skill");
321
+ });
322
+ });
186
323
  describe("getConnector", () => {
187
324
  it("returns undefined for non-existent agent", () => {
188
325
  const ctx = createMockContext(null);
@@ -330,10 +467,14 @@ describe("DiscordMessageEvent type", () => {
330
467
  wasMentioned: true,
331
468
  mode: "mention",
332
469
  },
333
- reply: async (content) => {
334
- console.log("Reply:", content);
335
- },
470
+ reply: vi.fn().mockResolvedValue(undefined),
336
471
  startTyping: () => () => { },
472
+ addReaction: vi.fn().mockResolvedValue(undefined),
473
+ removeReaction: vi.fn().mockResolvedValue(undefined),
474
+ replyWithRef: vi.fn().mockResolvedValue({
475
+ edit: vi.fn().mockResolvedValue(undefined),
476
+ delete: vi.fn().mockResolvedValue(undefined),
477
+ }),
337
478
  };
338
479
  expect(event.agentName).toBe("test-agent");
339
480
  expect(event.prompt).toBe("Hello, how are you?");
@@ -360,6 +501,12 @@ describe("DiscordMessageEvent type", () => {
360
501
  },
361
502
  reply: async () => { },
362
503
  startTyping: () => () => { },
504
+ addReaction: vi.fn().mockResolvedValue(undefined),
505
+ removeReaction: vi.fn().mockResolvedValue(undefined),
506
+ replyWithRef: vi.fn().mockResolvedValue({
507
+ edit: vi.fn().mockResolvedValue(undefined),
508
+ delete: vi.fn().mockResolvedValue(undefined),
509
+ }),
363
510
  };
364
511
  expect(event.metadata.guildId).toBeNull();
365
512
  expect(event.metadata.mode).toBe("auto");
@@ -404,6 +551,9 @@ describe("DiscordManager typing_indicator config", () => {
404
551
  result_summary: false,
405
552
  typing_indicator: false,
406
553
  errors: true,
554
+ acknowledge_emoji: "👀",
555
+ assistant_messages: "answers",
556
+ progress_indicator: false,
407
557
  },
408
558
  guilds: [],
409
559
  }),
@@ -483,6 +633,9 @@ describe("DiscordManager typing_indicator config", () => {
483
633
  },
484
634
  reply: replyMock,
485
635
  startTyping: startTypingMock,
636
+ addReaction: vi.fn().mockResolvedValue(undefined),
637
+ removeReaction: vi.fn().mockResolvedValue(undefined),
638
+ replyWithRef: vi.fn().mockResolvedValue({ messageId: "ref-msg" }),
486
639
  };
487
640
  mockConnector.emit("message", messageEvent);
488
641
  await new Promise((resolve) => setTimeout(resolve, 2500));
@@ -659,35 +812,743 @@ describe.skip("DiscordManager response splitting", () => {
659
812
  expect(result).toContain("/reset");
660
813
  expect(result).toContain("Please try again");
661
814
  });
662
- it("handles errors with special characters", () => {
663
- const error = new Error("Error with `code` and *markdown*");
664
- const result = manager.formatErrorMessage(error);
665
- expect(result).toContain("Error with `code` and *markdown*");
815
+ it("handles errors with special characters", () => {
816
+ const error = new Error("Error with `code` and *markdown*");
817
+ const result = manager.formatErrorMessage(error);
818
+ expect(result).toContain("Error with `code` and *markdown*");
819
+ });
820
+ });
821
+ describe("sendResponse", () => {
822
+ it("sends single message for short content", async () => {
823
+ const replyMock = vi.fn().mockResolvedValue(undefined);
824
+ await manager.sendResponse(replyMock, "Short message");
825
+ expect(replyMock).toHaveBeenCalledTimes(1);
826
+ expect(replyMock).toHaveBeenCalledWith("Short message");
827
+ });
828
+ it("sends multiple messages for long content", async () => {
829
+ const replyMock = vi.fn().mockResolvedValue(undefined);
830
+ const longText = "word ".repeat(500); // About 2500 chars
831
+ await manager.sendResponse(replyMock, longText);
832
+ expect(replyMock).toHaveBeenCalledTimes(2);
833
+ });
834
+ it("sends messages in order", async () => {
835
+ const calls = [];
836
+ const replyMock = vi.fn().mockImplementation(async (content) => {
837
+ calls.push(content);
838
+ });
839
+ const text = `First part.\n${"x".repeat(2000)}\nLast part.`;
840
+ await manager.sendResponse(replyMock, text);
841
+ // Verify order by checking first call starts with "First"
842
+ expect(calls[0]).toMatch(/^First/);
843
+ });
844
+ });
845
+ });
846
+ // =============================================================================
847
+ // handleMessage pipeline tests (active)
848
+ // =============================================================================
849
+ /**
850
+ * These tests exercise the handleMessage() pipeline end-to-end by:
851
+ * 1. Creating a DiscordManager with a mock FleetManagerContext
852
+ * 2. Injecting a mock connector (EventEmitter with session manager)
853
+ * 3. Emitting a "message" event on the connector → triggers handleMessage()
854
+ * 4. The mock ctx.trigger() captures and invokes the onMessage callback
855
+ * 5. Assertions verify reply calls, embeds, fallback behavior
856
+ */
857
+ describe("DiscordManager handleMessage pipeline", () => {
858
+ /**
859
+ * Helper: build a DiscordManager wired to a custom ctx.trigger mock.
860
+ * Returns { manager, connector, triggerMock } ready for emitting events.
861
+ */
862
+ function buildManagerWithTrigger(triggerImpl, agentOverrides) {
863
+ const discordConfig = {
864
+ bot_token_env: "TEST_BOT_TOKEN",
865
+ session_expiry_hours: 24,
866
+ log_level: "standard",
867
+ output: {
868
+ tool_results: true,
869
+ tool_result_max_length: 900,
870
+ system_status: true,
871
+ result_summary: false,
872
+ typing_indicator: true,
873
+ errors: true,
874
+ acknowledge_emoji: "",
875
+ assistant_messages: "answers",
876
+ progress_indicator: false, // disable for cleaner assertions
877
+ },
878
+ guilds: [],
879
+ };
880
+ const agent = {
881
+ ...createDiscordAgent("test-agent", discordConfig),
882
+ ...agentOverrides,
883
+ };
884
+ const emitter = new EventEmitter();
885
+ const ctx = {
886
+ getConfig: () => ({
887
+ fleet: { name: "test-fleet" },
888
+ agents: [agent],
889
+ configPath: "/test/herdctl.yaml",
890
+ configDir: "/test",
891
+ }),
892
+ getStateDir: () => "/tmp/test-state",
893
+ getStateDirInfo: () => null,
894
+ getLogger: () => mockLogger,
895
+ getScheduler: () => null,
896
+ getStatus: () => "running",
897
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
898
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
899
+ getStoppedAt: () => null,
900
+ getLastError: () => null,
901
+ getCheckInterval: () => 1000,
902
+ emit: (event, ...args) => emitter.emit(event, ...args),
903
+ getEmitter: () => emitter,
904
+ trigger: triggerImpl,
905
+ };
906
+ const manager = new DiscordManager(ctx);
907
+ // Build mock connector
908
+ const connector = new EventEmitter();
909
+ connector.connect = vi.fn().mockResolvedValue(undefined);
910
+ connector.disconnect = vi.fn().mockResolvedValue(undefined);
911
+ connector.isConnected = vi.fn().mockReturnValue(true);
912
+ connector.getState = vi.fn().mockReturnValue({
913
+ status: "connected",
914
+ connectedAt: "2024-01-01T00:00:00.000Z",
915
+ disconnectedAt: null,
916
+ reconnectAttempts: 0,
917
+ lastError: null,
918
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
919
+ rateLimits: {
920
+ totalCount: 0,
921
+ lastRateLimitAt: null,
922
+ isRateLimited: false,
923
+ currentResetTime: 0,
924
+ },
925
+ messageStats: { received: 0, sent: 0, ignored: 0 },
926
+ });
927
+ connector.uploadFile = vi.fn().mockResolvedValue({ fileId: "f1" });
928
+ connector.agentName = "test-agent";
929
+ connector.sessionManager = {
930
+ getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
931
+ getSession: vi.fn().mockResolvedValue(null),
932
+ setSession: vi.fn().mockResolvedValue(undefined),
933
+ touchSession: vi.fn().mockResolvedValue(undefined),
934
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
935
+ };
936
+ // Inject connector
937
+ // @ts-expect-error - accessing private property for testing
938
+ manager.connectors.set("test-agent", connector);
939
+ // @ts-expect-error - accessing private property for testing
940
+ manager.initialized = true;
941
+ return { manager, connector, ctx };
942
+ }
943
+ /** Create a standard message event with configurable reply mock */
944
+ function createMessageEvent(replyMock) {
945
+ const replyFn = replyMock ?? vi.fn().mockResolvedValue(undefined);
946
+ const replyWithRefFn = vi.fn().mockResolvedValue({
947
+ edit: vi.fn().mockResolvedValue(undefined),
948
+ delete: vi.fn().mockResolvedValue(undefined),
949
+ });
950
+ const event = {
951
+ agentName: "test-agent",
952
+ prompt: "Hello bot!",
953
+ context: { messages: [], wasMentioned: true, prompt: "Hello bot!" },
954
+ metadata: {
955
+ guildId: "guild1",
956
+ channelId: "channel1",
957
+ messageId: "msg1",
958
+ userId: "user1",
959
+ username: "TestUser",
960
+ wasMentioned: true,
961
+ mode: "mention",
962
+ },
963
+ reply: replyFn,
964
+ startTyping: () => () => { },
965
+ addReaction: vi.fn().mockResolvedValue(undefined),
966
+ removeReaction: vi.fn().mockResolvedValue(undefined),
967
+ replyWithRef: replyWithRefFn,
968
+ };
969
+ return { event, reply: replyFn, replyWithRef: replyWithRefFn };
970
+ }
971
+ // ---- answers mode: suppresses reasoning turns, sends answer turns ----
972
+ it("suppresses reasoning turns (text + tool_use) in 'answers' mode", async () => {
973
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
974
+ const options = args[2];
975
+ if (options?.onMessage) {
976
+ // Turn 1: reasoning (text + tool_use) — should be suppressed
977
+ await options.onMessage({
978
+ type: "assistant",
979
+ message: {
980
+ id: "msg_1",
981
+ stop_reason: "tool_use",
982
+ content: [
983
+ { type: "text", text: "Let me check..." },
984
+ { type: "tool_use", name: "Read", id: "t1", input: { file_path: "/x.txt" } },
985
+ ],
986
+ },
987
+ });
988
+ // Turn 2: answer (text only) — should be sent
989
+ await options.onMessage({
990
+ type: "assistant",
991
+ message: {
992
+ id: "msg_2",
993
+ stop_reason: "end_turn",
994
+ content: [{ type: "text", text: "Here is the answer." }],
995
+ },
996
+ });
997
+ await options.onMessage({ type: "result", result: "Here is the answer." });
998
+ }
999
+ return {
1000
+ jobId: "j1",
1001
+ agentName: "test-agent",
1002
+ scheduleName: null,
1003
+ startedAt: new Date().toISOString(),
1004
+ success: true,
1005
+ sessionId: "sid1",
1006
+ };
1007
+ });
1008
+ await manager.start();
1009
+ const { event, reply } = createMessageEvent();
1010
+ connector.emit("message", event);
1011
+ await new Promise((resolve) => setTimeout(resolve, 200));
1012
+ // Only the answer turn (no tool_use) should be delivered
1013
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1014
+ expect(textCalls).toHaveLength(1);
1015
+ expect(textCalls[0][0]).toBe("Here is the answer.");
1016
+ });
1017
+ it("sends all answer turns immediately in 'answers' mode", async () => {
1018
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1019
+ const options = args[2];
1020
+ if (options?.onMessage) {
1021
+ // Two answer turns (text only, no tool_use) — both should be sent
1022
+ await options.onMessage({
1023
+ type: "assistant",
1024
+ message: {
1025
+ id: "msg_1",
1026
+ stop_reason: "end_turn",
1027
+ content: [{ type: "text", text: "First answer." }],
1028
+ },
1029
+ });
1030
+ await options.onMessage({
1031
+ type: "assistant",
1032
+ message: {
1033
+ id: "msg_2",
1034
+ stop_reason: "end_turn",
1035
+ content: [{ type: "text", text: "Second answer." }],
1036
+ },
1037
+ });
1038
+ await options.onMessage({ type: "result", result: "Second answer." });
1039
+ }
1040
+ return {
1041
+ jobId: "j1b",
1042
+ agentName: "test-agent",
1043
+ scheduleName: null,
1044
+ startedAt: new Date().toISOString(),
1045
+ success: true,
1046
+ sessionId: "sid1b",
1047
+ };
1048
+ });
1049
+ await manager.start();
1050
+ const { event, reply } = createMessageEvent();
1051
+ connector.emit("message", event);
1052
+ await new Promise((resolve) => setTimeout(resolve, 2500)); // wait for rate limiting
1053
+ // Both answer turns should be delivered
1054
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1055
+ expect(textCalls).toHaveLength(2);
1056
+ expect(textCalls[0][0]).toBe("First answer.");
1057
+ expect(textCalls[1][0]).toBe("Second answer.");
1058
+ });
1059
+ // ---- resultText fallback when all turns are reasoning (tool-only) ----
1060
+ it("uses SDK result text as fallback when all turns are reasoning", async () => {
1061
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1062
+ const options = args[2];
1063
+ if (options?.onMessage) {
1064
+ // Assistant message with ONLY tool_use (no text blocks)
1065
+ await options.onMessage({
1066
+ type: "assistant",
1067
+ message: {
1068
+ id: "msg_1",
1069
+ stop_reason: "tool_use",
1070
+ content: [{ type: "tool_use", name: "Bash", id: "t1", input: { command: "ls" } }],
1071
+ },
1072
+ });
1073
+ // Tool result
1074
+ await options.onMessage({
1075
+ type: "user",
1076
+ message: {
1077
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "file1 file2" }],
1078
+ },
1079
+ });
1080
+ // Result message has the final answer text
1081
+ await options.onMessage({
1082
+ type: "result",
1083
+ result: "The directory contains file1 and file2.",
1084
+ });
1085
+ }
1086
+ return {
1087
+ jobId: "j2",
1088
+ agentName: "test-agent",
1089
+ scheduleName: null,
1090
+ startedAt: new Date().toISOString(),
1091
+ success: true,
1092
+ sessionId: "sid2",
1093
+ };
1094
+ });
1095
+ await manager.start();
1096
+ const { event, reply } = createMessageEvent();
1097
+ connector.emit("message", event);
1098
+ await new Promise((resolve) => setTimeout(resolve, 200));
1099
+ // The result text should be sent as fallback
1100
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1101
+ expect(textCalls).toHaveLength(1);
1102
+ expect(textCalls[0][0]).toBe("The directory contains file1 and file2.");
1103
+ });
1104
+ // ---- Dedup: skip intermediates (stop_reason: null) ----
1105
+ it("skips intermediate assistant snapshots (stop_reason: null)", async () => {
1106
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1107
+ const options = args[2];
1108
+ if (options?.onMessage) {
1109
+ // Intermediate snapshot (stop_reason: null) — should be skipped
1110
+ await options.onMessage({
1111
+ type: "assistant",
1112
+ message: {
1113
+ id: "msg_1",
1114
+ stop_reason: null,
1115
+ content: [{ type: "text", text: "Partial..." }],
1116
+ },
1117
+ });
1118
+ // Final snapshot (stop_reason: "end_turn") — should be delivered
1119
+ await options.onMessage({
1120
+ type: "assistant",
1121
+ message: {
1122
+ id: "msg_1",
1123
+ stop_reason: "end_turn",
1124
+ content: [{ type: "text", text: "Complete answer." }],
1125
+ },
1126
+ });
1127
+ await options.onMessage({ type: "result", result: "Complete answer." });
1128
+ }
1129
+ return {
1130
+ jobId: "j3",
1131
+ agentName: "test-agent",
1132
+ scheduleName: null,
1133
+ startedAt: new Date().toISOString(),
1134
+ success: true,
1135
+ sessionId: "sid3",
1136
+ };
1137
+ });
1138
+ await manager.start();
1139
+ const { event, reply } = createMessageEvent();
1140
+ connector.emit("message", event);
1141
+ await new Promise((resolve) => setTimeout(resolve, 200));
1142
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1143
+ expect(textCalls).toHaveLength(1);
1144
+ expect(textCalls[0][0]).toBe("Complete answer.");
1145
+ });
1146
+ // ---- Dedup: deduplicate same message.id ----
1147
+ it("deduplicates assistant messages by message.id", async () => {
1148
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1149
+ const options = args[2];
1150
+ if (options?.onMessage) {
1151
+ // Same message.id delivered twice (stop_reason not null) — second should be deduped
1152
+ await options.onMessage({
1153
+ type: "assistant",
1154
+ message: {
1155
+ id: "msg_1",
1156
+ stop_reason: "end_turn",
1157
+ content: [{ type: "text", text: "First delivery." }],
1158
+ },
1159
+ });
1160
+ await options.onMessage({
1161
+ type: "assistant",
1162
+ message: {
1163
+ id: "msg_1",
1164
+ stop_reason: "end_turn",
1165
+ content: [{ type: "text", text: "Duplicate delivery." }],
1166
+ },
1167
+ });
1168
+ await options.onMessage({ type: "result", result: "First delivery." });
1169
+ }
1170
+ return {
1171
+ jobId: "j4",
1172
+ agentName: "test-agent",
1173
+ scheduleName: null,
1174
+ startedAt: new Date().toISOString(),
1175
+ success: true,
1176
+ sessionId: "sid4",
1177
+ };
1178
+ });
1179
+ await manager.start();
1180
+ const { event, reply } = createMessageEvent();
1181
+ connector.emit("message", event);
1182
+ await new Promise((resolve) => setTimeout(resolve, 200));
1183
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1184
+ expect(textCalls).toHaveLength(1);
1185
+ // Only the first delivery should be buffered (not the duplicate)
1186
+ expect(textCalls[0][0]).toBe("First delivery.");
1187
+ });
1188
+ // ---- Fallback when no output at all ----
1189
+ it("shows fallback embed when no messages are sent", async () => {
1190
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1191
+ const options = args[2];
1192
+ if (options?.onMessage) {
1193
+ // Only a result message with no text
1194
+ await options.onMessage({ type: "result", is_error: false });
1195
+ }
1196
+ return {
1197
+ jobId: "j5",
1198
+ agentName: "test-agent",
1199
+ scheduleName: null,
1200
+ startedAt: new Date().toISOString(),
1201
+ success: true,
1202
+ sessionId: "sid5",
1203
+ };
1204
+ });
1205
+ await manager.start();
1206
+ const { event, reply } = createMessageEvent();
1207
+ connector.emit("message", event);
1208
+ await new Promise((resolve) => setTimeout(resolve, 200));
1209
+ // Should show the "Task completed" fallback embed
1210
+ const embedCalls = reply.mock.calls.filter((call) => typeof call[0] === "object" && call[0].embeds);
1211
+ expect(embedCalls.length).toBeGreaterThanOrEqual(1);
1212
+ const lastEmbed = embedCalls[embedCalls.length - 1][0];
1213
+ expect(lastEmbed.embeds[0].description).toContain("Task completed");
1214
+ });
1215
+ // ---- Error fallback ----
1216
+ it("shows error fallback when job fails with no output", async () => {
1217
+ const { manager, connector } = buildManagerWithTrigger(async () => {
1218
+ return {
1219
+ jobId: "j6",
1220
+ agentName: "test-agent",
1221
+ scheduleName: null,
1222
+ startedAt: new Date().toISOString(),
1223
+ success: false,
1224
+ error: { message: "Agent crashed" },
1225
+ errorDetails: { message: "Agent crashed" },
1226
+ };
1227
+ });
1228
+ await manager.start();
1229
+ const { event, reply } = createMessageEvent();
1230
+ connector.emit("message", event);
1231
+ await new Promise((resolve) => setTimeout(resolve, 200));
1232
+ const embedCalls = reply.mock.calls.filter((call) => typeof call[0] === "object" && call[0].embeds);
1233
+ expect(embedCalls.length).toBeGreaterThanOrEqual(1);
1234
+ const lastEmbed = embedCalls[embedCalls.length - 1][0];
1235
+ expect(lastEmbed.embeds[0].description).toContain("Agent crashed");
1236
+ });
1237
+ // ---- Tool result embeds ----
1238
+ it("sends final answer without per-tool embed burst when tool results are enabled", async () => {
1239
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1240
+ const options = args[2];
1241
+ if (options?.onMessage) {
1242
+ // Assistant with tool_use
1243
+ await options.onMessage({
1244
+ type: "assistant",
1245
+ message: {
1246
+ id: "msg_1",
1247
+ stop_reason: "tool_use",
1248
+ content: [
1249
+ { type: "text", text: "Let me check." },
1250
+ { type: "tool_use", name: "Bash", id: "t1", input: { command: "ls" } },
1251
+ ],
1252
+ },
1253
+ });
1254
+ // Tool result
1255
+ await options.onMessage({
1256
+ type: "user",
1257
+ message: {
1258
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "file1.txt\nfile2.txt" }],
1259
+ },
1260
+ });
1261
+ // Final answer
1262
+ await options.onMessage({
1263
+ type: "assistant",
1264
+ message: {
1265
+ id: "msg_2",
1266
+ stop_reason: "end_turn",
1267
+ content: [{ type: "text", text: "Found 2 files." }],
1268
+ },
1269
+ });
1270
+ await options.onMessage({ type: "result", result: "Found 2 files." });
1271
+ }
1272
+ return {
1273
+ jobId: "j7",
1274
+ agentName: "test-agent",
1275
+ scheduleName: null,
1276
+ startedAt: new Date().toISOString(),
1277
+ success: true,
1278
+ sessionId: "sid7",
1279
+ };
1280
+ });
1281
+ await manager.start();
1282
+ const { event, reply } = createMessageEvent();
1283
+ connector.emit("message", event);
1284
+ await new Promise((resolve) => setTimeout(resolve, 200));
1285
+ // Should have sent final text without a per-tool embed burst.
1286
+ const embedCalls = reply.mock.calls.filter((call) => typeof call[0] === "object" && call[0].embeds);
1287
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1288
+ expect(embedCalls).toHaveLength(0);
1289
+ expect(textCalls).toHaveLength(1);
1290
+ expect(textCalls[0][0]).toBe("Found 2 files.");
1291
+ });
1292
+ // ---- No system prompt injection (concise_mode removed) ----
1293
+ it("does not inject a systemPromptAppend", async () => {
1294
+ let capturedSystemPrompt;
1295
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1296
+ const options = args[2];
1297
+ capturedSystemPrompt = options?.systemPromptAppend;
1298
+ if (options?.onMessage) {
1299
+ await options.onMessage({
1300
+ type: "assistant",
1301
+ message: {
1302
+ id: "msg_1",
1303
+ stop_reason: "end_turn",
1304
+ content: [{ type: "text", text: "Done." }],
1305
+ },
1306
+ });
1307
+ await options.onMessage({ type: "result", result: "Done." });
1308
+ }
1309
+ return {
1310
+ jobId: "j8",
1311
+ agentName: "test-agent",
1312
+ scheduleName: null,
1313
+ startedAt: new Date().toISOString(),
1314
+ success: true,
1315
+ sessionId: "sid8",
1316
+ };
1317
+ });
1318
+ await manager.start();
1319
+ const { event } = createMessageEvent();
1320
+ connector.emit("message", event);
1321
+ await new Promise((resolve) => setTimeout(resolve, 200));
1322
+ expect(capturedSystemPrompt).toBeUndefined();
1323
+ });
1324
+ // ---- Session persistence ----
1325
+ it("stores session ID after successful job", async () => {
1326
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1327
+ const options = args[2];
1328
+ if (options?.onMessage) {
1329
+ await options.onMessage({
1330
+ type: "assistant",
1331
+ message: {
1332
+ id: "msg_1",
1333
+ stop_reason: "end_turn",
1334
+ content: [{ type: "text", text: "OK." }],
1335
+ },
1336
+ });
1337
+ await options.onMessage({ type: "result", result: "OK." });
1338
+ }
1339
+ return {
1340
+ jobId: "j9",
1341
+ agentName: "test-agent",
1342
+ scheduleName: null,
1343
+ startedAt: new Date().toISOString(),
1344
+ success: true,
1345
+ sessionId: "sdk-session-42",
1346
+ };
666
1347
  });
1348
+ await manager.start();
1349
+ const { event } = createMessageEvent();
1350
+ connector.emit("message", event);
1351
+ await new Promise((resolve) => setTimeout(resolve, 200));
1352
+ expect(connector.sessionManager.setSession).toHaveBeenCalledWith("channel1", "sdk-session-42");
667
1353
  });
668
- describe("sendResponse", () => {
669
- it("sends single message for short content", async () => {
670
- const replyMock = vi.fn().mockResolvedValue(undefined);
671
- await manager.sendResponse(replyMock, "Short message");
672
- expect(replyMock).toHaveBeenCalledTimes(1);
673
- expect(replyMock).toHaveBeenCalledWith("Short message");
1354
+ // ---- Mixed: tool-only turns + final text ----
1355
+ it("handles multi-turn tool usage with final text answer", async () => {
1356
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1357
+ const options = args[2];
1358
+ if (options?.onMessage) {
1359
+ // Turn 1: text + tool
1360
+ await options.onMessage({
1361
+ type: "assistant",
1362
+ message: {
1363
+ id: "msg_1",
1364
+ stop_reason: "tool_use",
1365
+ content: [
1366
+ { type: "text", text: "Let me look." },
1367
+ { type: "tool_use", name: "Read", id: "t1", input: { file_path: "/x.txt" } },
1368
+ ],
1369
+ },
1370
+ });
1371
+ await options.onMessage({
1372
+ type: "user",
1373
+ message: {
1374
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "contents" }],
1375
+ },
1376
+ });
1377
+ // Turn 2: tool-only (no text)
1378
+ await options.onMessage({
1379
+ type: "assistant",
1380
+ message: {
1381
+ id: "msg_2",
1382
+ stop_reason: "tool_use",
1383
+ content: [{ type: "tool_use", name: "Bash", id: "t2", input: { command: "wc -l" } }],
1384
+ },
1385
+ });
1386
+ await options.onMessage({
1387
+ type: "user",
1388
+ message: {
1389
+ content: [{ type: "tool_result", tool_use_id: "t2", content: "42" }],
1390
+ },
1391
+ });
1392
+ // Turn 3: final text
1393
+ await options.onMessage({
1394
+ type: "assistant",
1395
+ message: {
1396
+ id: "msg_3",
1397
+ stop_reason: "end_turn",
1398
+ content: [{ type: "text", text: "The file has 42 lines." }],
1399
+ },
1400
+ });
1401
+ await options.onMessage({ type: "result", result: "The file has 42 lines." });
1402
+ }
1403
+ return {
1404
+ jobId: "j10",
1405
+ agentName: "test-agent",
1406
+ scheduleName: null,
1407
+ startedAt: new Date().toISOString(),
1408
+ success: true,
1409
+ sessionId: "sid10",
1410
+ };
674
1411
  });
675
- it("sends multiple messages for long content", async () => {
676
- const replyMock = vi.fn().mockResolvedValue(undefined);
677
- const longText = "word ".repeat(500); // About 2500 chars
678
- await manager.sendResponse(replyMock, longText);
679
- expect(replyMock).toHaveBeenCalledTimes(2);
1412
+ await manager.start();
1413
+ const { event, reply } = createMessageEvent();
1414
+ connector.emit("message", event);
1415
+ await new Promise((resolve) => setTimeout(resolve, 200));
1416
+ // Only the answer turn (no tool_use) should be delivered
1417
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1418
+ expect(textCalls).toHaveLength(1);
1419
+ expect(textCalls[0][0]).toBe("The file has 42 lines.");
1420
+ });
1421
+ // ---- "all" mode: sends every assistant turn with text ----
1422
+ it("sends every assistant message immediately in 'all' mode", async () => {
1423
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1424
+ const options = args[2];
1425
+ if (options?.onMessage) {
1426
+ await options.onMessage({
1427
+ type: "assistant",
1428
+ message: {
1429
+ id: "msg_1",
1430
+ stop_reason: "end_turn",
1431
+ content: [{ type: "text", text: "First turn." }],
1432
+ },
1433
+ });
1434
+ await options.onMessage({
1435
+ type: "assistant",
1436
+ message: {
1437
+ id: "msg_2",
1438
+ stop_reason: "end_turn",
1439
+ content: [{ type: "text", text: "Second turn." }],
1440
+ },
1441
+ });
1442
+ await options.onMessage({ type: "result", result: "Second turn." });
1443
+ }
1444
+ return {
1445
+ jobId: "j11",
1446
+ agentName: "test-agent",
1447
+ scheduleName: null,
1448
+ startedAt: new Date().toISOString(),
1449
+ success: true,
1450
+ sessionId: "sid11",
1451
+ };
1452
+ },
1453
+ // Override: use "all" mode to send every turn
1454
+ {
1455
+ chat: {
1456
+ discord: {
1457
+ bot_token_env: "TEST_BOT_TOKEN",
1458
+ session_expiry_hours: 24,
1459
+ log_level: "standard",
1460
+ output: {
1461
+ tool_results: true,
1462
+ tool_result_max_length: 900,
1463
+ system_status: true,
1464
+ result_summary: false,
1465
+ typing_indicator: true,
1466
+ errors: true,
1467
+ acknowledge_emoji: "",
1468
+ assistant_messages: "all",
1469
+ progress_indicator: false,
1470
+ },
1471
+ guilds: [],
1472
+ },
1473
+ },
680
1474
  });
681
- it("sends messages in order", async () => {
682
- const calls = [];
683
- const replyMock = vi.fn().mockImplementation(async (content) => {
684
- calls.push(content);
685
- });
686
- const text = `First part.\n${"x".repeat(2000)}\nLast part.`;
687
- await manager.sendResponse(replyMock, text);
688
- // Verify order by checking first call starts with "First"
689
- expect(calls[0]).toMatch(/^First/);
1475
+ await manager.start();
1476
+ const { event, reply } = createMessageEvent();
1477
+ connector.emit("message", event);
1478
+ await new Promise((resolve) => setTimeout(resolve, 2500)); // wait for rate limiting
1479
+ // Both messages should be sent
1480
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1481
+ expect(textCalls).toHaveLength(2);
1482
+ expect(textCalls[0][0]).toBe("First turn.");
1483
+ expect(textCalls[1][0]).toBe("Second turn.");
1484
+ });
1485
+ it("sends reasoning turns (text + tool_use) in 'all' mode", async () => {
1486
+ const { manager, connector } = buildManagerWithTrigger(async (...args) => {
1487
+ const options = args[2];
1488
+ if (options?.onMessage) {
1489
+ // Reasoning turn: text + tool_use
1490
+ await options.onMessage({
1491
+ type: "assistant",
1492
+ message: {
1493
+ id: "msg_1",
1494
+ stop_reason: "tool_use",
1495
+ content: [
1496
+ { type: "text", text: "Let me check the file." },
1497
+ { type: "tool_use", name: "Read", id: "t1", input: { file_path: "/x.txt" } },
1498
+ ],
1499
+ },
1500
+ });
1501
+ // Answer turn: text only
1502
+ await options.onMessage({
1503
+ type: "assistant",
1504
+ message: {
1505
+ id: "msg_2",
1506
+ stop_reason: "end_turn",
1507
+ content: [{ type: "text", text: "The file has 42 lines." }],
1508
+ },
1509
+ });
1510
+ await options.onMessage({ type: "result", result: "The file has 42 lines." });
1511
+ }
1512
+ return {
1513
+ jobId: "j12",
1514
+ agentName: "test-agent",
1515
+ scheduleName: null,
1516
+ startedAt: new Date().toISOString(),
1517
+ success: true,
1518
+ sessionId: "sid12",
1519
+ };
1520
+ },
1521
+ // Override: use "all" mode to send every turn including reasoning
1522
+ {
1523
+ chat: {
1524
+ discord: {
1525
+ bot_token_env: "TEST_BOT_TOKEN",
1526
+ session_expiry_hours: 24,
1527
+ log_level: "standard",
1528
+ output: {
1529
+ tool_results: true,
1530
+ tool_result_max_length: 900,
1531
+ system_status: true,
1532
+ result_summary: false,
1533
+ typing_indicator: true,
1534
+ errors: true,
1535
+ acknowledge_emoji: "",
1536
+ assistant_messages: "all",
1537
+ progress_indicator: false,
1538
+ },
1539
+ guilds: [],
1540
+ },
1541
+ },
690
1542
  });
1543
+ await manager.start();
1544
+ const { event, reply } = createMessageEvent();
1545
+ connector.emit("message", event);
1546
+ await new Promise((resolve) => setTimeout(resolve, 2500)); // wait for rate limiting
1547
+ // Both reasoning and answer turns should be sent in "all" mode
1548
+ const textCalls = reply.mock.calls.filter((call) => typeof call[0] === "string");
1549
+ expect(textCalls).toHaveLength(2);
1550
+ expect(textCalls[0][0]).toBe("Let me check the file.");
1551
+ expect(textCalls[1][0]).toBe("The file has 42 lines.");
691
1552
  });
692
1553
  });
693
1554
  // Message handling tests are skipped pending refactor to work with the new architecture
@@ -719,6 +1580,9 @@ describe.skip("DiscordManager message handling", () => {
719
1580
  result_summary: false,
720
1581
  typing_indicator: true,
721
1582
  errors: true,
1583
+ acknowledge_emoji: "eyes",
1584
+ assistant_messages: "answers",
1585
+ progress_indicator: true,
722
1586
  },
723
1587
  guilds: [],
724
1588
  }),
@@ -832,6 +1696,12 @@ describe.skip("DiscordManager message handling", () => {
832
1696
  },
833
1697
  reply: replyMock,
834
1698
  startTyping: () => () => { },
1699
+ addReaction: vi.fn().mockResolvedValue(undefined),
1700
+ removeReaction: vi.fn().mockResolvedValue(undefined),
1701
+ replyWithRef: vi.fn().mockResolvedValue({
1702
+ edit: vi.fn().mockResolvedValue(undefined),
1703
+ delete: vi.fn().mockResolvedValue(undefined),
1704
+ }),
835
1705
  };
836
1706
  // Emit the message event
837
1707
  mockConnector.emit("message", messageEvent);
@@ -873,6 +1743,9 @@ describe.skip("DiscordManager message handling", () => {
873
1743
  result_summary: false,
874
1744
  typing_indicator: true,
875
1745
  errors: true,
1746
+ acknowledge_emoji: "eyes",
1747
+ assistant_messages: "answers",
1748
+ progress_indicator: true,
876
1749
  },
877
1750
  guilds: [],
878
1751
  }),
@@ -958,6 +1831,12 @@ describe.skip("DiscordManager message handling", () => {
958
1831
  },
959
1832
  reply: replyMock,
960
1833
  startTyping: () => () => { },
1834
+ addReaction: vi.fn().mockResolvedValue(undefined),
1835
+ removeReaction: vi.fn().mockResolvedValue(undefined),
1836
+ replyWithRef: vi.fn().mockResolvedValue({
1837
+ edit: vi.fn().mockResolvedValue(undefined),
1838
+ delete: vi.fn().mockResolvedValue(undefined),
1839
+ }),
961
1840
  };
962
1841
  // Emit the message event
963
1842
  mockConnector.emit("message", messageEvent);
@@ -996,6 +1875,9 @@ describe.skip("DiscordManager message handling", () => {
996
1875
  result_summary: false,
997
1876
  typing_indicator: true,
998
1877
  errors: true,
1878
+ acknowledge_emoji: "eyes",
1879
+ assistant_messages: "answers",
1880
+ progress_indicator: true,
999
1881
  },
1000
1882
  guilds: [],
1001
1883
  }),
@@ -1081,6 +1963,12 @@ describe.skip("DiscordManager message handling", () => {
1081
1963
  },
1082
1964
  reply: replyMock,
1083
1965
  startTyping: () => () => { },
1966
+ addReaction: vi.fn().mockResolvedValue(undefined),
1967
+ removeReaction: vi.fn().mockResolvedValue(undefined),
1968
+ replyWithRef: vi.fn().mockResolvedValue({
1969
+ edit: vi.fn().mockResolvedValue(undefined),
1970
+ delete: vi.fn().mockResolvedValue(undefined),
1971
+ }),
1084
1972
  };
1085
1973
  // Emit the message event
1086
1974
  mockConnector.emit("message", messageEvent);
@@ -1150,6 +2038,9 @@ describe.skip("DiscordManager message handling", () => {
1150
2038
  result_summary: false,
1151
2039
  typing_indicator: true,
1152
2040
  errors: true,
2041
+ acknowledge_emoji: "eyes",
2042
+ assistant_messages: "answers",
2043
+ progress_indicator: true,
1153
2044
  },
1154
2045
  guilds: [],
1155
2046
  }),
@@ -1232,6 +2123,12 @@ describe.skip("DiscordManager message handling", () => {
1232
2123
  },
1233
2124
  reply: replyMock,
1234
2125
  startTyping: () => () => { },
2126
+ addReaction: vi.fn().mockResolvedValue(undefined),
2127
+ removeReaction: vi.fn().mockResolvedValue(undefined),
2128
+ replyWithRef: vi.fn().mockResolvedValue({
2129
+ edit: vi.fn().mockResolvedValue(undefined),
2130
+ delete: vi.fn().mockResolvedValue(undefined),
2131
+ }),
1235
2132
  };
1236
2133
  mockConnector.emit("message", messageEvent);
1237
2134
  // Wait for async processing (includes rate limiting delays)
@@ -1287,6 +2184,9 @@ describe.skip("DiscordManager message handling", () => {
1287
2184
  result_summary: false,
1288
2185
  typing_indicator: true,
1289
2186
  errors: true,
2187
+ acknowledge_emoji: "eyes",
2188
+ assistant_messages: "answers",
2189
+ progress_indicator: true,
1290
2190
  },
1291
2191
  guilds: [],
1292
2192
  }),
@@ -1369,6 +2269,12 @@ describe.skip("DiscordManager message handling", () => {
1369
2269
  },
1370
2270
  reply: replyMock,
1371
2271
  startTyping: () => () => { },
2272
+ addReaction: vi.fn().mockResolvedValue(undefined),
2273
+ removeReaction: vi.fn().mockResolvedValue(undefined),
2274
+ replyWithRef: vi.fn().mockResolvedValue({
2275
+ edit: vi.fn().mockResolvedValue(undefined),
2276
+ delete: vi.fn().mockResolvedValue(undefined),
2277
+ }),
1372
2278
  };
1373
2279
  mockConnector.emit("message", messageEvent);
1374
2280
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -1469,6 +2375,12 @@ describe.skip("DiscordManager message handling", () => {
1469
2375
  },
1470
2376
  reply: replyMock,
1471
2377
  startTyping: () => () => { },
2378
+ addReaction: vi.fn().mockResolvedValue(undefined),
2379
+ removeReaction: vi.fn().mockResolvedValue(undefined),
2380
+ replyWithRef: vi.fn().mockResolvedValue({
2381
+ edit: vi.fn().mockResolvedValue(undefined),
2382
+ delete: vi.fn().mockResolvedValue(undefined),
2383
+ }),
1472
2384
  };
1473
2385
  // Emit the message event - this will trigger handleMessage which will fail
1474
2386
  // because agent is not in config, and then try to reply, and that also fails
@@ -1567,6 +2479,12 @@ describe.skip("DiscordManager message handling", () => {
1567
2479
  },
1568
2480
  reply: replyMock,
1569
2481
  startTyping: () => () => { },
2482
+ addReaction: vi.fn().mockResolvedValue(undefined),
2483
+ removeReaction: vi.fn().mockResolvedValue(undefined),
2484
+ replyWithRef: vi.fn().mockResolvedValue({
2485
+ edit: vi.fn().mockResolvedValue(undefined),
2486
+ delete: vi.fn().mockResolvedValue(undefined),
2487
+ }),
1570
2488
  };
1571
2489
  // Emit the message event
1572
2490
  mockConnector.emit("message", messageEvent);
@@ -1627,6 +2545,12 @@ describe.skip("DiscordManager message handling", () => {
1627
2545
  },
1628
2546
  reply: replyMock,
1629
2547
  startTyping: () => () => { },
2548
+ addReaction: vi.fn().mockResolvedValue(undefined),
2549
+ removeReaction: vi.fn().mockResolvedValue(undefined),
2550
+ replyWithRef: vi.fn().mockResolvedValue({
2551
+ edit: vi.fn().mockResolvedValue(undefined),
2552
+ delete: vi.fn().mockResolvedValue(undefined),
2553
+ }),
1630
2554
  };
1631
2555
  // Emit the message event
1632
2556
  mockConnector.emit("message", messageEvent);
@@ -1684,6 +2608,12 @@ describe.skip("DiscordManager message handling", () => {
1684
2608
  },
1685
2609
  reply: replyMock,
1686
2610
  startTyping: () => () => { },
2611
+ addReaction: vi.fn().mockResolvedValue(undefined),
2612
+ removeReaction: vi.fn().mockResolvedValue(undefined),
2613
+ replyWithRef: vi.fn().mockResolvedValue({
2614
+ edit: vi.fn().mockResolvedValue(undefined),
2615
+ delete: vi.fn().mockResolvedValue(undefined),
2616
+ }),
1687
2617
  };
1688
2618
  // Emit the message event
1689
2619
  mockConnector.emit("message", messageEvent);
@@ -2179,6 +3109,9 @@ describe.skip("DiscordManager session integration", () => {
2179
3109
  result_summary: false,
2180
3110
  typing_indicator: true,
2181
3111
  errors: true,
3112
+ acknowledge_emoji: "eyes",
3113
+ assistant_messages: "answers",
3114
+ progress_indicator: true,
2182
3115
  },
2183
3116
  guilds: [],
2184
3117
  }),
@@ -2259,6 +3192,12 @@ describe.skip("DiscordManager session integration", () => {
2259
3192
  },
2260
3193
  reply: replyMock,
2261
3194
  startTyping: () => () => { },
3195
+ addReaction: vi.fn().mockResolvedValue(undefined),
3196
+ removeReaction: vi.fn().mockResolvedValue(undefined),
3197
+ replyWithRef: vi.fn().mockResolvedValue({
3198
+ edit: vi.fn().mockResolvedValue(undefined),
3199
+ delete: vi.fn().mockResolvedValue(undefined),
3200
+ }),
2262
3201
  };
2263
3202
  // Emit the message event
2264
3203
  mockConnector.emit("message", messageEvent);
@@ -2316,6 +3255,12 @@ describe.skip("DiscordManager session integration", () => {
2316
3255
  },
2317
3256
  reply: replyMock,
2318
3257
  startTyping: () => () => { },
3258
+ addReaction: vi.fn().mockResolvedValue(undefined),
3259
+ removeReaction: vi.fn().mockResolvedValue(undefined),
3260
+ replyWithRef: vi.fn().mockResolvedValue({
3261
+ edit: vi.fn().mockResolvedValue(undefined),
3262
+ delete: vi.fn().mockResolvedValue(undefined),
3263
+ }),
2319
3264
  };
2320
3265
  // Emit the message event
2321
3266
  mockConnector.emit("message", messageEvent);
@@ -2374,6 +3319,12 @@ describe.skip("DiscordManager session integration", () => {
2374
3319
  },
2375
3320
  reply: replyMock,
2376
3321
  startTyping: () => () => { },
3322
+ addReaction: vi.fn().mockResolvedValue(undefined),
3323
+ removeReaction: vi.fn().mockResolvedValue(undefined),
3324
+ replyWithRef: vi.fn().mockResolvedValue({
3325
+ edit: vi.fn().mockResolvedValue(undefined),
3326
+ delete: vi.fn().mockResolvedValue(undefined),
3327
+ }),
2377
3328
  };
2378
3329
  // Emit the message event
2379
3330
  mockConnector.emit("message", messageEvent);
@@ -2434,6 +3385,12 @@ describe.skip("DiscordManager session integration", () => {
2434
3385
  },
2435
3386
  reply: replyMock,
2436
3387
  startTyping: () => () => { },
3388
+ addReaction: vi.fn().mockResolvedValue(undefined),
3389
+ removeReaction: vi.fn().mockResolvedValue(undefined),
3390
+ replyWithRef: vi.fn().mockResolvedValue({
3391
+ edit: vi.fn().mockResolvedValue(undefined),
3392
+ delete: vi.fn().mockResolvedValue(undefined),
3393
+ }),
2437
3394
  };
2438
3395
  // Emit the message event
2439
3396
  mockConnector.emit("message", messageEvent);
@@ -2705,6 +3662,9 @@ describe.skip("DiscordManager lifecycle", () => {
2705
3662
  result_summary: false,
2706
3663
  typing_indicator: true,
2707
3664
  errors: true,
3665
+ acknowledge_emoji: "eyes",
3666
+ assistant_messages: "answers",
3667
+ progress_indicator: true,
2708
3668
  },
2709
3669
  guilds: [],
2710
3670
  }),
@@ -2786,6 +3746,12 @@ describe.skip("DiscordManager lifecycle", () => {
2786
3746
  },
2787
3747
  reply: replyMock,
2788
3748
  startTyping: () => () => { },
3749
+ addReaction: vi.fn().mockResolvedValue(undefined),
3750
+ removeReaction: vi.fn().mockResolvedValue(undefined),
3751
+ replyWithRef: vi.fn().mockResolvedValue({
3752
+ edit: vi.fn().mockResolvedValue(undefined),
3753
+ delete: vi.fn().mockResolvedValue(undefined),
3754
+ }),
2789
3755
  };
2790
3756
  // Emit the message event
2791
3757
  mockConnector.emit("message", messageEvent);
@@ -2826,6 +3792,9 @@ describe.skip("DiscordManager lifecycle", () => {
2826
3792
  result_summary: false,
2827
3793
  typing_indicator: true,
2828
3794
  errors: true,
3795
+ acknowledge_emoji: "eyes",
3796
+ assistant_messages: "answers",
3797
+ progress_indicator: true,
2829
3798
  },
2830
3799
  guilds: [],
2831
3800
  }),
@@ -2907,6 +3876,12 @@ describe.skip("DiscordManager lifecycle", () => {
2907
3876
  },
2908
3877
  reply: replyMock,
2909
3878
  startTyping: () => () => { },
3879
+ addReaction: vi.fn().mockResolvedValue(undefined),
3880
+ removeReaction: vi.fn().mockResolvedValue(undefined),
3881
+ replyWithRef: vi.fn().mockResolvedValue({
3882
+ edit: vi.fn().mockResolvedValue(undefined),
3883
+ delete: vi.fn().mockResolvedValue(undefined),
3884
+ }),
2910
3885
  };
2911
3886
  // Emit the message event
2912
3887
  mockConnector.emit("message", messageEvent);
@@ -3075,6 +4050,12 @@ describe.skip("DiscordManager lifecycle", () => {
3075
4050
  },
3076
4051
  reply: replyMock,
3077
4052
  startTyping: () => () => { },
4053
+ addReaction: vi.fn().mockResolvedValue(undefined),
4054
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4055
+ replyWithRef: vi.fn().mockResolvedValue({
4056
+ edit: vi.fn().mockResolvedValue(undefined),
4057
+ delete: vi.fn().mockResolvedValue(undefined),
4058
+ }),
3078
4059
  };
3079
4060
  // Emit the message event
3080
4061
  mockConnector.emit("message", messageEvent);
@@ -3113,6 +4094,9 @@ describe.skip("DiscordManager lifecycle", () => {
3113
4094
  result_summary: false,
3114
4095
  typing_indicator: true,
3115
4096
  errors: true,
4097
+ acknowledge_emoji: "eyes",
4098
+ assistant_messages: "answers",
4099
+ progress_indicator: true,
3116
4100
  },
3117
4101
  guilds: [],
3118
4102
  }),
@@ -3194,6 +4178,12 @@ describe.skip("DiscordManager lifecycle", () => {
3194
4178
  },
3195
4179
  reply: replyMock,
3196
4180
  startTyping: () => () => { },
4181
+ addReaction: vi.fn().mockResolvedValue(undefined),
4182
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4183
+ replyWithRef: vi.fn().mockResolvedValue({
4184
+ edit: vi.fn().mockResolvedValue(undefined),
4185
+ delete: vi.fn().mockResolvedValue(undefined),
4186
+ }),
3197
4187
  };
3198
4188
  // Emit the message event
3199
4189
  mockConnector.emit("message", messageEvent);
@@ -3270,6 +4260,9 @@ describe.skip("DiscordManager output configuration", () => {
3270
4260
  result_summary: false,
3271
4261
  typing_indicator: true,
3272
4262
  errors: true,
4263
+ acknowledge_emoji: "eyes",
4264
+ assistant_messages: "answers",
4265
+ progress_indicator: true,
3273
4266
  },
3274
4267
  guilds: [],
3275
4268
  },
@@ -3353,6 +4346,12 @@ describe.skip("DiscordManager output configuration", () => {
3353
4346
  },
3354
4347
  reply: replyMock,
3355
4348
  startTyping: () => () => { },
4349
+ addReaction: vi.fn().mockResolvedValue(undefined),
4350
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4351
+ replyWithRef: vi.fn().mockResolvedValue({
4352
+ edit: vi.fn().mockResolvedValue(undefined),
4353
+ delete: vi.fn().mockResolvedValue(undefined),
4354
+ }),
3356
4355
  };
3357
4356
  mockConnector.emit("message", messageEvent);
3358
4357
  await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -3403,6 +4402,9 @@ describe.skip("DiscordManager output configuration", () => {
3403
4402
  result_summary: false,
3404
4403
  typing_indicator: true,
3405
4404
  errors: true,
4405
+ acknowledge_emoji: "eyes",
4406
+ assistant_messages: "answers",
4407
+ progress_indicator: true,
3406
4408
  },
3407
4409
  guilds: [],
3408
4410
  },
@@ -3486,6 +4488,12 @@ describe.skip("DiscordManager output configuration", () => {
3486
4488
  },
3487
4489
  reply: replyMock,
3488
4490
  startTyping: () => () => { },
4491
+ addReaction: vi.fn().mockResolvedValue(undefined),
4492
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4493
+ replyWithRef: vi.fn().mockResolvedValue({
4494
+ edit: vi.fn().mockResolvedValue(undefined),
4495
+ delete: vi.fn().mockResolvedValue(undefined),
4496
+ }),
3489
4497
  };
3490
4498
  mockConnector.emit("message", messageEvent);
3491
4499
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -3535,6 +4543,9 @@ describe.skip("DiscordManager output configuration", () => {
3535
4543
  result_summary: false,
3536
4544
  typing_indicator: true,
3537
4545
  errors: true,
4546
+ acknowledge_emoji: "eyes",
4547
+ assistant_messages: "answers",
4548
+ progress_indicator: true,
3538
4549
  },
3539
4550
  guilds: [],
3540
4551
  },
@@ -3618,6 +4629,12 @@ describe.skip("DiscordManager output configuration", () => {
3618
4629
  },
3619
4630
  reply: replyMock,
3620
4631
  startTyping: () => () => { },
4632
+ addReaction: vi.fn().mockResolvedValue(undefined),
4633
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4634
+ replyWithRef: vi.fn().mockResolvedValue({
4635
+ edit: vi.fn().mockResolvedValue(undefined),
4636
+ delete: vi.fn().mockResolvedValue(undefined),
4637
+ }),
3621
4638
  };
3622
4639
  mockConnector.emit("message", messageEvent);
3623
4640
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -3668,6 +4685,9 @@ describe.skip("DiscordManager output configuration", () => {
3668
4685
  result_summary: true,
3669
4686
  typing_indicator: true,
3670
4687
  errors: true,
4688
+ acknowledge_emoji: "eyes",
4689
+ assistant_messages: "answers",
4690
+ progress_indicator: true,
3671
4691
  },
3672
4692
  guilds: [],
3673
4693
  },
@@ -3751,6 +4771,12 @@ describe.skip("DiscordManager output configuration", () => {
3751
4771
  },
3752
4772
  reply: replyMock,
3753
4773
  startTyping: () => () => { },
4774
+ addReaction: vi.fn().mockResolvedValue(undefined),
4775
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4776
+ replyWithRef: vi.fn().mockResolvedValue({
4777
+ edit: vi.fn().mockResolvedValue(undefined),
4778
+ delete: vi.fn().mockResolvedValue(undefined),
4779
+ }),
3754
4780
  };
3755
4781
  mockConnector.emit("message", messageEvent);
3756
4782
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -3804,6 +4830,9 @@ describe.skip("DiscordManager output configuration", () => {
3804
4830
  result_summary: false,
3805
4831
  typing_indicator: true,
3806
4832
  errors: true,
4833
+ acknowledge_emoji: "eyes",
4834
+ assistant_messages: "answers",
4835
+ progress_indicator: true,
3807
4836
  },
3808
4837
  guilds: [],
3809
4838
  },
@@ -3887,6 +4916,12 @@ describe.skip("DiscordManager output configuration", () => {
3887
4916
  },
3888
4917
  reply: replyMock,
3889
4918
  startTyping: () => () => { },
4919
+ addReaction: vi.fn().mockResolvedValue(undefined),
4920
+ removeReaction: vi.fn().mockResolvedValue(undefined),
4921
+ replyWithRef: vi.fn().mockResolvedValue({
4922
+ edit: vi.fn().mockResolvedValue(undefined),
4923
+ delete: vi.fn().mockResolvedValue(undefined),
4924
+ }),
3890
4925
  };
3891
4926
  mockConnector.emit("message", messageEvent);
3892
4927
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -3935,6 +4970,9 @@ describe.skip("DiscordManager output configuration", () => {
3935
4970
  result_summary: false,
3936
4971
  typing_indicator: true,
3937
4972
  errors: false,
4973
+ acknowledge_emoji: "eyes",
4974
+ assistant_messages: "answers",
4975
+ progress_indicator: true,
3938
4976
  },
3939
4977
  guilds: [],
3940
4978
  },
@@ -4018,6 +5056,12 @@ describe.skip("DiscordManager output configuration", () => {
4018
5056
  },
4019
5057
  reply: replyMock,
4020
5058
  startTyping: () => () => { },
5059
+ addReaction: vi.fn().mockResolvedValue(undefined),
5060
+ removeReaction: vi.fn().mockResolvedValue(undefined),
5061
+ replyWithRef: vi.fn().mockResolvedValue({
5062
+ edit: vi.fn().mockResolvedValue(undefined),
5063
+ delete: vi.fn().mockResolvedValue(undefined),
5064
+ }),
4021
5065
  };
4022
5066
  mockConnector.emit("message", messageEvent);
4023
5067
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -4028,6 +5072,127 @@ describe.skip("DiscordManager output configuration", () => {
4028
5072
  });
4029
5073
  expect(embedCalls.length).toBe(0);
4030
5074
  }, 10000);
5075
+ describe("file sender MCP injection", () => {
5076
+ it("passes injectedMcpServers to trigger when agent has working_directory", async () => {
5077
+ const triggerMock = vi.fn().mockResolvedValue({
5078
+ jobId: "job-file",
5079
+ agentName: "test",
5080
+ scheduleName: null,
5081
+ startedAt: new Date().toISOString(),
5082
+ success: true,
5083
+ });
5084
+ const agentWithWorkDir = {
5085
+ ...createDiscordAgent("file-agent", {
5086
+ bot_token_env: "TEST_BOT_TOKEN",
5087
+ session_expiry_hours: 24,
5088
+ log_level: "standard",
5089
+ output: {
5090
+ tool_results: true,
5091
+ tool_result_max_length: 900,
5092
+ system_status: true,
5093
+ result_summary: false,
5094
+ typing_indicator: true,
5095
+ errors: true,
5096
+ acknowledge_emoji: "",
5097
+ assistant_messages: "answers",
5098
+ progress_indicator: true,
5099
+ },
5100
+ guilds: [],
5101
+ }),
5102
+ working_directory: "/tmp/test-workspace",
5103
+ };
5104
+ const config = {
5105
+ fleet: { name: "test-fleet" },
5106
+ agents: [agentWithWorkDir],
5107
+ configPath: "/test/herdctl.yaml",
5108
+ configDir: "/test",
5109
+ };
5110
+ const mockContext = {
5111
+ getConfig: () => config,
5112
+ getStateDir: () => "/tmp/test-state",
5113
+ getStateDirInfo: () => null,
5114
+ getLogger: () => mockLogger,
5115
+ getScheduler: () => null,
5116
+ getStatus: () => "running",
5117
+ getInitializedAt: () => "2024-01-01T00:00:00.000Z",
5118
+ getStartedAt: () => "2024-01-01T00:00:01.000Z",
5119
+ getStoppedAt: () => null,
5120
+ getLastError: () => null,
5121
+ getCheckInterval: () => 1000,
5122
+ emit: vi.fn(),
5123
+ getEmitter: () => new EventEmitter(),
5124
+ trigger: triggerMock,
5125
+ };
5126
+ const manager = new DiscordManager(mockContext);
5127
+ const mockConnector = new EventEmitter();
5128
+ mockConnector.connect = vi.fn().mockResolvedValue(undefined);
5129
+ mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
5130
+ mockConnector.isConnected = vi.fn().mockReturnValue(true);
5131
+ mockConnector.getState = vi.fn().mockReturnValue({
5132
+ status: "connected",
5133
+ connectedAt: "2024-01-01T00:00:00.000Z",
5134
+ disconnectedAt: null,
5135
+ reconnectAttempts: 0,
5136
+ lastError: null,
5137
+ botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
5138
+ rateLimits: {
5139
+ totalCount: 0,
5140
+ lastRateLimitAt: null,
5141
+ isRateLimited: false,
5142
+ currentResetTime: 0,
5143
+ },
5144
+ messageStats: { received: 0, sent: 0, ignored: 0 },
5145
+ });
5146
+ mockConnector.uploadFile = vi.fn().mockResolvedValue({ fileId: "file-123" });
5147
+ mockConnector.agentName = "file-agent";
5148
+ mockConnector.sessionManager = {
5149
+ getSession: vi.fn().mockResolvedValue(null),
5150
+ setSession: vi.fn().mockResolvedValue(undefined),
5151
+ touchSession: vi.fn().mockResolvedValue(undefined),
5152
+ getActiveSessionCount: vi.fn().mockResolvedValue(0),
5153
+ };
5154
+ // @ts-expect-error - accessing private property for testing
5155
+ manager.connectors.set("file-agent", mockConnector);
5156
+ // @ts-expect-error - accessing private property for testing
5157
+ manager.initialized = true;
5158
+ await manager.start();
5159
+ const messageEvent = {
5160
+ agentName: "file-agent",
5161
+ prompt: "Send me the file",
5162
+ context: { messages: [], wasMentioned: true, prompt: "Send me the file" },
5163
+ metadata: {
5164
+ guildId: "guild1",
5165
+ channelId: "channel1",
5166
+ messageId: "msg1",
5167
+ userId: "user1",
5168
+ username: "TestUser",
5169
+ wasMentioned: true,
5170
+ mode: "mention",
5171
+ },
5172
+ reply: vi.fn().mockResolvedValue(undefined),
5173
+ startTyping: () => () => { },
5174
+ addReaction: vi.fn().mockResolvedValue(undefined),
5175
+ removeReaction: vi.fn().mockResolvedValue(undefined),
5176
+ replyWithRef: vi.fn().mockResolvedValue({
5177
+ edit: vi.fn().mockResolvedValue(undefined),
5178
+ delete: vi.fn().mockResolvedValue(undefined),
5179
+ }),
5180
+ };
5181
+ mockConnector.emit("message", messageEvent);
5182
+ await new Promise((resolve) => setTimeout(resolve, 100));
5183
+ // Verify trigger was called with injectedMcpServers
5184
+ expect(triggerMock).toHaveBeenCalledWith("file-agent", undefined, expect.objectContaining({
5185
+ injectedMcpServers: expect.objectContaining({
5186
+ "herdctl-file-sender": expect.objectContaining({
5187
+ name: "herdctl-file-sender",
5188
+ tools: expect.arrayContaining([
5189
+ expect.objectContaining({ name: "herdctl_send_file" }),
5190
+ ]),
5191
+ }),
5192
+ }),
5193
+ }));
5194
+ }, 10000);
5195
+ });
4031
5196
  describe("buildToolEmbed with custom maxOutputChars", () => {
4032
5197
  it("respects custom maxOutputChars parameter", () => {
4033
5198
  const ctx = createMockContext(null);