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