@herdctl/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fleet-manager/__tests__/discord-manager.test.d.ts +8 -0
- package/dist/fleet-manager/__tests__/discord-manager.test.d.ts.map +1 -0
- package/dist/fleet-manager/__tests__/discord-manager.test.js +2162 -0
- package/dist/fleet-manager/__tests__/discord-manager.test.js.map +1 -0
- package/dist/fleet-manager/context.d.ts +4 -0
- package/dist/fleet-manager/context.d.ts.map +1 -1
- package/dist/fleet-manager/discord-manager.d.ts +275 -0
- package/dist/fleet-manager/discord-manager.d.ts.map +1 -0
- package/dist/fleet-manager/discord-manager.js +569 -0
- package/dist/fleet-manager/discord-manager.js.map +1 -0
- package/dist/fleet-manager/event-types.d.ts +45 -0
- package/dist/fleet-manager/event-types.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.d.ts +3 -0
- package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.js +10 -0
- package/dist/fleet-manager/fleet-manager.js.map +1 -1
- package/dist/fleet-manager/index.d.ts +3 -1
- package/dist/fleet-manager/index.d.ts.map +1 -1
- package/dist/fleet-manager/index.js +1 -0
- package/dist/fleet-manager/index.js.map +1 -1
- package/dist/fleet-manager/status-queries.d.ts +5 -1
- package/dist/fleet-manager/status-queries.d.ts.map +1 -1
- package/dist/fleet-manager/status-queries.js +42 -3
- package/dist/fleet-manager/status-queries.js.map +1 -1
- package/dist/fleet-manager/types.d.ts +26 -1
- package/dist/fleet-manager/types.d.ts.map +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,2162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for DiscordManager
|
|
3
|
+
*
|
|
4
|
+
* Tests the DiscordManager class which manages Discord connectors
|
|
5
|
+
* for agents with chat.discord configured.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import { DiscordManager } from "../discord-manager.js";
|
|
10
|
+
// Mock logger
|
|
11
|
+
const mockLogger = {
|
|
12
|
+
debug: vi.fn(),
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
warn: vi.fn(),
|
|
15
|
+
error: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
// Mock emitter
|
|
18
|
+
const mockEmitter = new EventEmitter();
|
|
19
|
+
// Create mock FleetManagerContext
|
|
20
|
+
function createMockContext(config = null) {
|
|
21
|
+
return {
|
|
22
|
+
getConfig: () => config,
|
|
23
|
+
getStateDir: () => "/tmp/test-state",
|
|
24
|
+
getStateDirInfo: () => null,
|
|
25
|
+
getLogger: () => mockLogger,
|
|
26
|
+
getScheduler: () => null,
|
|
27
|
+
getStatus: () => "initialized",
|
|
28
|
+
getInitializedAt: () => null,
|
|
29
|
+
getStartedAt: () => null,
|
|
30
|
+
getStoppedAt: () => null,
|
|
31
|
+
getLastError: () => null,
|
|
32
|
+
getCheckInterval: () => 1000,
|
|
33
|
+
emit: (event, ...args) => mockEmitter.emit(event, ...args),
|
|
34
|
+
getEmitter: () => mockEmitter,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// Create a mock agent with Discord config
|
|
38
|
+
function createDiscordAgent(name, discordConfig) {
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
model: "sonnet",
|
|
42
|
+
schedules: {},
|
|
43
|
+
chat: {
|
|
44
|
+
discord: discordConfig,
|
|
45
|
+
},
|
|
46
|
+
configPath: "/test/herdctl.yaml",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Create a mock agent without Discord config
|
|
50
|
+
function createNonDiscordAgent(name) {
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
model: "sonnet",
|
|
54
|
+
schedules: {},
|
|
55
|
+
configPath: "/test/herdctl.yaml",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
describe("DiscordManager", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
});
|
|
65
|
+
describe("constructor", () => {
|
|
66
|
+
it("creates instance with context", () => {
|
|
67
|
+
const ctx = createMockContext();
|
|
68
|
+
const manager = new DiscordManager(ctx);
|
|
69
|
+
expect(manager).toBeDefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("initialize", () => {
|
|
73
|
+
it("skips initialization when no config is available", async () => {
|
|
74
|
+
const ctx = createMockContext(null);
|
|
75
|
+
const manager = new DiscordManager(ctx);
|
|
76
|
+
await manager.initialize();
|
|
77
|
+
expect(mockLogger.debug).toHaveBeenCalledWith("No config available, skipping Discord initialization");
|
|
78
|
+
expect(manager.getConnectorNames()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
it("skips initialization when no agents have Discord configured", async () => {
|
|
81
|
+
const config = {
|
|
82
|
+
fleet: { name: "test-fleet" },
|
|
83
|
+
agents: [
|
|
84
|
+
createNonDiscordAgent("agent1"),
|
|
85
|
+
createNonDiscordAgent("agent2"),
|
|
86
|
+
],
|
|
87
|
+
configPath: "/test/herdctl.yaml",
|
|
88
|
+
configDir: "/test",
|
|
89
|
+
};
|
|
90
|
+
const ctx = createMockContext(config);
|
|
91
|
+
const manager = new DiscordManager(ctx);
|
|
92
|
+
// Mock the dynamic import to return null (package not installed)
|
|
93
|
+
vi.doMock("@herdctl/discord", () => {
|
|
94
|
+
throw new Error("Package not found");
|
|
95
|
+
});
|
|
96
|
+
await manager.initialize();
|
|
97
|
+
// Should either say "not installed" or "No agents with Discord configured"
|
|
98
|
+
const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
|
|
99
|
+
expect(debugCalls.some((msg) => msg.includes("not installed") ||
|
|
100
|
+
msg.includes("No agents with Discord configured"))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
it("is idempotent - multiple calls only initialize once", async () => {
|
|
103
|
+
const config = {
|
|
104
|
+
fleet: { name: "test-fleet" },
|
|
105
|
+
agents: [createNonDiscordAgent("agent1")],
|
|
106
|
+
configPath: "/test/herdctl.yaml",
|
|
107
|
+
configDir: "/test",
|
|
108
|
+
};
|
|
109
|
+
const ctx = createMockContext(config);
|
|
110
|
+
const manager = new DiscordManager(ctx);
|
|
111
|
+
await manager.initialize();
|
|
112
|
+
await manager.initialize();
|
|
113
|
+
// The second call should return early without doing anything
|
|
114
|
+
// We can verify by checking the debug logs
|
|
115
|
+
const debugCalls = mockLogger.debug.mock.calls.map((c) => c[0]);
|
|
116
|
+
// First init will log something, second call should not add more logs
|
|
117
|
+
// about initialization because it returns early
|
|
118
|
+
});
|
|
119
|
+
it("warns when bot token environment variable is not set", async () => {
|
|
120
|
+
const discordConfig = {
|
|
121
|
+
bot_token_env: "NONEXISTENT_BOT_TOKEN_VAR",
|
|
122
|
+
session_expiry_hours: 24,
|
|
123
|
+
log_level: "standard",
|
|
124
|
+
guilds: [],
|
|
125
|
+
};
|
|
126
|
+
const config = {
|
|
127
|
+
fleet: { name: "test-fleet" },
|
|
128
|
+
agents: [createDiscordAgent("agent1", discordConfig)],
|
|
129
|
+
configPath: "/test/herdctl.yaml",
|
|
130
|
+
configDir: "/test",
|
|
131
|
+
};
|
|
132
|
+
const ctx = createMockContext(config);
|
|
133
|
+
const manager = new DiscordManager(ctx);
|
|
134
|
+
// Clear the env var if it exists
|
|
135
|
+
const originalValue = process.env["NONEXISTENT_BOT_TOKEN_VAR"];
|
|
136
|
+
delete process.env["NONEXISTENT_BOT_TOKEN_VAR"];
|
|
137
|
+
await manager.initialize();
|
|
138
|
+
// Restore if it existed
|
|
139
|
+
if (originalValue !== undefined) {
|
|
140
|
+
process.env["NONEXISTENT_BOT_TOKEN_VAR"] = originalValue;
|
|
141
|
+
}
|
|
142
|
+
// The warning should only be logged if the discord package is available
|
|
143
|
+
// If the package is not available, it will log "not installed" first
|
|
144
|
+
const warnCalls = mockLogger.warn.mock.calls;
|
|
145
|
+
const debugCalls = mockLogger.debug.mock.calls;
|
|
146
|
+
// Either the package is not installed (debug log) or the token is missing (warn log)
|
|
147
|
+
const packageNotInstalled = debugCalls.some((call) => call[0].includes("not installed"));
|
|
148
|
+
const tokenMissing = warnCalls.some((call) => call[0].includes("Bot token not found"));
|
|
149
|
+
expect(packageNotInstalled || tokenMissing || warnCalls.length === 0).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe("start", () => {
|
|
153
|
+
it("logs when no connectors to start", async () => {
|
|
154
|
+
const ctx = createMockContext(null);
|
|
155
|
+
const manager = new DiscordManager(ctx);
|
|
156
|
+
await manager.initialize();
|
|
157
|
+
await manager.start();
|
|
158
|
+
expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to start");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe("stop", () => {
|
|
162
|
+
it("logs when no connectors to stop", async () => {
|
|
163
|
+
const ctx = createMockContext(null);
|
|
164
|
+
const manager = new DiscordManager(ctx);
|
|
165
|
+
await manager.initialize();
|
|
166
|
+
await manager.stop();
|
|
167
|
+
expect(mockLogger.debug).toHaveBeenCalledWith("No Discord connectors to stop");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe("getConnector", () => {
|
|
171
|
+
it("returns undefined for non-existent agent", () => {
|
|
172
|
+
const ctx = createMockContext(null);
|
|
173
|
+
const manager = new DiscordManager(ctx);
|
|
174
|
+
const connector = manager.getConnector("nonexistent");
|
|
175
|
+
expect(connector).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe("getConnectorNames", () => {
|
|
179
|
+
it("returns empty array when no connectors", () => {
|
|
180
|
+
const ctx = createMockContext(null);
|
|
181
|
+
const manager = new DiscordManager(ctx);
|
|
182
|
+
expect(manager.getConnectorNames()).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("getConnectedCount", () => {
|
|
186
|
+
it("returns 0 when no connectors", () => {
|
|
187
|
+
const ctx = createMockContext(null);
|
|
188
|
+
const manager = new DiscordManager(ctx);
|
|
189
|
+
expect(manager.getConnectedCount()).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe("hasConnector", () => {
|
|
193
|
+
it("returns false for non-existent agent", () => {
|
|
194
|
+
const ctx = createMockContext(null);
|
|
195
|
+
const manager = new DiscordManager(ctx);
|
|
196
|
+
expect(manager.hasConnector("nonexistent")).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe("DiscordConnectorState type", () => {
|
|
201
|
+
it("defines proper connector state structure", () => {
|
|
202
|
+
// This test verifies the type is exported correctly
|
|
203
|
+
const state = {
|
|
204
|
+
status: "disconnected",
|
|
205
|
+
connectedAt: null,
|
|
206
|
+
disconnectedAt: null,
|
|
207
|
+
reconnectAttempts: 0,
|
|
208
|
+
lastError: null,
|
|
209
|
+
botUser: null,
|
|
210
|
+
rateLimits: {
|
|
211
|
+
totalCount: 0,
|
|
212
|
+
lastRateLimitAt: null,
|
|
213
|
+
isRateLimited: false,
|
|
214
|
+
currentResetTime: 0,
|
|
215
|
+
},
|
|
216
|
+
messageStats: {
|
|
217
|
+
received: 0,
|
|
218
|
+
sent: 0,
|
|
219
|
+
ignored: 0,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
expect(state.status).toBe("disconnected");
|
|
223
|
+
expect(state.botUser).toBeNull();
|
|
224
|
+
expect(state.rateLimits.isRateLimited).toBe(false);
|
|
225
|
+
expect(state.messageStats.received).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
it("supports all connection status values", () => {
|
|
228
|
+
const statuses = [
|
|
229
|
+
"disconnected",
|
|
230
|
+
"connecting",
|
|
231
|
+
"connected",
|
|
232
|
+
"reconnecting",
|
|
233
|
+
"disconnecting",
|
|
234
|
+
"error",
|
|
235
|
+
];
|
|
236
|
+
statuses.forEach((status) => {
|
|
237
|
+
const state = {
|
|
238
|
+
status,
|
|
239
|
+
connectedAt: null,
|
|
240
|
+
disconnectedAt: null,
|
|
241
|
+
reconnectAttempts: 0,
|
|
242
|
+
lastError: null,
|
|
243
|
+
botUser: null,
|
|
244
|
+
rateLimits: {
|
|
245
|
+
totalCount: 0,
|
|
246
|
+
lastRateLimitAt: null,
|
|
247
|
+
isRateLimited: false,
|
|
248
|
+
currentResetTime: 0,
|
|
249
|
+
},
|
|
250
|
+
messageStats: {
|
|
251
|
+
received: 0,
|
|
252
|
+
sent: 0,
|
|
253
|
+
ignored: 0,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
expect(state.status).toBe(status);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
it("supports connected state with bot user", () => {
|
|
260
|
+
const state = {
|
|
261
|
+
status: "connected",
|
|
262
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
263
|
+
disconnectedAt: null,
|
|
264
|
+
reconnectAttempts: 0,
|
|
265
|
+
lastError: null,
|
|
266
|
+
botUser: {
|
|
267
|
+
id: "123456789",
|
|
268
|
+
username: "TestBot",
|
|
269
|
+
discriminator: "1234",
|
|
270
|
+
},
|
|
271
|
+
rateLimits: {
|
|
272
|
+
totalCount: 5,
|
|
273
|
+
lastRateLimitAt: "2024-01-01T00:01:00.000Z",
|
|
274
|
+
isRateLimited: false,
|
|
275
|
+
currentResetTime: 0,
|
|
276
|
+
},
|
|
277
|
+
messageStats: {
|
|
278
|
+
received: 100,
|
|
279
|
+
sent: 50,
|
|
280
|
+
ignored: 25,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
expect(state.status).toBe("connected");
|
|
284
|
+
expect(state.botUser?.username).toBe("TestBot");
|
|
285
|
+
expect(state.messageStats.received).toBe(100);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
describe("DiscordMessageEvent type", () => {
|
|
289
|
+
it("defines proper message event structure", () => {
|
|
290
|
+
const event = {
|
|
291
|
+
agentName: "test-agent",
|
|
292
|
+
prompt: "Hello, how are you?",
|
|
293
|
+
context: {
|
|
294
|
+
messages: [
|
|
295
|
+
{
|
|
296
|
+
author: "user123",
|
|
297
|
+
content: "Hello!",
|
|
298
|
+
isBot: false,
|
|
299
|
+
timestamp: "2024-01-01T00:00:00.000Z",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
wasMentioned: true,
|
|
303
|
+
prompt: "Hello, how are you?",
|
|
304
|
+
},
|
|
305
|
+
metadata: {
|
|
306
|
+
guildId: "guild123",
|
|
307
|
+
channelId: "channel456",
|
|
308
|
+
messageId: "msg789",
|
|
309
|
+
userId: "user123",
|
|
310
|
+
username: "TestUser",
|
|
311
|
+
wasMentioned: true,
|
|
312
|
+
mode: "mention",
|
|
313
|
+
},
|
|
314
|
+
reply: async (content) => {
|
|
315
|
+
console.log("Reply:", content);
|
|
316
|
+
},
|
|
317
|
+
startTyping: () => () => { },
|
|
318
|
+
};
|
|
319
|
+
expect(event.agentName).toBe("test-agent");
|
|
320
|
+
expect(event.prompt).toBe("Hello, how are you?");
|
|
321
|
+
expect(event.metadata.guildId).toBe("guild123");
|
|
322
|
+
expect(event.context.wasMentioned).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
it("supports DM context (null guildId)", () => {
|
|
325
|
+
const event = {
|
|
326
|
+
agentName: "dm-agent",
|
|
327
|
+
prompt: "Private message",
|
|
328
|
+
context: {
|
|
329
|
+
messages: [],
|
|
330
|
+
wasMentioned: false,
|
|
331
|
+
prompt: "Private message",
|
|
332
|
+
},
|
|
333
|
+
metadata: {
|
|
334
|
+
guildId: null,
|
|
335
|
+
channelId: "dm-channel",
|
|
336
|
+
messageId: "dm-msg",
|
|
337
|
+
userId: "user1",
|
|
338
|
+
username: "DMUser",
|
|
339
|
+
wasMentioned: false,
|
|
340
|
+
mode: "auto",
|
|
341
|
+
},
|
|
342
|
+
reply: async () => { },
|
|
343
|
+
startTyping: () => () => { },
|
|
344
|
+
};
|
|
345
|
+
expect(event.metadata.guildId).toBeNull();
|
|
346
|
+
expect(event.metadata.mode).toBe("auto");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
describe("DiscordErrorEvent type", () => {
|
|
350
|
+
it("defines proper error event structure", () => {
|
|
351
|
+
const event = {
|
|
352
|
+
agentName: "test-agent",
|
|
353
|
+
error: new Error("Connection failed"),
|
|
354
|
+
};
|
|
355
|
+
expect(event.agentName).toBe("test-agent");
|
|
356
|
+
expect(event.error.message).toBe("Connection failed");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
describe("DiscordManager response splitting", () => {
|
|
360
|
+
let manager;
|
|
361
|
+
beforeEach(() => {
|
|
362
|
+
vi.clearAllMocks();
|
|
363
|
+
const ctx = createMockContext(null);
|
|
364
|
+
manager = new DiscordManager(ctx);
|
|
365
|
+
});
|
|
366
|
+
describe("splitResponse", () => {
|
|
367
|
+
it("returns text as-is when under 2000 characters", () => {
|
|
368
|
+
const text = "Hello, this is a short message.";
|
|
369
|
+
const result = manager.splitResponse(text);
|
|
370
|
+
expect(result).toEqual([text]);
|
|
371
|
+
});
|
|
372
|
+
it("returns text as-is when exactly 2000 characters", () => {
|
|
373
|
+
const text = "a".repeat(2000);
|
|
374
|
+
const result = manager.splitResponse(text);
|
|
375
|
+
expect(result).toEqual([text]);
|
|
376
|
+
});
|
|
377
|
+
it("splits text at natural boundaries (newlines)", () => {
|
|
378
|
+
// Create text that's over 2000 chars with newlines
|
|
379
|
+
const line = "This is a line of text.\n";
|
|
380
|
+
const text = line.repeat(100); // About 2400 chars
|
|
381
|
+
const result = manager.splitResponse(text);
|
|
382
|
+
expect(result.length).toBeGreaterThan(1);
|
|
383
|
+
// Each chunk should be under 2000 chars
|
|
384
|
+
result.forEach((chunk) => {
|
|
385
|
+
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
386
|
+
});
|
|
387
|
+
// Chunks should join back to original
|
|
388
|
+
expect(result.join("")).toBe(text);
|
|
389
|
+
});
|
|
390
|
+
it("splits text at spaces when no newlines available", () => {
|
|
391
|
+
// Create text that's over 2000 chars with spaces but no newlines
|
|
392
|
+
const words = "word ".repeat(500); // About 2500 chars
|
|
393
|
+
const result = manager.splitResponse(words);
|
|
394
|
+
expect(result.length).toBeGreaterThan(1);
|
|
395
|
+
result.forEach((chunk) => {
|
|
396
|
+
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
it("handles text with no natural break points", () => {
|
|
400
|
+
const text = "a".repeat(3000); // No spaces or newlines
|
|
401
|
+
const result = manager.splitResponse(text);
|
|
402
|
+
expect(result.length).toBe(2);
|
|
403
|
+
expect(result[0].length).toBe(2000);
|
|
404
|
+
expect(result[1].length).toBe(1000);
|
|
405
|
+
});
|
|
406
|
+
it("preserves code blocks when splitting", () => {
|
|
407
|
+
// Create a code block that spans beyond 2000 chars
|
|
408
|
+
const codeBlock = "```typescript\n" + "const x = 1;\n".repeat(200) + "```";
|
|
409
|
+
const result = manager.splitResponse(codeBlock);
|
|
410
|
+
expect(result.length).toBeGreaterThan(1);
|
|
411
|
+
// First chunk should close the code block
|
|
412
|
+
expect(result[0]).toMatch(/```$/);
|
|
413
|
+
// Second chunk should reopen with the same language
|
|
414
|
+
expect(result[1]).toMatch(/^```typescript/);
|
|
415
|
+
});
|
|
416
|
+
it("preserves code blocks with no language specified", () => {
|
|
417
|
+
const codeBlock = "```\n" + "line of code\n".repeat(200) + "```";
|
|
418
|
+
const result = manager.splitResponse(codeBlock);
|
|
419
|
+
expect(result.length).toBeGreaterThan(1);
|
|
420
|
+
// First chunk should close the code block
|
|
421
|
+
expect(result[0]).toMatch(/```$/);
|
|
422
|
+
// Second chunk should reopen (possibly with empty language)
|
|
423
|
+
expect(result[1]).toMatch(/^```/);
|
|
424
|
+
});
|
|
425
|
+
it("handles multiple code blocks", () => {
|
|
426
|
+
const text = "Some text\n```js\nconsole.log('hello');\n```\nMore text\n```python\nprint('hello')\n```";
|
|
427
|
+
const result = manager.splitResponse(text);
|
|
428
|
+
// This should fit in one message
|
|
429
|
+
expect(result).toEqual([text]);
|
|
430
|
+
});
|
|
431
|
+
it("handles empty string", () => {
|
|
432
|
+
const result = manager.splitResponse("");
|
|
433
|
+
expect(result).toEqual([""]);
|
|
434
|
+
});
|
|
435
|
+
it("prefers paragraph breaks over line breaks", () => {
|
|
436
|
+
// Create text with both paragraph and line breaks
|
|
437
|
+
const paragraph1 = "First paragraph. ".repeat(50) + "\n\n";
|
|
438
|
+
const paragraph2 = "Second paragraph. ".repeat(50);
|
|
439
|
+
const text = paragraph1 + paragraph2;
|
|
440
|
+
if (text.length > 2000) {
|
|
441
|
+
const result = manager.splitResponse(text);
|
|
442
|
+
// Should split at the paragraph break
|
|
443
|
+
expect(result[0]).toMatch(/\n\n$/);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
it("handles code block that opens and closes within split region", () => {
|
|
447
|
+
// Create text where a code block opens and then closes before split point
|
|
448
|
+
// This tests the code path where insideBlock becomes false after closing
|
|
449
|
+
const text = "Some intro text\n```js\nconst x = 1;\n```\nMore text here " + "padding ".repeat(250);
|
|
450
|
+
const result = manager.splitResponse(text);
|
|
451
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
452
|
+
// Should not break inside code block since it's closed
|
|
453
|
+
result.forEach((chunk) => {
|
|
454
|
+
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
it("handles code block analysis when initially inside but closes on re-analysis", () => {
|
|
458
|
+
// Create text where initial analysis shows inside block at 2000 chars,
|
|
459
|
+
// but when we find a natural break and re-analyze, the block is closed
|
|
460
|
+
// This exercises the code path at line 727 where actualState.insideBlock is false
|
|
461
|
+
const codeBlock = "```js\nshort code\n```";
|
|
462
|
+
const paddingToReachSplit = "x".repeat(1900 - codeBlock.length);
|
|
463
|
+
const moreContent = " ".repeat(50) + "y".repeat(200); // Add space for split and more content
|
|
464
|
+
const text = codeBlock + paddingToReachSplit + moreContent;
|
|
465
|
+
const result = manager.splitResponse(text);
|
|
466
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
467
|
+
result.forEach((chunk) => {
|
|
468
|
+
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
it("handles multiple code blocks opening and closing", () => {
|
|
472
|
+
// Multiple code blocks that open and close
|
|
473
|
+
const text = "```js\ncode1\n```\n" + "text ".repeat(100) + "\n```py\ncode2\n```\n" + "more ".repeat(200);
|
|
474
|
+
const result = manager.splitResponse(text);
|
|
475
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
476
|
+
result.forEach((chunk) => {
|
|
477
|
+
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
it("splits at paragraph break when within 500 chars of split point", () => {
|
|
481
|
+
// Create text where paragraph break is close enough to the split point to be used
|
|
482
|
+
// Need text > 2000 chars with a paragraph break in the last 500 chars before 2000
|
|
483
|
+
const part1 = "a".repeat(1600);
|
|
484
|
+
const part2 = "\n\n"; // paragraph break
|
|
485
|
+
const part3 = "b".repeat(600); // Pushes us over 2000
|
|
486
|
+
const text = part1 + part2 + part3;
|
|
487
|
+
const result = manager.splitResponse(text);
|
|
488
|
+
expect(result.length).toBe(2);
|
|
489
|
+
// First chunk should end at paragraph break
|
|
490
|
+
expect(result[0]).toBe(part1 + part2);
|
|
491
|
+
expect(result[1]).toBe(part3);
|
|
492
|
+
});
|
|
493
|
+
it("falls back to newline when paragraph break is too far from split point", () => {
|
|
494
|
+
// Create text where paragraph break is too far but newline is close
|
|
495
|
+
const part1 = "a".repeat(1000);
|
|
496
|
+
const part2 = "\n\n"; // paragraph break too early
|
|
497
|
+
const part3 = "b".repeat(800);
|
|
498
|
+
const part4 = "\n"; // newline close to split point
|
|
499
|
+
const part5 = "c".repeat(400);
|
|
500
|
+
const text = part1 + part2 + part3 + part4 + part5;
|
|
501
|
+
const result = manager.splitResponse(text);
|
|
502
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
503
|
+
result.forEach((chunk) => {
|
|
504
|
+
expect(chunk.length).toBeLessThanOrEqual(2000);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
it("handles text just slightly over 2000 chars", () => {
|
|
508
|
+
const text = "a".repeat(2001);
|
|
509
|
+
const result = manager.splitResponse(text);
|
|
510
|
+
expect(result.length).toBe(2);
|
|
511
|
+
expect(result[0].length).toBe(2000);
|
|
512
|
+
expect(result[1].length).toBe(1);
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
describe("formatErrorMessage", () => {
|
|
516
|
+
it("formats error with message and guidance", () => {
|
|
517
|
+
const error = new Error("Something went wrong");
|
|
518
|
+
const result = manager.formatErrorMessage(error);
|
|
519
|
+
expect(result).toContain("❌ **Error**:");
|
|
520
|
+
expect(result).toContain("Something went wrong");
|
|
521
|
+
expect(result).toContain("/reset");
|
|
522
|
+
expect(result).toContain("Please try again");
|
|
523
|
+
});
|
|
524
|
+
it("handles errors with special characters", () => {
|
|
525
|
+
const error = new Error("Error with `code` and *markdown*");
|
|
526
|
+
const result = manager.formatErrorMessage(error);
|
|
527
|
+
expect(result).toContain("Error with `code` and *markdown*");
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
describe("sendResponse", () => {
|
|
531
|
+
it("sends single message for short content", async () => {
|
|
532
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
533
|
+
await manager.sendResponse(replyMock, "Short message");
|
|
534
|
+
expect(replyMock).toHaveBeenCalledTimes(1);
|
|
535
|
+
expect(replyMock).toHaveBeenCalledWith("Short message");
|
|
536
|
+
});
|
|
537
|
+
it("sends multiple messages for long content", async () => {
|
|
538
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
539
|
+
const longText = "word ".repeat(500); // About 2500 chars
|
|
540
|
+
await manager.sendResponse(replyMock, longText);
|
|
541
|
+
expect(replyMock).toHaveBeenCalledTimes(2);
|
|
542
|
+
});
|
|
543
|
+
it("sends messages in order", async () => {
|
|
544
|
+
const calls = [];
|
|
545
|
+
const replyMock = vi.fn().mockImplementation(async (content) => {
|
|
546
|
+
calls.push(content);
|
|
547
|
+
});
|
|
548
|
+
const text = "First part.\n" + "x".repeat(2000) + "\nLast part.";
|
|
549
|
+
await manager.sendResponse(replyMock, text);
|
|
550
|
+
// Verify order by checking first call starts with "First"
|
|
551
|
+
expect(calls[0]).toMatch(/^First/);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
describe("DiscordManager message handling", () => {
|
|
556
|
+
let manager;
|
|
557
|
+
let mockContext;
|
|
558
|
+
let triggerMock;
|
|
559
|
+
let emitterWithTrigger;
|
|
560
|
+
beforeEach(() => {
|
|
561
|
+
vi.clearAllMocks();
|
|
562
|
+
// Create a mock FleetManager (emitter) with trigger method
|
|
563
|
+
triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123" });
|
|
564
|
+
emitterWithTrigger = Object.assign(new EventEmitter(), {
|
|
565
|
+
trigger: triggerMock,
|
|
566
|
+
});
|
|
567
|
+
const config = {
|
|
568
|
+
fleet: { name: "test-fleet" },
|
|
569
|
+
agents: [
|
|
570
|
+
createDiscordAgent("test-agent", {
|
|
571
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
572
|
+
session_expiry_hours: 24,
|
|
573
|
+
log_level: "standard",
|
|
574
|
+
guilds: [],
|
|
575
|
+
}),
|
|
576
|
+
],
|
|
577
|
+
configPath: "/test/herdctl.yaml",
|
|
578
|
+
configDir: "/test",
|
|
579
|
+
};
|
|
580
|
+
mockContext = {
|
|
581
|
+
getConfig: () => config,
|
|
582
|
+
getStateDir: () => "/tmp/test-state",
|
|
583
|
+
getStateDirInfo: () => null,
|
|
584
|
+
getLogger: () => mockLogger,
|
|
585
|
+
getScheduler: () => null,
|
|
586
|
+
getStatus: () => "running",
|
|
587
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
588
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
589
|
+
getStoppedAt: () => null,
|
|
590
|
+
getLastError: () => null,
|
|
591
|
+
getCheckInterval: () => 1000,
|
|
592
|
+
emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
|
|
593
|
+
getEmitter: () => emitterWithTrigger,
|
|
594
|
+
};
|
|
595
|
+
manager = new DiscordManager(mockContext);
|
|
596
|
+
});
|
|
597
|
+
describe("start with mock connector", () => {
|
|
598
|
+
it("subscribes to connector events when starting", async () => {
|
|
599
|
+
// Create a mock connector that supports event handling
|
|
600
|
+
const mockConnector = new EventEmitter();
|
|
601
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
602
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
603
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
604
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
605
|
+
status: "connected",
|
|
606
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
607
|
+
disconnectedAt: null,
|
|
608
|
+
reconnectAttempts: 0,
|
|
609
|
+
lastError: null,
|
|
610
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
611
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
612
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
613
|
+
});
|
|
614
|
+
mockConnector.agentName = "test-agent";
|
|
615
|
+
// Access private connectors map to inject mock
|
|
616
|
+
// @ts-expect-error - accessing private property for testing
|
|
617
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
618
|
+
// @ts-expect-error - accessing private property for testing
|
|
619
|
+
manager.initialized = true;
|
|
620
|
+
await manager.start();
|
|
621
|
+
expect(mockConnector.connect).toHaveBeenCalled();
|
|
622
|
+
// Verify event listeners were attached
|
|
623
|
+
expect(mockConnector.listenerCount("message")).toBeGreaterThan(0);
|
|
624
|
+
expect(mockConnector.listenerCount("error")).toBeGreaterThan(0);
|
|
625
|
+
});
|
|
626
|
+
it("handles message events from connector", async () => {
|
|
627
|
+
// Create a mock connector
|
|
628
|
+
const mockConnector = new EventEmitter();
|
|
629
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
630
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
631
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
632
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
633
|
+
status: "connected",
|
|
634
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
635
|
+
disconnectedAt: null,
|
|
636
|
+
reconnectAttempts: 0,
|
|
637
|
+
lastError: null,
|
|
638
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
639
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
640
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
641
|
+
});
|
|
642
|
+
mockConnector.agentName = "test-agent";
|
|
643
|
+
// @ts-expect-error - accessing private property for testing
|
|
644
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
645
|
+
// @ts-expect-error - accessing private property for testing
|
|
646
|
+
manager.initialized = true;
|
|
647
|
+
await manager.start();
|
|
648
|
+
// Create a mock message event
|
|
649
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
650
|
+
const messageEvent = {
|
|
651
|
+
agentName: "test-agent",
|
|
652
|
+
prompt: "Hello bot!",
|
|
653
|
+
context: {
|
|
654
|
+
messages: [],
|
|
655
|
+
wasMentioned: true,
|
|
656
|
+
prompt: "Hello bot!",
|
|
657
|
+
},
|
|
658
|
+
metadata: {
|
|
659
|
+
guildId: "guild1",
|
|
660
|
+
channelId: "channel1",
|
|
661
|
+
messageId: "msg1",
|
|
662
|
+
userId: "user1",
|
|
663
|
+
username: "TestUser",
|
|
664
|
+
wasMentioned: true,
|
|
665
|
+
mode: "mention",
|
|
666
|
+
},
|
|
667
|
+
reply: replyMock,
|
|
668
|
+
startTyping: () => () => { },
|
|
669
|
+
};
|
|
670
|
+
// Emit the message event
|
|
671
|
+
mockConnector.emit("message", messageEvent);
|
|
672
|
+
// Wait for async processing
|
|
673
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
674
|
+
// Should have called trigger
|
|
675
|
+
expect(triggerMock).toHaveBeenCalledWith("test-agent", undefined, expect.objectContaining({
|
|
676
|
+
prompt: "Hello bot!",
|
|
677
|
+
}));
|
|
678
|
+
});
|
|
679
|
+
it("collects and sends streaming response with onMessage callback", async () => {
|
|
680
|
+
// Create trigger mock that invokes onMessage callback with streaming content
|
|
681
|
+
const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
|
|
682
|
+
// Simulate streaming messages from the agent
|
|
683
|
+
if (options?.onMessage) {
|
|
684
|
+
options.onMessage({ type: "assistant", content: "Hello! " });
|
|
685
|
+
options.onMessage({ type: "assistant", content: "How can I help you today?" });
|
|
686
|
+
// Non-assistant message should be ignored
|
|
687
|
+
options.onMessage({ type: "system", content: "System message" });
|
|
688
|
+
}
|
|
689
|
+
return { jobId: "streaming-job-123" };
|
|
690
|
+
});
|
|
691
|
+
const streamingEmitter = Object.assign(new EventEmitter(), {
|
|
692
|
+
trigger: customTriggerMock,
|
|
693
|
+
});
|
|
694
|
+
const streamingConfig = {
|
|
695
|
+
fleet: { name: "test-fleet" },
|
|
696
|
+
agents: [
|
|
697
|
+
createDiscordAgent("streaming-agent", {
|
|
698
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
699
|
+
session_expiry_hours: 24,
|
|
700
|
+
log_level: "standard",
|
|
701
|
+
guilds: [],
|
|
702
|
+
}),
|
|
703
|
+
],
|
|
704
|
+
configPath: "/test/herdctl.yaml",
|
|
705
|
+
configDir: "/test",
|
|
706
|
+
};
|
|
707
|
+
const streamingContext = {
|
|
708
|
+
getConfig: () => streamingConfig,
|
|
709
|
+
getStateDir: () => "/tmp/test-state",
|
|
710
|
+
getStateDirInfo: () => null,
|
|
711
|
+
getLogger: () => mockLogger,
|
|
712
|
+
getScheduler: () => null,
|
|
713
|
+
getStatus: () => "running",
|
|
714
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
715
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
716
|
+
getStoppedAt: () => null,
|
|
717
|
+
getLastError: () => null,
|
|
718
|
+
getCheckInterval: () => 1000,
|
|
719
|
+
emit: (event, ...args) => streamingEmitter.emit(event, ...args),
|
|
720
|
+
getEmitter: () => streamingEmitter,
|
|
721
|
+
};
|
|
722
|
+
const streamingManager = new DiscordManager(streamingContext);
|
|
723
|
+
const mockConnector = new EventEmitter();
|
|
724
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
725
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
726
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
727
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
728
|
+
status: "connected",
|
|
729
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
730
|
+
disconnectedAt: null,
|
|
731
|
+
reconnectAttempts: 0,
|
|
732
|
+
lastError: null,
|
|
733
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
734
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
735
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
736
|
+
});
|
|
737
|
+
mockConnector.agentName = "streaming-agent";
|
|
738
|
+
mockConnector.sessionManager = {
|
|
739
|
+
getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
|
|
740
|
+
touchSession: vi.fn().mockResolvedValue(undefined),
|
|
741
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
742
|
+
};
|
|
743
|
+
// @ts-expect-error - accessing private property for testing
|
|
744
|
+
streamingManager.connectors.set("streaming-agent", mockConnector);
|
|
745
|
+
// @ts-expect-error - accessing private property for testing
|
|
746
|
+
streamingManager.initialized = true;
|
|
747
|
+
await streamingManager.start();
|
|
748
|
+
// Create a mock message event
|
|
749
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
750
|
+
const messageEvent = {
|
|
751
|
+
agentName: "streaming-agent",
|
|
752
|
+
prompt: "Hello bot!",
|
|
753
|
+
context: {
|
|
754
|
+
messages: [],
|
|
755
|
+
wasMentioned: true,
|
|
756
|
+
prompt: "Hello bot!",
|
|
757
|
+
},
|
|
758
|
+
metadata: {
|
|
759
|
+
guildId: "guild1",
|
|
760
|
+
channelId: "channel1",
|
|
761
|
+
messageId: "msg1",
|
|
762
|
+
userId: "user1",
|
|
763
|
+
username: "TestUser",
|
|
764
|
+
wasMentioned: true,
|
|
765
|
+
mode: "mention",
|
|
766
|
+
},
|
|
767
|
+
reply: replyMock,
|
|
768
|
+
startTyping: () => () => { },
|
|
769
|
+
};
|
|
770
|
+
// Emit the message event
|
|
771
|
+
mockConnector.emit("message", messageEvent);
|
|
772
|
+
// Wait for async processing
|
|
773
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
774
|
+
// Should have collected the streaming messages and sent them
|
|
775
|
+
expect(replyMock).toHaveBeenCalledWith("Hello! How can I help you today?");
|
|
776
|
+
});
|
|
777
|
+
it("sends long streaming response with splitResponse", async () => {
|
|
778
|
+
// Create trigger mock that produces a long response
|
|
779
|
+
const longResponse = "This is a very long response. ".repeat(100); // About 3100 chars
|
|
780
|
+
const customTriggerMock = vi.fn().mockImplementation(async (_agentName, _scheduleName, options) => {
|
|
781
|
+
if (options?.onMessage) {
|
|
782
|
+
options.onMessage({ type: "assistant", content: longResponse });
|
|
783
|
+
}
|
|
784
|
+
return { jobId: "long-job-123" };
|
|
785
|
+
});
|
|
786
|
+
const streamingEmitter = Object.assign(new EventEmitter(), {
|
|
787
|
+
trigger: customTriggerMock,
|
|
788
|
+
});
|
|
789
|
+
const streamingConfig = {
|
|
790
|
+
fleet: { name: "test-fleet" },
|
|
791
|
+
agents: [
|
|
792
|
+
createDiscordAgent("long-agent", {
|
|
793
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
794
|
+
session_expiry_hours: 24,
|
|
795
|
+
log_level: "standard",
|
|
796
|
+
guilds: [],
|
|
797
|
+
}),
|
|
798
|
+
],
|
|
799
|
+
configPath: "/test/herdctl.yaml",
|
|
800
|
+
configDir: "/test",
|
|
801
|
+
};
|
|
802
|
+
const streamingContext = {
|
|
803
|
+
getConfig: () => streamingConfig,
|
|
804
|
+
getStateDir: () => "/tmp/test-state",
|
|
805
|
+
getStateDirInfo: () => null,
|
|
806
|
+
getLogger: () => mockLogger,
|
|
807
|
+
getScheduler: () => null,
|
|
808
|
+
getStatus: () => "running",
|
|
809
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
810
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
811
|
+
getStoppedAt: () => null,
|
|
812
|
+
getLastError: () => null,
|
|
813
|
+
getCheckInterval: () => 1000,
|
|
814
|
+
emit: (event, ...args) => streamingEmitter.emit(event, ...args),
|
|
815
|
+
getEmitter: () => streamingEmitter,
|
|
816
|
+
};
|
|
817
|
+
const streamingManager = new DiscordManager(streamingContext);
|
|
818
|
+
const mockConnector = new EventEmitter();
|
|
819
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
820
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
821
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
822
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
823
|
+
status: "connected",
|
|
824
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
825
|
+
disconnectedAt: null,
|
|
826
|
+
reconnectAttempts: 0,
|
|
827
|
+
lastError: null,
|
|
828
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
829
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
830
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
831
|
+
});
|
|
832
|
+
mockConnector.agentName = "long-agent";
|
|
833
|
+
mockConnector.sessionManager = {
|
|
834
|
+
getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
|
|
835
|
+
touchSession: vi.fn().mockResolvedValue(undefined),
|
|
836
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
837
|
+
};
|
|
838
|
+
// @ts-expect-error - accessing private property for testing
|
|
839
|
+
streamingManager.connectors.set("long-agent", mockConnector);
|
|
840
|
+
// @ts-expect-error - accessing private property for testing
|
|
841
|
+
streamingManager.initialized = true;
|
|
842
|
+
await streamingManager.start();
|
|
843
|
+
// Create a mock message event
|
|
844
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
845
|
+
const messageEvent = {
|
|
846
|
+
agentName: "long-agent",
|
|
847
|
+
prompt: "Hello bot!",
|
|
848
|
+
context: {
|
|
849
|
+
messages: [],
|
|
850
|
+
wasMentioned: true,
|
|
851
|
+
prompt: "Hello bot!",
|
|
852
|
+
},
|
|
853
|
+
metadata: {
|
|
854
|
+
guildId: "guild1",
|
|
855
|
+
channelId: "channel1",
|
|
856
|
+
messageId: "msg1",
|
|
857
|
+
userId: "user1",
|
|
858
|
+
username: "TestUser",
|
|
859
|
+
wasMentioned: true,
|
|
860
|
+
mode: "mention",
|
|
861
|
+
},
|
|
862
|
+
reply: replyMock,
|
|
863
|
+
startTyping: () => () => { },
|
|
864
|
+
};
|
|
865
|
+
// Emit the message event
|
|
866
|
+
mockConnector.emit("message", messageEvent);
|
|
867
|
+
// Wait for async processing
|
|
868
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
869
|
+
// Should have sent multiple messages (split response)
|
|
870
|
+
expect(replyMock).toHaveBeenCalledTimes(2);
|
|
871
|
+
});
|
|
872
|
+
it("handles message handler rejection via catch handler", async () => {
|
|
873
|
+
// This tests the .catch(error => this.handleError()) path in start()
|
|
874
|
+
// when handleMessage throws an error that propagates to the catch handler
|
|
875
|
+
// Create a config with no agents to trigger the "agent not found" error path
|
|
876
|
+
const emptyConfig = {
|
|
877
|
+
fleet: { name: "test-fleet" },
|
|
878
|
+
agents: [], // No agents!
|
|
879
|
+
configPath: "/test/herdctl.yaml",
|
|
880
|
+
configDir: "/test",
|
|
881
|
+
};
|
|
882
|
+
const errorEmitter = new EventEmitter();
|
|
883
|
+
const errorContext = {
|
|
884
|
+
getConfig: () => emptyConfig,
|
|
885
|
+
getStateDir: () => "/tmp/test-state",
|
|
886
|
+
getStateDirInfo: () => null,
|
|
887
|
+
getLogger: () => mockLogger,
|
|
888
|
+
getScheduler: () => null,
|
|
889
|
+
getStatus: () => "running",
|
|
890
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
891
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
892
|
+
getStoppedAt: () => null,
|
|
893
|
+
getLastError: () => null,
|
|
894
|
+
getCheckInterval: () => 1000,
|
|
895
|
+
emit: (event, ...args) => errorEmitter.emit(event, ...args),
|
|
896
|
+
getEmitter: () => errorEmitter,
|
|
897
|
+
};
|
|
898
|
+
const errorManager = new DiscordManager(errorContext);
|
|
899
|
+
const mockConnector = new EventEmitter();
|
|
900
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
901
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
902
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
903
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
904
|
+
status: "connected",
|
|
905
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
906
|
+
disconnectedAt: null,
|
|
907
|
+
reconnectAttempts: 0,
|
|
908
|
+
lastError: null,
|
|
909
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
910
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
911
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
912
|
+
});
|
|
913
|
+
mockConnector.agentName = "missing-agent";
|
|
914
|
+
mockConnector.sessionManager = {
|
|
915
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
916
|
+
};
|
|
917
|
+
// @ts-expect-error - accessing private property for testing
|
|
918
|
+
errorManager.connectors.set("missing-agent", mockConnector);
|
|
919
|
+
// @ts-expect-error - accessing private property for testing
|
|
920
|
+
errorManager.initialized = true;
|
|
921
|
+
await errorManager.start();
|
|
922
|
+
// Create a message event with a reply that throws
|
|
923
|
+
const replyMock = vi.fn().mockRejectedValue(new Error("Reply threw"));
|
|
924
|
+
const messageEvent = {
|
|
925
|
+
agentName: "missing-agent",
|
|
926
|
+
prompt: "Hello!",
|
|
927
|
+
context: {
|
|
928
|
+
messages: [],
|
|
929
|
+
wasMentioned: true,
|
|
930
|
+
prompt: "Hello!",
|
|
931
|
+
},
|
|
932
|
+
metadata: {
|
|
933
|
+
guildId: "guild1",
|
|
934
|
+
channelId: "channel1",
|
|
935
|
+
messageId: "msg1",
|
|
936
|
+
userId: "user1",
|
|
937
|
+
username: "TestUser",
|
|
938
|
+
wasMentioned: true,
|
|
939
|
+
mode: "mention",
|
|
940
|
+
},
|
|
941
|
+
reply: replyMock,
|
|
942
|
+
startTyping: () => () => { },
|
|
943
|
+
};
|
|
944
|
+
// Emit the message event - this will trigger handleMessage which will fail
|
|
945
|
+
// because agent is not in config, and then try to reply, and that also fails
|
|
946
|
+
mockConnector.emit("message", messageEvent);
|
|
947
|
+
// Wait for async processing
|
|
948
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
949
|
+
// The catch handler should have caught the error and called handleError
|
|
950
|
+
// which logs the error via discord:error event
|
|
951
|
+
expect(mockLogger.error).toHaveBeenCalled();
|
|
952
|
+
});
|
|
953
|
+
it("handles error events from connector", async () => {
|
|
954
|
+
// Create a mock connector
|
|
955
|
+
const mockConnector = new EventEmitter();
|
|
956
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
957
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
958
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
959
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
960
|
+
status: "connected",
|
|
961
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
962
|
+
disconnectedAt: null,
|
|
963
|
+
reconnectAttempts: 0,
|
|
964
|
+
lastError: null,
|
|
965
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
966
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
967
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
968
|
+
});
|
|
969
|
+
mockConnector.agentName = "test-agent";
|
|
970
|
+
// @ts-expect-error - accessing private property for testing
|
|
971
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
972
|
+
// @ts-expect-error - accessing private property for testing
|
|
973
|
+
manager.initialized = true;
|
|
974
|
+
await manager.start();
|
|
975
|
+
// Emit an error event
|
|
976
|
+
const errorEvent = {
|
|
977
|
+
agentName: "test-agent",
|
|
978
|
+
error: new Error("Test error"),
|
|
979
|
+
};
|
|
980
|
+
mockConnector.emit("error", errorEvent);
|
|
981
|
+
// Wait for async processing
|
|
982
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
983
|
+
// Should have logged the error
|
|
984
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord connector error"));
|
|
985
|
+
});
|
|
986
|
+
it("sends formatted error reply when trigger fails", async () => {
|
|
987
|
+
// Create a mock connector
|
|
988
|
+
const mockConnector = new EventEmitter();
|
|
989
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
990
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
991
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
992
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
993
|
+
status: "connected",
|
|
994
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
995
|
+
disconnectedAt: null,
|
|
996
|
+
reconnectAttempts: 0,
|
|
997
|
+
lastError: null,
|
|
998
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
999
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1000
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1001
|
+
});
|
|
1002
|
+
mockConnector.agentName = "test-agent";
|
|
1003
|
+
// Make trigger fail
|
|
1004
|
+
triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
|
|
1005
|
+
// @ts-expect-error - accessing private property for testing
|
|
1006
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1007
|
+
// @ts-expect-error - accessing private property for testing
|
|
1008
|
+
manager.initialized = true;
|
|
1009
|
+
await manager.start();
|
|
1010
|
+
// Create a mock message event
|
|
1011
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1012
|
+
const messageEvent = {
|
|
1013
|
+
agentName: "test-agent",
|
|
1014
|
+
prompt: "Hello bot!",
|
|
1015
|
+
context: {
|
|
1016
|
+
messages: [],
|
|
1017
|
+
wasMentioned: true,
|
|
1018
|
+
prompt: "Hello bot!",
|
|
1019
|
+
},
|
|
1020
|
+
metadata: {
|
|
1021
|
+
guildId: "guild1",
|
|
1022
|
+
channelId: "channel1",
|
|
1023
|
+
messageId: "msg1",
|
|
1024
|
+
userId: "user1",
|
|
1025
|
+
username: "TestUser",
|
|
1026
|
+
wasMentioned: true,
|
|
1027
|
+
mode: "mention",
|
|
1028
|
+
},
|
|
1029
|
+
reply: replyMock,
|
|
1030
|
+
startTyping: () => () => { },
|
|
1031
|
+
};
|
|
1032
|
+
// Emit the message event
|
|
1033
|
+
mockConnector.emit("message", messageEvent);
|
|
1034
|
+
// Wait for async processing
|
|
1035
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1036
|
+
// Should have sent a formatted error reply
|
|
1037
|
+
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("❌ **Error**:"));
|
|
1038
|
+
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("Agent execution failed"));
|
|
1039
|
+
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("/reset"));
|
|
1040
|
+
});
|
|
1041
|
+
it("handles error reply failure when trigger fails", async () => {
|
|
1042
|
+
// Create a mock connector
|
|
1043
|
+
const mockConnector = new EventEmitter();
|
|
1044
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1045
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1046
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1047
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1048
|
+
status: "connected",
|
|
1049
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1050
|
+
disconnectedAt: null,
|
|
1051
|
+
reconnectAttempts: 0,
|
|
1052
|
+
lastError: null,
|
|
1053
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1054
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1055
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1056
|
+
});
|
|
1057
|
+
mockConnector.agentName = "test-agent";
|
|
1058
|
+
// Make trigger fail
|
|
1059
|
+
triggerMock.mockRejectedValueOnce(new Error("Agent execution failed"));
|
|
1060
|
+
// @ts-expect-error - accessing private property for testing
|
|
1061
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1062
|
+
// @ts-expect-error - accessing private property for testing
|
|
1063
|
+
manager.initialized = true;
|
|
1064
|
+
await manager.start();
|
|
1065
|
+
// Create a mock message event with reply that also fails
|
|
1066
|
+
const replyMock = vi.fn().mockRejectedValue(new Error("Reply also failed"));
|
|
1067
|
+
const messageEvent = {
|
|
1068
|
+
agentName: "test-agent",
|
|
1069
|
+
prompt: "Hello bot!",
|
|
1070
|
+
context: {
|
|
1071
|
+
messages: [],
|
|
1072
|
+
wasMentioned: true,
|
|
1073
|
+
prompt: "Hello bot!",
|
|
1074
|
+
},
|
|
1075
|
+
metadata: {
|
|
1076
|
+
guildId: "guild1",
|
|
1077
|
+
channelId: "channel1",
|
|
1078
|
+
messageId: "msg1",
|
|
1079
|
+
userId: "user1",
|
|
1080
|
+
username: "TestUser",
|
|
1081
|
+
wasMentioned: true,
|
|
1082
|
+
mode: "mention",
|
|
1083
|
+
},
|
|
1084
|
+
reply: replyMock,
|
|
1085
|
+
startTyping: () => () => { },
|
|
1086
|
+
};
|
|
1087
|
+
// Emit the message event
|
|
1088
|
+
mockConnector.emit("message", messageEvent);
|
|
1089
|
+
// Wait for async processing
|
|
1090
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1091
|
+
// Should have logged both errors
|
|
1092
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Discord message handling failed"));
|
|
1093
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
|
|
1094
|
+
});
|
|
1095
|
+
it("sends error reply when agent not found", async () => {
|
|
1096
|
+
// Create a mock connector
|
|
1097
|
+
const mockConnector = new EventEmitter();
|
|
1098
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1099
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1100
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1101
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1102
|
+
status: "connected",
|
|
1103
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1104
|
+
disconnectedAt: null,
|
|
1105
|
+
reconnectAttempts: 0,
|
|
1106
|
+
lastError: null,
|
|
1107
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1108
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1109
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1110
|
+
});
|
|
1111
|
+
mockConnector.agentName = "unknown-agent";
|
|
1112
|
+
// @ts-expect-error - accessing private property for testing
|
|
1113
|
+
manager.connectors.set("unknown-agent", mockConnector);
|
|
1114
|
+
// @ts-expect-error - accessing private property for testing
|
|
1115
|
+
manager.initialized = true;
|
|
1116
|
+
await manager.start();
|
|
1117
|
+
// Create a mock message event for an agent not in config
|
|
1118
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1119
|
+
const messageEvent = {
|
|
1120
|
+
agentName: "unknown-agent",
|
|
1121
|
+
prompt: "Hello bot!",
|
|
1122
|
+
context: {
|
|
1123
|
+
messages: [],
|
|
1124
|
+
wasMentioned: true,
|
|
1125
|
+
prompt: "Hello bot!",
|
|
1126
|
+
},
|
|
1127
|
+
metadata: {
|
|
1128
|
+
guildId: "guild1",
|
|
1129
|
+
channelId: "channel1",
|
|
1130
|
+
messageId: "msg1",
|
|
1131
|
+
userId: "user1",
|
|
1132
|
+
username: "TestUser",
|
|
1133
|
+
wasMentioned: true,
|
|
1134
|
+
mode: "mention",
|
|
1135
|
+
},
|
|
1136
|
+
reply: replyMock,
|
|
1137
|
+
startTyping: () => () => { },
|
|
1138
|
+
};
|
|
1139
|
+
// Emit the message event
|
|
1140
|
+
mockConnector.emit("message", messageEvent);
|
|
1141
|
+
// Wait for async processing
|
|
1142
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1143
|
+
// Should have sent an error reply
|
|
1144
|
+
expect(replyMock).toHaveBeenCalledWith(expect.stringContaining("not properly configured"));
|
|
1145
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
describe("extractMessageContent", () => {
|
|
1149
|
+
it("extracts direct string content", () => {
|
|
1150
|
+
// @ts-expect-error - accessing private method for testing
|
|
1151
|
+
const result = manager.extractMessageContent({
|
|
1152
|
+
type: "assistant",
|
|
1153
|
+
content: "Direct content",
|
|
1154
|
+
});
|
|
1155
|
+
expect(result).toBe("Direct content");
|
|
1156
|
+
});
|
|
1157
|
+
it("extracts nested message content", () => {
|
|
1158
|
+
// @ts-expect-error - accessing private method for testing
|
|
1159
|
+
const result = manager.extractMessageContent({
|
|
1160
|
+
type: "assistant",
|
|
1161
|
+
message: { content: "Nested content" },
|
|
1162
|
+
});
|
|
1163
|
+
expect(result).toBe("Nested content");
|
|
1164
|
+
});
|
|
1165
|
+
it("extracts text from content blocks", () => {
|
|
1166
|
+
// @ts-expect-error - accessing private method for testing
|
|
1167
|
+
const result = manager.extractMessageContent({
|
|
1168
|
+
type: "assistant",
|
|
1169
|
+
message: {
|
|
1170
|
+
content: [
|
|
1171
|
+
{ type: "text", text: "First part" },
|
|
1172
|
+
{ type: "text", text: " Second part" },
|
|
1173
|
+
],
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
expect(result).toBe("First part Second part");
|
|
1177
|
+
});
|
|
1178
|
+
it("returns undefined for empty content", () => {
|
|
1179
|
+
// @ts-expect-error - accessing private method for testing
|
|
1180
|
+
const result = manager.extractMessageContent({
|
|
1181
|
+
type: "assistant",
|
|
1182
|
+
});
|
|
1183
|
+
expect(result).toBeUndefined();
|
|
1184
|
+
});
|
|
1185
|
+
it("returns undefined for non-text content blocks", () => {
|
|
1186
|
+
// @ts-expect-error - accessing private method for testing
|
|
1187
|
+
const result = manager.extractMessageContent({
|
|
1188
|
+
type: "assistant",
|
|
1189
|
+
message: {
|
|
1190
|
+
content: [
|
|
1191
|
+
{ type: "tool_use", name: "some_tool" },
|
|
1192
|
+
],
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
expect(result).toBeUndefined();
|
|
1196
|
+
});
|
|
1197
|
+
it("returns undefined for empty string content", () => {
|
|
1198
|
+
// @ts-expect-error - accessing private method for testing
|
|
1199
|
+
const result = manager.extractMessageContent({
|
|
1200
|
+
type: "assistant",
|
|
1201
|
+
content: "",
|
|
1202
|
+
});
|
|
1203
|
+
expect(result).toBeUndefined();
|
|
1204
|
+
});
|
|
1205
|
+
it("handles mixed content blocks (text and non-text)", () => {
|
|
1206
|
+
// @ts-expect-error - accessing private method for testing
|
|
1207
|
+
const result = manager.extractMessageContent({
|
|
1208
|
+
type: "assistant",
|
|
1209
|
+
message: {
|
|
1210
|
+
content: [
|
|
1211
|
+
{ type: "tool_use", name: "some_tool" },
|
|
1212
|
+
{ type: "text", text: "After tool" },
|
|
1213
|
+
],
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
expect(result).toBe("After tool");
|
|
1217
|
+
});
|
|
1218
|
+
it("handles empty content blocks array", () => {
|
|
1219
|
+
// @ts-expect-error - accessing private method for testing
|
|
1220
|
+
const result = manager.extractMessageContent({
|
|
1221
|
+
type: "assistant",
|
|
1222
|
+
message: {
|
|
1223
|
+
content: [],
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
expect(result).toBeUndefined();
|
|
1227
|
+
});
|
|
1228
|
+
it("returns undefined for content that is not a string or array", () => {
|
|
1229
|
+
// @ts-expect-error - accessing private method for testing
|
|
1230
|
+
const result = manager.extractMessageContent({
|
|
1231
|
+
type: "assistant",
|
|
1232
|
+
message: {
|
|
1233
|
+
content: { someObject: "value" }, // Not string or array
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
expect(result).toBeUndefined();
|
|
1237
|
+
});
|
|
1238
|
+
it("handles content blocks with missing text property", () => {
|
|
1239
|
+
// @ts-expect-error - accessing private method for testing
|
|
1240
|
+
const result = manager.extractMessageContent({
|
|
1241
|
+
type: "assistant",
|
|
1242
|
+
message: {
|
|
1243
|
+
content: [
|
|
1244
|
+
{ type: "text" }, // Missing text property
|
|
1245
|
+
],
|
|
1246
|
+
},
|
|
1247
|
+
});
|
|
1248
|
+
expect(result).toBeUndefined();
|
|
1249
|
+
});
|
|
1250
|
+
it("handles content block with non-string text", () => {
|
|
1251
|
+
// @ts-expect-error - accessing private method for testing
|
|
1252
|
+
const result = manager.extractMessageContent({
|
|
1253
|
+
type: "assistant",
|
|
1254
|
+
message: {
|
|
1255
|
+
content: [
|
|
1256
|
+
{ type: "text", text: 123 }, // Non-string text
|
|
1257
|
+
],
|
|
1258
|
+
},
|
|
1259
|
+
});
|
|
1260
|
+
expect(result).toBeUndefined();
|
|
1261
|
+
});
|
|
1262
|
+
});
|
|
1263
|
+
});
|
|
1264
|
+
describe("DiscordManager session integration", () => {
|
|
1265
|
+
let manager;
|
|
1266
|
+
let mockContext;
|
|
1267
|
+
let triggerMock;
|
|
1268
|
+
let emitterWithTrigger;
|
|
1269
|
+
let mockSessionManager;
|
|
1270
|
+
beforeEach(() => {
|
|
1271
|
+
vi.clearAllMocks();
|
|
1272
|
+
// Create mock session manager
|
|
1273
|
+
mockSessionManager = {
|
|
1274
|
+
agentName: "test-agent",
|
|
1275
|
+
getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "session-123", isNew: false }),
|
|
1276
|
+
touchSession: vi.fn().mockResolvedValue(undefined),
|
|
1277
|
+
clearSession: vi.fn().mockResolvedValue(true),
|
|
1278
|
+
cleanupExpiredSessions: vi.fn().mockResolvedValue(0),
|
|
1279
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(5),
|
|
1280
|
+
};
|
|
1281
|
+
// Create a mock FleetManager (emitter) with trigger method
|
|
1282
|
+
triggerMock = vi.fn().mockResolvedValue({ jobId: "job-123" });
|
|
1283
|
+
emitterWithTrigger = Object.assign(new EventEmitter(), {
|
|
1284
|
+
trigger: triggerMock,
|
|
1285
|
+
});
|
|
1286
|
+
const config = {
|
|
1287
|
+
fleet: { name: "test-fleet" },
|
|
1288
|
+
agents: [
|
|
1289
|
+
createDiscordAgent("test-agent", {
|
|
1290
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
1291
|
+
session_expiry_hours: 24,
|
|
1292
|
+
log_level: "standard",
|
|
1293
|
+
guilds: [],
|
|
1294
|
+
}),
|
|
1295
|
+
],
|
|
1296
|
+
configPath: "/test/herdctl.yaml",
|
|
1297
|
+
configDir: "/test",
|
|
1298
|
+
};
|
|
1299
|
+
mockContext = {
|
|
1300
|
+
getConfig: () => config,
|
|
1301
|
+
getStateDir: () => "/tmp/test-state",
|
|
1302
|
+
getStateDirInfo: () => null,
|
|
1303
|
+
getLogger: () => mockLogger,
|
|
1304
|
+
getScheduler: () => null,
|
|
1305
|
+
getStatus: () => "running",
|
|
1306
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
1307
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
1308
|
+
getStoppedAt: () => null,
|
|
1309
|
+
getLastError: () => null,
|
|
1310
|
+
getCheckInterval: () => 1000,
|
|
1311
|
+
emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
|
|
1312
|
+
getEmitter: () => emitterWithTrigger,
|
|
1313
|
+
};
|
|
1314
|
+
manager = new DiscordManager(mockContext);
|
|
1315
|
+
});
|
|
1316
|
+
it("calls getOrCreateSession on message", async () => {
|
|
1317
|
+
// Create a mock connector with session manager
|
|
1318
|
+
const mockConnector = new EventEmitter();
|
|
1319
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1320
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1321
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1322
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1323
|
+
status: "connected",
|
|
1324
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1325
|
+
disconnectedAt: null,
|
|
1326
|
+
reconnectAttempts: 0,
|
|
1327
|
+
lastError: null,
|
|
1328
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1329
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1330
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1331
|
+
});
|
|
1332
|
+
mockConnector.agentName = "test-agent";
|
|
1333
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1334
|
+
// @ts-expect-error - accessing private property for testing
|
|
1335
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1336
|
+
// @ts-expect-error - accessing private property for testing
|
|
1337
|
+
manager.initialized = true;
|
|
1338
|
+
await manager.start();
|
|
1339
|
+
// Create a mock message event
|
|
1340
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1341
|
+
const messageEvent = {
|
|
1342
|
+
agentName: "test-agent",
|
|
1343
|
+
prompt: "Hello bot!",
|
|
1344
|
+
context: {
|
|
1345
|
+
messages: [],
|
|
1346
|
+
wasMentioned: true,
|
|
1347
|
+
prompt: "Hello bot!",
|
|
1348
|
+
},
|
|
1349
|
+
metadata: {
|
|
1350
|
+
guildId: "guild1",
|
|
1351
|
+
channelId: "channel1",
|
|
1352
|
+
messageId: "msg1",
|
|
1353
|
+
userId: "user1",
|
|
1354
|
+
username: "TestUser",
|
|
1355
|
+
wasMentioned: true,
|
|
1356
|
+
mode: "mention",
|
|
1357
|
+
},
|
|
1358
|
+
reply: replyMock,
|
|
1359
|
+
startTyping: () => () => { },
|
|
1360
|
+
};
|
|
1361
|
+
// Emit the message event
|
|
1362
|
+
mockConnector.emit("message", messageEvent);
|
|
1363
|
+
// Wait for async processing
|
|
1364
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1365
|
+
// Should have called getOrCreateSession
|
|
1366
|
+
expect(mockSessionManager.getOrCreateSession).toHaveBeenCalledWith("channel1");
|
|
1367
|
+
});
|
|
1368
|
+
it("calls touchSession after successful response", async () => {
|
|
1369
|
+
// Create a mock connector with session manager
|
|
1370
|
+
const mockConnector = new EventEmitter();
|
|
1371
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1372
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1373
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1374
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1375
|
+
status: "connected",
|
|
1376
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1377
|
+
disconnectedAt: null,
|
|
1378
|
+
reconnectAttempts: 0,
|
|
1379
|
+
lastError: null,
|
|
1380
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1381
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1382
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1383
|
+
});
|
|
1384
|
+
mockConnector.agentName = "test-agent";
|
|
1385
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1386
|
+
// @ts-expect-error - accessing private property for testing
|
|
1387
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1388
|
+
// @ts-expect-error - accessing private property for testing
|
|
1389
|
+
manager.initialized = true;
|
|
1390
|
+
await manager.start();
|
|
1391
|
+
// Create a mock message event
|
|
1392
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1393
|
+
const messageEvent = {
|
|
1394
|
+
agentName: "test-agent",
|
|
1395
|
+
prompt: "Hello bot!",
|
|
1396
|
+
context: {
|
|
1397
|
+
messages: [],
|
|
1398
|
+
wasMentioned: true,
|
|
1399
|
+
prompt: "Hello bot!",
|
|
1400
|
+
},
|
|
1401
|
+
metadata: {
|
|
1402
|
+
guildId: "guild1",
|
|
1403
|
+
channelId: "channel1",
|
|
1404
|
+
messageId: "msg1",
|
|
1405
|
+
userId: "user1",
|
|
1406
|
+
username: "TestUser",
|
|
1407
|
+
wasMentioned: true,
|
|
1408
|
+
mode: "mention",
|
|
1409
|
+
},
|
|
1410
|
+
reply: replyMock,
|
|
1411
|
+
startTyping: () => () => { },
|
|
1412
|
+
};
|
|
1413
|
+
// Emit the message event
|
|
1414
|
+
mockConnector.emit("message", messageEvent);
|
|
1415
|
+
// Wait for async processing
|
|
1416
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1417
|
+
// Should have called touchSession
|
|
1418
|
+
expect(mockSessionManager.touchSession).toHaveBeenCalledWith("channel1");
|
|
1419
|
+
});
|
|
1420
|
+
it("handles session manager errors gracefully", async () => {
|
|
1421
|
+
// Create a mock connector with session manager that fails
|
|
1422
|
+
mockSessionManager.getOrCreateSession.mockRejectedValue(new Error("Session error"));
|
|
1423
|
+
const mockConnector = new EventEmitter();
|
|
1424
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1425
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1426
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1427
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1428
|
+
status: "connected",
|
|
1429
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1430
|
+
disconnectedAt: null,
|
|
1431
|
+
reconnectAttempts: 0,
|
|
1432
|
+
lastError: null,
|
|
1433
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1434
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1435
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1436
|
+
});
|
|
1437
|
+
mockConnector.agentName = "test-agent";
|
|
1438
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1439
|
+
// @ts-expect-error - accessing private property for testing
|
|
1440
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1441
|
+
// @ts-expect-error - accessing private property for testing
|
|
1442
|
+
manager.initialized = true;
|
|
1443
|
+
await manager.start();
|
|
1444
|
+
// Create a mock message event
|
|
1445
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1446
|
+
const messageEvent = {
|
|
1447
|
+
agentName: "test-agent",
|
|
1448
|
+
prompt: "Hello bot!",
|
|
1449
|
+
context: {
|
|
1450
|
+
messages: [],
|
|
1451
|
+
wasMentioned: true,
|
|
1452
|
+
prompt: "Hello bot!",
|
|
1453
|
+
},
|
|
1454
|
+
metadata: {
|
|
1455
|
+
guildId: "guild1",
|
|
1456
|
+
channelId: "channel1",
|
|
1457
|
+
messageId: "msg1",
|
|
1458
|
+
userId: "user1",
|
|
1459
|
+
username: "TestUser",
|
|
1460
|
+
wasMentioned: true,
|
|
1461
|
+
mode: "mention",
|
|
1462
|
+
},
|
|
1463
|
+
reply: replyMock,
|
|
1464
|
+
startTyping: () => () => { },
|
|
1465
|
+
};
|
|
1466
|
+
// Emit the message event
|
|
1467
|
+
mockConnector.emit("message", messageEvent);
|
|
1468
|
+
// Wait for async processing
|
|
1469
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1470
|
+
// Should have logged a warning but continued processing
|
|
1471
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get/create session"));
|
|
1472
|
+
// Should still have called trigger
|
|
1473
|
+
expect(triggerMock).toHaveBeenCalled();
|
|
1474
|
+
});
|
|
1475
|
+
it("handles touchSession errors gracefully", async () => {
|
|
1476
|
+
// Create a mock connector with session manager where touchSession fails
|
|
1477
|
+
mockSessionManager.touchSession.mockRejectedValue(new Error("Touch error"));
|
|
1478
|
+
const mockConnector = new EventEmitter();
|
|
1479
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1480
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1481
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1482
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1483
|
+
status: "connected",
|
|
1484
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1485
|
+
disconnectedAt: null,
|
|
1486
|
+
reconnectAttempts: 0,
|
|
1487
|
+
lastError: null,
|
|
1488
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1489
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1490
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1491
|
+
});
|
|
1492
|
+
mockConnector.agentName = "test-agent";
|
|
1493
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1494
|
+
// @ts-expect-error - accessing private property for testing
|
|
1495
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1496
|
+
// @ts-expect-error - accessing private property for testing
|
|
1497
|
+
manager.initialized = true;
|
|
1498
|
+
await manager.start();
|
|
1499
|
+
// Create a mock message event
|
|
1500
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1501
|
+
const messageEvent = {
|
|
1502
|
+
agentName: "test-agent",
|
|
1503
|
+
prompt: "Hello bot!",
|
|
1504
|
+
context: {
|
|
1505
|
+
messages: [],
|
|
1506
|
+
wasMentioned: true,
|
|
1507
|
+
prompt: "Hello bot!",
|
|
1508
|
+
},
|
|
1509
|
+
metadata: {
|
|
1510
|
+
guildId: "guild1",
|
|
1511
|
+
channelId: "channel1",
|
|
1512
|
+
messageId: "msg1",
|
|
1513
|
+
userId: "user1",
|
|
1514
|
+
username: "TestUser",
|
|
1515
|
+
wasMentioned: true,
|
|
1516
|
+
mode: "mention",
|
|
1517
|
+
},
|
|
1518
|
+
reply: replyMock,
|
|
1519
|
+
startTyping: () => () => { },
|
|
1520
|
+
};
|
|
1521
|
+
// Emit the message event
|
|
1522
|
+
mockConnector.emit("message", messageEvent);
|
|
1523
|
+
// Wait for async processing
|
|
1524
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1525
|
+
// Should have logged a warning but continued
|
|
1526
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to touch session"));
|
|
1527
|
+
// Reply should still have been sent
|
|
1528
|
+
expect(replyMock).toHaveBeenCalled();
|
|
1529
|
+
});
|
|
1530
|
+
it("logs session count on stop", async () => {
|
|
1531
|
+
// Create a mock connector with session manager
|
|
1532
|
+
const mockConnector = new EventEmitter();
|
|
1533
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1534
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1535
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1536
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1537
|
+
status: "connected",
|
|
1538
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1539
|
+
disconnectedAt: null,
|
|
1540
|
+
reconnectAttempts: 0,
|
|
1541
|
+
lastError: null,
|
|
1542
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1543
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1544
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1545
|
+
});
|
|
1546
|
+
mockConnector.agentName = "test-agent";
|
|
1547
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1548
|
+
// @ts-expect-error - accessing private property for testing
|
|
1549
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1550
|
+
// @ts-expect-error - accessing private property for testing
|
|
1551
|
+
manager.initialized = true;
|
|
1552
|
+
await manager.stop();
|
|
1553
|
+
// Should have queried session count
|
|
1554
|
+
expect(mockSessionManager.getActiveSessionCount).toHaveBeenCalled();
|
|
1555
|
+
// Should have logged about preserving sessions
|
|
1556
|
+
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Preserving 5 active session(s)"));
|
|
1557
|
+
});
|
|
1558
|
+
it("handles getActiveSessionCount errors on stop", async () => {
|
|
1559
|
+
// Create a mock connector with session manager that fails
|
|
1560
|
+
mockSessionManager.getActiveSessionCount.mockRejectedValue(new Error("Count error"));
|
|
1561
|
+
const mockConnector = new EventEmitter();
|
|
1562
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1563
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1564
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1565
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1566
|
+
status: "connected",
|
|
1567
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1568
|
+
disconnectedAt: null,
|
|
1569
|
+
reconnectAttempts: 0,
|
|
1570
|
+
lastError: null,
|
|
1571
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1572
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1573
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1574
|
+
});
|
|
1575
|
+
mockConnector.agentName = "test-agent";
|
|
1576
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1577
|
+
// @ts-expect-error - accessing private property for testing
|
|
1578
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1579
|
+
// @ts-expect-error - accessing private property for testing
|
|
1580
|
+
manager.initialized = true;
|
|
1581
|
+
await manager.stop();
|
|
1582
|
+
// Should have warned about the error
|
|
1583
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get session count"));
|
|
1584
|
+
// Should still disconnect
|
|
1585
|
+
expect(mockConnector.disconnect).toHaveBeenCalled();
|
|
1586
|
+
});
|
|
1587
|
+
it("does not log session preservation when count is 0", async () => {
|
|
1588
|
+
// Create a mock connector with session manager returning 0 sessions
|
|
1589
|
+
mockSessionManager.getActiveSessionCount.mockResolvedValue(0);
|
|
1590
|
+
const mockConnector = new EventEmitter();
|
|
1591
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1592
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1593
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1594
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1595
|
+
status: "connected",
|
|
1596
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1597
|
+
disconnectedAt: null,
|
|
1598
|
+
reconnectAttempts: 0,
|
|
1599
|
+
lastError: null,
|
|
1600
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1601
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1602
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1603
|
+
});
|
|
1604
|
+
mockConnector.agentName = "test-agent";
|
|
1605
|
+
mockConnector.sessionManager = mockSessionManager;
|
|
1606
|
+
// @ts-expect-error - accessing private property for testing
|
|
1607
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1608
|
+
// @ts-expect-error - accessing private property for testing
|
|
1609
|
+
manager.initialized = true;
|
|
1610
|
+
await manager.stop();
|
|
1611
|
+
// Should NOT have logged about preserving sessions (0 sessions)
|
|
1612
|
+
expect(mockLogger.info).not.toHaveBeenCalledWith(expect.stringContaining("Preserving"));
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
describe("DiscordManager lifecycle", () => {
|
|
1616
|
+
beforeEach(() => {
|
|
1617
|
+
vi.clearAllMocks();
|
|
1618
|
+
});
|
|
1619
|
+
it("handles connect failure gracefully", async () => {
|
|
1620
|
+
const mockConnector = new EventEmitter();
|
|
1621
|
+
mockConnector.connect = vi.fn().mockRejectedValue(new Error("Connection failed"));
|
|
1622
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1623
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(false);
|
|
1624
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1625
|
+
status: "error",
|
|
1626
|
+
connectedAt: null,
|
|
1627
|
+
disconnectedAt: null,
|
|
1628
|
+
reconnectAttempts: 0,
|
|
1629
|
+
lastError: "Connection failed",
|
|
1630
|
+
botUser: null,
|
|
1631
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1632
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1633
|
+
});
|
|
1634
|
+
mockConnector.agentName = "test-agent";
|
|
1635
|
+
mockConnector.sessionManager = {
|
|
1636
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1637
|
+
};
|
|
1638
|
+
const ctx = createMockContext(null);
|
|
1639
|
+
const manager = new DiscordManager(ctx);
|
|
1640
|
+
// @ts-expect-error - accessing private property for testing
|
|
1641
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1642
|
+
// @ts-expect-error - accessing private property for testing
|
|
1643
|
+
manager.initialized = true;
|
|
1644
|
+
// Should not throw
|
|
1645
|
+
await manager.start();
|
|
1646
|
+
// Should have logged the error
|
|
1647
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to connect Discord"));
|
|
1648
|
+
});
|
|
1649
|
+
it("handles disconnect failure gracefully", async () => {
|
|
1650
|
+
const mockConnector = new EventEmitter();
|
|
1651
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1652
|
+
mockConnector.disconnect = vi.fn().mockRejectedValue(new Error("Disconnect failed"));
|
|
1653
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1654
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1655
|
+
status: "connected",
|
|
1656
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1657
|
+
disconnectedAt: null,
|
|
1658
|
+
reconnectAttempts: 0,
|
|
1659
|
+
lastError: null,
|
|
1660
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1661
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1662
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1663
|
+
});
|
|
1664
|
+
mockConnector.agentName = "test-agent";
|
|
1665
|
+
mockConnector.sessionManager = {
|
|
1666
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1667
|
+
};
|
|
1668
|
+
const ctx = createMockContext(null);
|
|
1669
|
+
const manager = new DiscordManager(ctx);
|
|
1670
|
+
// @ts-expect-error - accessing private property for testing
|
|
1671
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1672
|
+
// @ts-expect-error - accessing private property for testing
|
|
1673
|
+
manager.initialized = true;
|
|
1674
|
+
// Should not throw
|
|
1675
|
+
await manager.stop();
|
|
1676
|
+
// Should have logged the error
|
|
1677
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Error disconnecting Discord"));
|
|
1678
|
+
});
|
|
1679
|
+
it("reports correct connected count", async () => {
|
|
1680
|
+
const connectedConnector = new EventEmitter();
|
|
1681
|
+
connectedConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1682
|
+
connectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1683
|
+
connectedConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1684
|
+
connectedConnector.getState = vi.fn().mockReturnValue({
|
|
1685
|
+
status: "connected",
|
|
1686
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1687
|
+
disconnectedAt: null,
|
|
1688
|
+
reconnectAttempts: 0,
|
|
1689
|
+
lastError: null,
|
|
1690
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1691
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1692
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1693
|
+
});
|
|
1694
|
+
connectedConnector.agentName = "connected-agent";
|
|
1695
|
+
connectedConnector.sessionManager = {
|
|
1696
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1697
|
+
};
|
|
1698
|
+
const disconnectedConnector = new EventEmitter();
|
|
1699
|
+
disconnectedConnector.connect = vi.fn().mockRejectedValue(new Error("Failed"));
|
|
1700
|
+
disconnectedConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1701
|
+
disconnectedConnector.isConnected = vi.fn().mockReturnValue(false);
|
|
1702
|
+
disconnectedConnector.getState = vi.fn().mockReturnValue({
|
|
1703
|
+
status: "error",
|
|
1704
|
+
connectedAt: null,
|
|
1705
|
+
disconnectedAt: null,
|
|
1706
|
+
reconnectAttempts: 0,
|
|
1707
|
+
lastError: "Failed",
|
|
1708
|
+
botUser: null,
|
|
1709
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1710
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1711
|
+
});
|
|
1712
|
+
disconnectedConnector.agentName = "disconnected-agent";
|
|
1713
|
+
disconnectedConnector.sessionManager = {
|
|
1714
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1715
|
+
};
|
|
1716
|
+
const ctx = createMockContext(null);
|
|
1717
|
+
const manager = new DiscordManager(ctx);
|
|
1718
|
+
// @ts-expect-error - accessing private property for testing
|
|
1719
|
+
manager.connectors.set("connected-agent", connectedConnector);
|
|
1720
|
+
// @ts-expect-error - accessing private property for testing
|
|
1721
|
+
manager.connectors.set("disconnected-agent", disconnectedConnector);
|
|
1722
|
+
// @ts-expect-error - accessing private property for testing
|
|
1723
|
+
manager.initialized = true;
|
|
1724
|
+
await manager.start();
|
|
1725
|
+
// Should report correct counts
|
|
1726
|
+
expect(manager.getConnectedCount()).toBe(1);
|
|
1727
|
+
expect(manager.getConnectorNames()).toEqual(["connected-agent", "disconnected-agent"]);
|
|
1728
|
+
});
|
|
1729
|
+
it("emits discord:message:handled event on successful message handling", async () => {
|
|
1730
|
+
const eventEmitter = new EventEmitter();
|
|
1731
|
+
const emittedEvents = [];
|
|
1732
|
+
// Track emitted events
|
|
1733
|
+
eventEmitter.on("discord:message:handled", (data) => {
|
|
1734
|
+
emittedEvents.push({ event: "discord:message:handled", data });
|
|
1735
|
+
});
|
|
1736
|
+
const triggerMock = vi.fn().mockResolvedValue({ jobId: "job-456" });
|
|
1737
|
+
const emitterWithTrigger = Object.assign(eventEmitter, {
|
|
1738
|
+
trigger: triggerMock,
|
|
1739
|
+
});
|
|
1740
|
+
const config = {
|
|
1741
|
+
fleet: { name: "test-fleet" },
|
|
1742
|
+
agents: [
|
|
1743
|
+
createDiscordAgent("test-agent", {
|
|
1744
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
1745
|
+
session_expiry_hours: 24,
|
|
1746
|
+
log_level: "standard",
|
|
1747
|
+
guilds: [],
|
|
1748
|
+
}),
|
|
1749
|
+
],
|
|
1750
|
+
configPath: "/test/herdctl.yaml",
|
|
1751
|
+
configDir: "/test",
|
|
1752
|
+
};
|
|
1753
|
+
const mockContext = {
|
|
1754
|
+
getConfig: () => config,
|
|
1755
|
+
getStateDir: () => "/tmp/test-state",
|
|
1756
|
+
getStateDirInfo: () => null,
|
|
1757
|
+
getLogger: () => mockLogger,
|
|
1758
|
+
getScheduler: () => null,
|
|
1759
|
+
getStatus: () => "running",
|
|
1760
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
1761
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
1762
|
+
getStoppedAt: () => null,
|
|
1763
|
+
getLastError: () => null,
|
|
1764
|
+
getCheckInterval: () => 1000,
|
|
1765
|
+
emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
|
|
1766
|
+
getEmitter: () => emitterWithTrigger,
|
|
1767
|
+
};
|
|
1768
|
+
const manager = new DiscordManager(mockContext);
|
|
1769
|
+
const mockConnector = new EventEmitter();
|
|
1770
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1771
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1772
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1773
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1774
|
+
status: "connected",
|
|
1775
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1776
|
+
disconnectedAt: null,
|
|
1777
|
+
reconnectAttempts: 0,
|
|
1778
|
+
lastError: null,
|
|
1779
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1780
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1781
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1782
|
+
});
|
|
1783
|
+
mockConnector.agentName = "test-agent";
|
|
1784
|
+
mockConnector.sessionManager = {
|
|
1785
|
+
getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
|
|
1786
|
+
touchSession: vi.fn().mockResolvedValue(undefined),
|
|
1787
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1788
|
+
};
|
|
1789
|
+
// @ts-expect-error - accessing private property for testing
|
|
1790
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1791
|
+
// @ts-expect-error - accessing private property for testing
|
|
1792
|
+
manager.initialized = true;
|
|
1793
|
+
await manager.start();
|
|
1794
|
+
// Create a mock message event
|
|
1795
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1796
|
+
const messageEvent = {
|
|
1797
|
+
agentName: "test-agent",
|
|
1798
|
+
prompt: "Hello bot!",
|
|
1799
|
+
context: {
|
|
1800
|
+
messages: [],
|
|
1801
|
+
wasMentioned: true,
|
|
1802
|
+
prompt: "Hello bot!",
|
|
1803
|
+
},
|
|
1804
|
+
metadata: {
|
|
1805
|
+
guildId: "guild1",
|
|
1806
|
+
channelId: "channel1",
|
|
1807
|
+
messageId: "msg1",
|
|
1808
|
+
userId: "user1",
|
|
1809
|
+
username: "TestUser",
|
|
1810
|
+
wasMentioned: true,
|
|
1811
|
+
mode: "mention",
|
|
1812
|
+
},
|
|
1813
|
+
reply: replyMock,
|
|
1814
|
+
startTyping: () => () => { },
|
|
1815
|
+
};
|
|
1816
|
+
// Emit the message event
|
|
1817
|
+
mockConnector.emit("message", messageEvent);
|
|
1818
|
+
// Wait for async processing
|
|
1819
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1820
|
+
// Should have emitted the handled event
|
|
1821
|
+
expect(emittedEvents.length).toBe(1);
|
|
1822
|
+
expect(emittedEvents[0].event).toBe("discord:message:handled");
|
|
1823
|
+
expect(emittedEvents[0].data).toMatchObject({
|
|
1824
|
+
agentName: "test-agent",
|
|
1825
|
+
channelId: "channel1",
|
|
1826
|
+
messageId: "msg1",
|
|
1827
|
+
jobId: "job-456",
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
it("emits discord:message:error event on message handling failure", async () => {
|
|
1831
|
+
const eventEmitter = new EventEmitter();
|
|
1832
|
+
const emittedEvents = [];
|
|
1833
|
+
// Track emitted events
|
|
1834
|
+
eventEmitter.on("discord:message:error", (data) => {
|
|
1835
|
+
emittedEvents.push({ event: "discord:message:error", data });
|
|
1836
|
+
});
|
|
1837
|
+
const triggerMock = vi.fn().mockRejectedValue(new Error("Execution failed"));
|
|
1838
|
+
const emitterWithTrigger = Object.assign(eventEmitter, {
|
|
1839
|
+
trigger: triggerMock,
|
|
1840
|
+
});
|
|
1841
|
+
const config = {
|
|
1842
|
+
fleet: { name: "test-fleet" },
|
|
1843
|
+
agents: [
|
|
1844
|
+
createDiscordAgent("test-agent", {
|
|
1845
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
1846
|
+
session_expiry_hours: 24,
|
|
1847
|
+
log_level: "standard",
|
|
1848
|
+
guilds: [],
|
|
1849
|
+
}),
|
|
1850
|
+
],
|
|
1851
|
+
configPath: "/test/herdctl.yaml",
|
|
1852
|
+
configDir: "/test",
|
|
1853
|
+
};
|
|
1854
|
+
const mockContext = {
|
|
1855
|
+
getConfig: () => config,
|
|
1856
|
+
getStateDir: () => "/tmp/test-state",
|
|
1857
|
+
getStateDirInfo: () => null,
|
|
1858
|
+
getLogger: () => mockLogger,
|
|
1859
|
+
getScheduler: () => null,
|
|
1860
|
+
getStatus: () => "running",
|
|
1861
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
1862
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
1863
|
+
getStoppedAt: () => null,
|
|
1864
|
+
getLastError: () => null,
|
|
1865
|
+
getCheckInterval: () => 1000,
|
|
1866
|
+
emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
|
|
1867
|
+
getEmitter: () => emitterWithTrigger,
|
|
1868
|
+
};
|
|
1869
|
+
const manager = new DiscordManager(mockContext);
|
|
1870
|
+
const mockConnector = new EventEmitter();
|
|
1871
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1872
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1873
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1874
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1875
|
+
status: "connected",
|
|
1876
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1877
|
+
disconnectedAt: null,
|
|
1878
|
+
reconnectAttempts: 0,
|
|
1879
|
+
lastError: null,
|
|
1880
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1881
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1882
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1883
|
+
});
|
|
1884
|
+
mockConnector.agentName = "test-agent";
|
|
1885
|
+
mockConnector.sessionManager = {
|
|
1886
|
+
getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
|
|
1887
|
+
touchSession: vi.fn().mockResolvedValue(undefined),
|
|
1888
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1889
|
+
};
|
|
1890
|
+
// @ts-expect-error - accessing private property for testing
|
|
1891
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1892
|
+
// @ts-expect-error - accessing private property for testing
|
|
1893
|
+
manager.initialized = true;
|
|
1894
|
+
await manager.start();
|
|
1895
|
+
// Create a mock message event
|
|
1896
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
1897
|
+
const messageEvent = {
|
|
1898
|
+
agentName: "test-agent",
|
|
1899
|
+
prompt: "Hello bot!",
|
|
1900
|
+
context: {
|
|
1901
|
+
messages: [],
|
|
1902
|
+
wasMentioned: true,
|
|
1903
|
+
prompt: "Hello bot!",
|
|
1904
|
+
},
|
|
1905
|
+
metadata: {
|
|
1906
|
+
guildId: "guild1",
|
|
1907
|
+
channelId: "channel1",
|
|
1908
|
+
messageId: "msg1",
|
|
1909
|
+
userId: "user1",
|
|
1910
|
+
username: "TestUser",
|
|
1911
|
+
wasMentioned: true,
|
|
1912
|
+
mode: "mention",
|
|
1913
|
+
},
|
|
1914
|
+
reply: replyMock,
|
|
1915
|
+
startTyping: () => () => { },
|
|
1916
|
+
};
|
|
1917
|
+
// Emit the message event
|
|
1918
|
+
mockConnector.emit("message", messageEvent);
|
|
1919
|
+
// Wait for async processing
|
|
1920
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1921
|
+
// Should have emitted the error event
|
|
1922
|
+
expect(emittedEvents.length).toBe(1);
|
|
1923
|
+
expect(emittedEvents[0].event).toBe("discord:message:error");
|
|
1924
|
+
expect(emittedEvents[0].data).toMatchObject({
|
|
1925
|
+
agentName: "test-agent",
|
|
1926
|
+
channelId: "channel1",
|
|
1927
|
+
messageId: "msg1",
|
|
1928
|
+
error: "Execution failed",
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1931
|
+
it("emits discord:error event on connector error", async () => {
|
|
1932
|
+
const eventEmitter = new EventEmitter();
|
|
1933
|
+
const emittedEvents = [];
|
|
1934
|
+
// Track emitted events
|
|
1935
|
+
eventEmitter.on("discord:error", (data) => {
|
|
1936
|
+
emittedEvents.push({ event: "discord:error", data });
|
|
1937
|
+
});
|
|
1938
|
+
const mockContext = {
|
|
1939
|
+
getConfig: () => null,
|
|
1940
|
+
getStateDir: () => "/tmp/test-state",
|
|
1941
|
+
getStateDirInfo: () => null,
|
|
1942
|
+
getLogger: () => mockLogger,
|
|
1943
|
+
getScheduler: () => null,
|
|
1944
|
+
getStatus: () => "running",
|
|
1945
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
1946
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
1947
|
+
getStoppedAt: () => null,
|
|
1948
|
+
getLastError: () => null,
|
|
1949
|
+
getCheckInterval: () => 1000,
|
|
1950
|
+
emit: (event, ...args) => eventEmitter.emit(event, ...args),
|
|
1951
|
+
getEmitter: () => eventEmitter,
|
|
1952
|
+
};
|
|
1953
|
+
const manager = new DiscordManager(mockContext);
|
|
1954
|
+
const mockConnector = new EventEmitter();
|
|
1955
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
1956
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
1957
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
1958
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
1959
|
+
status: "connected",
|
|
1960
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
1961
|
+
disconnectedAt: null,
|
|
1962
|
+
reconnectAttempts: 0,
|
|
1963
|
+
lastError: null,
|
|
1964
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
1965
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
1966
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
1967
|
+
});
|
|
1968
|
+
mockConnector.agentName = "test-agent";
|
|
1969
|
+
mockConnector.sessionManager = {
|
|
1970
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
1971
|
+
};
|
|
1972
|
+
// @ts-expect-error - accessing private property for testing
|
|
1973
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
1974
|
+
// @ts-expect-error - accessing private property for testing
|
|
1975
|
+
manager.initialized = true;
|
|
1976
|
+
await manager.start();
|
|
1977
|
+
// Emit error event from connector
|
|
1978
|
+
const errorEvent = {
|
|
1979
|
+
agentName: "test-agent",
|
|
1980
|
+
error: new Error("Connector error"),
|
|
1981
|
+
};
|
|
1982
|
+
mockConnector.emit("error", errorEvent);
|
|
1983
|
+
// Wait for async processing
|
|
1984
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1985
|
+
// Should have emitted the discord:error event
|
|
1986
|
+
expect(emittedEvents.length).toBe(1);
|
|
1987
|
+
expect(emittedEvents[0].event).toBe("discord:error");
|
|
1988
|
+
expect(emittedEvents[0].data).toMatchObject({
|
|
1989
|
+
agentName: "test-agent",
|
|
1990
|
+
error: "Connector error",
|
|
1991
|
+
});
|
|
1992
|
+
});
|
|
1993
|
+
it("handles reply failure when agent not found", async () => {
|
|
1994
|
+
const config = {
|
|
1995
|
+
fleet: { name: "test-fleet" },
|
|
1996
|
+
agents: [], // No agents!
|
|
1997
|
+
configPath: "/test/herdctl.yaml",
|
|
1998
|
+
configDir: "/test",
|
|
1999
|
+
};
|
|
2000
|
+
const mockContext = {
|
|
2001
|
+
getConfig: () => config,
|
|
2002
|
+
getStateDir: () => "/tmp/test-state",
|
|
2003
|
+
getStateDirInfo: () => null,
|
|
2004
|
+
getLogger: () => mockLogger,
|
|
2005
|
+
getScheduler: () => null,
|
|
2006
|
+
getStatus: () => "running",
|
|
2007
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
2008
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
2009
|
+
getStoppedAt: () => null,
|
|
2010
|
+
getLastError: () => null,
|
|
2011
|
+
getCheckInterval: () => 1000,
|
|
2012
|
+
emit: () => true,
|
|
2013
|
+
getEmitter: () => new EventEmitter(),
|
|
2014
|
+
};
|
|
2015
|
+
const manager = new DiscordManager(mockContext);
|
|
2016
|
+
const mockConnector = new EventEmitter();
|
|
2017
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
2018
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
2019
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
2020
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
2021
|
+
status: "connected",
|
|
2022
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
2023
|
+
disconnectedAt: null,
|
|
2024
|
+
reconnectAttempts: 0,
|
|
2025
|
+
lastError: null,
|
|
2026
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
2027
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
2028
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
2029
|
+
});
|
|
2030
|
+
mockConnector.agentName = "unknown-agent";
|
|
2031
|
+
mockConnector.sessionManager = {
|
|
2032
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
2033
|
+
};
|
|
2034
|
+
// @ts-expect-error - accessing private property for testing
|
|
2035
|
+
manager.connectors.set("unknown-agent", mockConnector);
|
|
2036
|
+
// @ts-expect-error - accessing private property for testing
|
|
2037
|
+
manager.initialized = true;
|
|
2038
|
+
await manager.start();
|
|
2039
|
+
// Reply that fails
|
|
2040
|
+
const replyMock = vi.fn().mockRejectedValue(new Error("Reply failed"));
|
|
2041
|
+
const messageEvent = {
|
|
2042
|
+
agentName: "unknown-agent",
|
|
2043
|
+
prompt: "Hello bot!",
|
|
2044
|
+
context: {
|
|
2045
|
+
messages: [],
|
|
2046
|
+
wasMentioned: true,
|
|
2047
|
+
prompt: "Hello bot!",
|
|
2048
|
+
},
|
|
2049
|
+
metadata: {
|
|
2050
|
+
guildId: "guild1",
|
|
2051
|
+
channelId: "channel1",
|
|
2052
|
+
messageId: "msg1",
|
|
2053
|
+
userId: "user1",
|
|
2054
|
+
username: "TestUser",
|
|
2055
|
+
wasMentioned: true,
|
|
2056
|
+
mode: "mention",
|
|
2057
|
+
},
|
|
2058
|
+
reply: replyMock,
|
|
2059
|
+
startTyping: () => () => { },
|
|
2060
|
+
};
|
|
2061
|
+
// Emit the message event
|
|
2062
|
+
mockConnector.emit("message", messageEvent);
|
|
2063
|
+
// Wait for async processing
|
|
2064
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2065
|
+
// Should have logged both the agent not found error and the reply failure
|
|
2066
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Agent 'unknown-agent' not found"));
|
|
2067
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
|
|
2068
|
+
});
|
|
2069
|
+
it("sends default response when job produces no output", async () => {
|
|
2070
|
+
const eventEmitter = new EventEmitter();
|
|
2071
|
+
// Trigger that returns but doesn't call onMessage
|
|
2072
|
+
const triggerMock = vi.fn().mockImplementation(async () => {
|
|
2073
|
+
return { jobId: "job-789" };
|
|
2074
|
+
});
|
|
2075
|
+
const emitterWithTrigger = Object.assign(eventEmitter, {
|
|
2076
|
+
trigger: triggerMock,
|
|
2077
|
+
});
|
|
2078
|
+
const config = {
|
|
2079
|
+
fleet: { name: "test-fleet" },
|
|
2080
|
+
agents: [
|
|
2081
|
+
createDiscordAgent("test-agent", {
|
|
2082
|
+
bot_token_env: "TEST_BOT_TOKEN",
|
|
2083
|
+
session_expiry_hours: 24,
|
|
2084
|
+
log_level: "standard",
|
|
2085
|
+
guilds: [],
|
|
2086
|
+
}),
|
|
2087
|
+
],
|
|
2088
|
+
configPath: "/test/herdctl.yaml",
|
|
2089
|
+
configDir: "/test",
|
|
2090
|
+
};
|
|
2091
|
+
const mockContext = {
|
|
2092
|
+
getConfig: () => config,
|
|
2093
|
+
getStateDir: () => "/tmp/test-state",
|
|
2094
|
+
getStateDirInfo: () => null,
|
|
2095
|
+
getLogger: () => mockLogger,
|
|
2096
|
+
getScheduler: () => null,
|
|
2097
|
+
getStatus: () => "running",
|
|
2098
|
+
getInitializedAt: () => "2024-01-01T00:00:00.000Z",
|
|
2099
|
+
getStartedAt: () => "2024-01-01T00:00:01.000Z",
|
|
2100
|
+
getStoppedAt: () => null,
|
|
2101
|
+
getLastError: () => null,
|
|
2102
|
+
getCheckInterval: () => 1000,
|
|
2103
|
+
emit: (event, ...args) => emitterWithTrigger.emit(event, ...args),
|
|
2104
|
+
getEmitter: () => emitterWithTrigger,
|
|
2105
|
+
};
|
|
2106
|
+
const manager = new DiscordManager(mockContext);
|
|
2107
|
+
const mockConnector = new EventEmitter();
|
|
2108
|
+
mockConnector.connect = vi.fn().mockResolvedValue(undefined);
|
|
2109
|
+
mockConnector.disconnect = vi.fn().mockResolvedValue(undefined);
|
|
2110
|
+
mockConnector.isConnected = vi.fn().mockReturnValue(true);
|
|
2111
|
+
mockConnector.getState = vi.fn().mockReturnValue({
|
|
2112
|
+
status: "connected",
|
|
2113
|
+
connectedAt: "2024-01-01T00:00:00.000Z",
|
|
2114
|
+
disconnectedAt: null,
|
|
2115
|
+
reconnectAttempts: 0,
|
|
2116
|
+
lastError: null,
|
|
2117
|
+
botUser: { id: "bot1", username: "TestBot", discriminator: "0000" },
|
|
2118
|
+
rateLimits: { totalCount: 0, lastRateLimitAt: null, isRateLimited: false, currentResetTime: 0 },
|
|
2119
|
+
messageStats: { received: 0, sent: 0, ignored: 0 },
|
|
2120
|
+
});
|
|
2121
|
+
mockConnector.agentName = "test-agent";
|
|
2122
|
+
mockConnector.sessionManager = {
|
|
2123
|
+
getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "s1", isNew: false }),
|
|
2124
|
+
touchSession: vi.fn().mockResolvedValue(undefined),
|
|
2125
|
+
getActiveSessionCount: vi.fn().mockResolvedValue(0),
|
|
2126
|
+
};
|
|
2127
|
+
// @ts-expect-error - accessing private property for testing
|
|
2128
|
+
manager.connectors.set("test-agent", mockConnector);
|
|
2129
|
+
// @ts-expect-error - accessing private property for testing
|
|
2130
|
+
manager.initialized = true;
|
|
2131
|
+
await manager.start();
|
|
2132
|
+
// Create a mock message event
|
|
2133
|
+
const replyMock = vi.fn().mockResolvedValue(undefined);
|
|
2134
|
+
const messageEvent = {
|
|
2135
|
+
agentName: "test-agent",
|
|
2136
|
+
prompt: "Hello bot!",
|
|
2137
|
+
context: {
|
|
2138
|
+
messages: [],
|
|
2139
|
+
wasMentioned: true,
|
|
2140
|
+
prompt: "Hello bot!",
|
|
2141
|
+
},
|
|
2142
|
+
metadata: {
|
|
2143
|
+
guildId: "guild1",
|
|
2144
|
+
channelId: "channel1",
|
|
2145
|
+
messageId: "msg1",
|
|
2146
|
+
userId: "user1",
|
|
2147
|
+
username: "TestUser",
|
|
2148
|
+
wasMentioned: true,
|
|
2149
|
+
mode: "mention",
|
|
2150
|
+
},
|
|
2151
|
+
reply: replyMock,
|
|
2152
|
+
startTyping: () => () => { },
|
|
2153
|
+
};
|
|
2154
|
+
// Emit the message event
|
|
2155
|
+
mockConnector.emit("message", messageEvent);
|
|
2156
|
+
// Wait for async processing
|
|
2157
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2158
|
+
// Should have sent the default "no output" message
|
|
2159
|
+
expect(replyMock).toHaveBeenCalledWith("I've completed the task, but I don't have a specific response to share.");
|
|
2160
|
+
});
|
|
2161
|
+
});
|
|
2162
|
+
//# sourceMappingURL=discord-manager.test.js.map
|