@herdctl/core 3.0.1 → 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.
- package/dist/config/__tests__/schema.test.js +45 -0
- package/dist/config/__tests__/schema.test.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/schema.d.ts +203 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +33 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/fleet-manager/__tests__/discord-manager.test.js +1415 -84
- package/dist/fleet-manager/__tests__/discord-manager.test.js.map +1 -1
- package/dist/fleet-manager/discord-manager.d.ts +75 -2
- package/dist/fleet-manager/discord-manager.d.ts.map +1 -1
- package/dist/fleet-manager/discord-manager.js +374 -3
- package/dist/fleet-manager/discord-manager.js.map +1 -1
- package/dist/scheduler/__tests__/scheduler.test.js +112 -7
- package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts.map +1 -1
- package/dist/scheduler/scheduler.js +7 -11
- package/dist/scheduler/scheduler.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|