@herdctl/core 3.0.2 → 4.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.
@@ -122,6 +122,7 @@ describe("DiscordManager", () => {
122
122
  bot_token_env: "NONEXISTENT_BOT_TOKEN_VAR",
123
123
  session_expiry_hours: 24,
124
124
  log_level: "standard",
125
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
125
126
  guilds: [],
126
127
  };
127
128
  const config = {
@@ -572,6 +573,7 @@ describe("DiscordManager message handling", () => {
572
573
  bot_token_env: "TEST_BOT_TOKEN",
573
574
  session_expiry_hours: 24,
574
575
  log_level: "standard",
576
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
575
577
  guilds: [],
576
578
  }),
577
579
  ],
@@ -699,6 +701,7 @@ describe("DiscordManager message handling", () => {
699
701
  bot_token_env: "TEST_BOT_TOKEN",
700
702
  session_expiry_hours: 24,
701
703
  log_level: "standard",
704
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
702
705
  guilds: [],
703
706
  }),
704
707
  ],
@@ -798,6 +801,7 @@ describe("DiscordManager message handling", () => {
798
801
  bot_token_env: "TEST_BOT_TOKEN",
799
802
  session_expiry_hours: 24,
800
803
  log_level: "standard",
804
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
801
805
  guilds: [],
802
806
  }),
803
807
  ],
@@ -876,6 +880,258 @@ describe("DiscordManager message handling", () => {
876
880
  // Should have sent multiple messages (split response)
877
881
  expect(replyMock).toHaveBeenCalledTimes(2);
878
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
+ });
879
1135
  it("handles message handler rejection via catch handler", async () => {
880
1136
  // This tests the .catch(error => this.handleError()) path in start()
881
1137
  // when handleMessage throws an error that propagates to the catch handler
@@ -1267,92 +1523,423 @@ describe("DiscordManager message handling", () => {
1267
1523
  expect(result).toBeUndefined();
1268
1524
  });
1269
1525
  });
1270
- });
1271
- describe("DiscordManager session integration", () => {
1272
- let manager;
1273
- let mockContext;
1274
- let triggerMock;
1275
- let emitterWithTrigger;
1276
- let mockSessionManager;
1277
- beforeEach(() => {
1278
- vi.clearAllMocks();
1279
- // Create mock session manager
1280
- mockSessionManager = {
1281
- agentName: "test-agent",
1282
- getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "session-123", isNew: false }),
1283
- getSession: vi.fn().mockResolvedValue({ sessionId: "session-123", lastMessageAt: new Date().toISOString() }),
1284
- setSession: vi.fn().mockResolvedValue(undefined),
1285
- touchSession: vi.fn().mockResolvedValue(undefined),
1286
- clearSession: vi.fn().mockResolvedValue(true),
1287
- cleanupExpiredSessions: vi.fn().mockResolvedValue(0),
1288
- getActiveSessionCount: vi.fn().mockResolvedValue(5),
1289
- };
1290
- // Create a mock FleetManager (emitter) with trigger method
1291
- triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123", success: true, sessionId: "sdk-session-456" });
1292
- emitterWithTrigger = Object.assign(new EventEmitter(), {
1293
- trigger: triggerMock,
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" });
1294
1542
  });
1295
- const config = {
1296
- fleet: { name: "test-fleet" },
1297
- agents: [
1298
- createDiscordAgent("test-agent", {
1299
- bot_token_env: "TEST_BOT_TOKEN",
1300
- session_expiry_hours: 24,
1301
- log_level: "standard",
1302
- guilds: [],
1303
- }),
1304
- ],
1305
- configPath: "/test/herdctl.yaml",
1306
- configDir: "/test",
1307
- };
1308
- mockContext = {
1309
- getConfig: () => config,
1310
- getStateDir: () => "/tmp/test-state",
1311
- getStateDirInfo: () => null,
1312
- getLogger: () => mockLogger,
1313
- getScheduler: () => null,
1314
- getStatus: () => "running",
1315
- getInitializedAt: () => "2024-01-01T00:00:00.000Z",
1316
- getStartedAt: () => "2024-01-01T00:00:01.000Z",
1317
- getStoppedAt: () => null,
1318
- getLastError: () => null,
1319
- getCheckInterval: () => 1000,
1320
- emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
1321
- getEmitter: () => emitterWithTrigger,
1322
- };
1323
- manager = new DiscordManager(mockContext);
1324
- });
1325
- it("calls getSession on message to check for existing session", async () => {
1326
- // Create a mock connector with session manager
1327
- const mockConnector = new EventEmitter();
1328
- mockConnector.connect = vi.fn().mockResolvedValue(undefined);
1329
- mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
1330
- mockConnector.isConnected = vi.fn().mockReturnValue(true);
1331
- mockConnector.getState = vi.fn().mockReturnValue({
1332
- status: "connected",
1333
- connectedAt: "2024-01-01T00:00:00.000Z",
1334
- disconnectedAt: null,
1335
- reconnectAttempts: 0,
1336
- lastError: null,
1337
- botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
1338
- rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
1339
- messageStats: { received: 0, sent: 0, ignored: 0 },
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");
1340
1560
  });
1341
- mockConnector.agentName = "test-agent";
1342
- mockConnector.sessionManager = mockSessionManager;
1343
- // @ts-expect-error - accessing private property for testing
1344
- manager.connectors.set("test-agent", mockConnector);
1345
- // @ts-expect-error - accessing private property for testing
1346
- manager.initialized = true;
1347
- await manager.start();
1348
- // Create a mock message event
1349
- const replyMock = vi.fn().mockResolvedValue(undefined);
1350
- const messageEvent = {
1351
- agentName: "test-agent",
1352
- prompt: "Hello bot!",
1353
- context: {
1354
- messages: [],
1355
- wasMentioned: true,
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,
1356
1943
  prompt: "Hello bot!",
1357
1944
  },
1358
1945
  metadata: {
@@ -1753,6 +2340,7 @@ describe("DiscordManager lifecycle", () => {
1753
2340
  bot_token_env: "TEST_BOT_TOKEN",
1754
2341
  session_expiry_hours: 24,
1755
2342
  log_level: "standard",
2343
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
1756
2344
  guilds: [],
1757
2345
  }),
1758
2346
  ],
@@ -1854,6 +2442,7 @@ describe("DiscordManager lifecycle", () => {
1854
2442
  bot_token_env: "TEST_BOT_TOKEN",
1855
2443
  session_expiry_hours: 24,
1856
2444
  log_level: "standard",
2445
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
1857
2446
  guilds: [],
1858
2447
  }),
1859
2448
  ],
@@ -2097,6 +2686,7 @@ describe("DiscordManager lifecycle", () => {
2097
2686
  bot_token_env: "TEST_BOT_TOKEN",
2098
2687
  session_expiry_hours: 24,
2099
2688
  log_level: "standard",
2689
+ output: { tool_results: true, tool_result_max_length: 900, system_status: true, result_summary: false, errors: true },
2100
2690
  guilds: [],
2101
2691
  }),
2102
2692
  ],
@@ -2174,4 +2764,745 @@ describe("DiscordManager lifecycle", () => {
2174
2764
  expect(replyMock).toHaveBeenCalledWith("I've completed the task, but I don't have a specific response to share.");
2175
2765
  });
2176
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
+ });
2177
3508
  //# sourceMappingURL=discord-manager.test.js.map