@herdctl/core 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/config/__tests__/schema.test.js +6 -6
  2. package/dist/config/__tests__/schema.test.js.map +1 -1
  3. package/dist/config/index.d.ts +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/index.js +3 -1
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/config/schema.d.ts +128 -3
  8. package/dist/config/schema.d.ts.map +1 -1
  9. package/dist/config/schema.js +8 -4
  10. package/dist/config/schema.js.map +1 -1
  11. package/dist/fleet-manager/chat-manager-interface.d.ts +116 -0
  12. package/dist/fleet-manager/chat-manager-interface.d.ts.map +1 -0
  13. package/dist/fleet-manager/chat-manager-interface.js +11 -0
  14. package/dist/fleet-manager/chat-manager-interface.js.map +1 -0
  15. package/dist/fleet-manager/context.d.ts +24 -5
  16. package/dist/fleet-manager/context.d.ts.map +1 -1
  17. package/dist/fleet-manager/event-types.d.ts +0 -92
  18. package/dist/fleet-manager/event-types.d.ts.map +1 -1
  19. package/dist/fleet-manager/fleet-manager.d.ts +18 -6
  20. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  21. package/dist/fleet-manager/fleet-manager.js +79 -20
  22. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  23. package/dist/fleet-manager/index.d.ts +2 -3
  24. package/dist/fleet-manager/index.d.ts.map +1 -1
  25. package/dist/fleet-manager/index.js +4 -1
  26. package/dist/fleet-manager/index.js.map +1 -1
  27. package/dist/fleet-manager/status-queries.d.ts +5 -6
  28. package/dist/fleet-manager/status-queries.d.ts.map +1 -1
  29. package/dist/fleet-manager/status-queries.js +52 -59
  30. package/dist/fleet-manager/status-queries.js.map +1 -1
  31. package/dist/fleet-manager/types.d.ts +11 -31
  32. package/dist/fleet-manager/types.d.ts.map +1 -1
  33. package/dist/runner/index.d.ts +1 -1
  34. package/dist/runner/index.d.ts.map +1 -1
  35. package/dist/runner/index.js.map +1 -1
  36. package/dist/scheduler/__tests__/scheduler.test.js +9 -7
  37. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  38. package/dist/scheduler/scheduler.js +5 -4
  39. package/dist/scheduler/scheduler.js.map +1 -1
  40. package/package.json +1 -1
  41. package/dist/fleet-manager/__tests__/discord-manager.test.d.ts +0 -8
  42. package/dist/fleet-manager/__tests__/discord-manager.test.d.ts.map +0 -1
  43. package/dist/fleet-manager/__tests__/discord-manager.test.js +0 -3508
  44. package/dist/fleet-manager/__tests__/discord-manager.test.js.map +0 -1
  45. package/dist/fleet-manager/__tests__/slack-manager.test.d.ts +0 -11
  46. package/dist/fleet-manager/__tests__/slack-manager.test.d.ts.map +0 -1
  47. package/dist/fleet-manager/__tests__/slack-manager.test.js +0 -983
  48. package/dist/fleet-manager/__tests__/slack-manager.test.js.map +0 -1
  49. package/dist/fleet-manager/discord-manager.d.ts +0 -353
  50. package/dist/fleet-manager/discord-manager.d.ts.map +0 -1
  51. package/dist/fleet-manager/discord-manager.js +0 -1087
  52. package/dist/fleet-manager/discord-manager.js.map +0 -1
  53. package/dist/fleet-manager/slack-manager.d.ts +0 -201
  54. package/dist/fleet-manager/slack-manager.d.ts.map +0 -1
  55. package/dist/fleet-manager/slack-manager.js +0 -576
  56. package/dist/fleet-manager/slack-manager.js.map +0 -1
@@ -1,983 +0,0 @@
1
- /**
2
- * Tests for SlackManager
3
- *
4
- * Tests the SlackManager class which manages Slack connectors for agents
5
- * with chat.slack configured (one connector per agent).
6
- *
7
- * Since @herdctl/slack is not a dependency of @herdctl/core, we mock the
8
- * dynamic import to test the full initialization and lifecycle paths.
9
- */
10
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
- import { EventEmitter } from "node:events";
12
- // ---------------------------------------------------------------------------
13
- // Mock Factories
14
- // ---------------------------------------------------------------------------
15
- const mockLogger = {
16
- debug: vi.fn(),
17
- info: vi.fn(),
18
- warn: vi.fn(),
19
- error: vi.fn(),
20
- };
21
- function createMockEmitter() {
22
- const emitter = new EventEmitter();
23
- vi.spyOn(emitter, "emit");
24
- return emitter;
25
- }
26
- function createMockContext(config = null, emitter = createMockEmitter()) {
27
- return {
28
- getConfig: () => config,
29
- getStateDir: () => "/tmp/test-state",
30
- getStateDirInfo: () => null,
31
- getLogger: () => mockLogger,
32
- getScheduler: () => null,
33
- getStatus: () => "initialized",
34
- getInitializedAt: () => null,
35
- getStartedAt: () => null,
36
- getStoppedAt: () => null,
37
- getLastError: () => null,
38
- getCheckInterval: () => 1000,
39
- emit: (event, ...args) => emitter.emit(event, ...args),
40
- getEmitter: () => emitter,
41
- };
42
- }
43
- function createSlackAgent(name, slackConfig) {
44
- return {
45
- name,
46
- model: "sonnet",
47
- runtime: "sdk",
48
- schedules: {},
49
- chat: { slack: slackConfig },
50
- configPath: "/test/herdctl.yaml",
51
- };
52
- }
53
- function createNonSlackAgent(name) {
54
- return {
55
- name,
56
- model: "sonnet",
57
- schedules: {},
58
- configPath: "/test/herdctl.yaml",
59
- };
60
- }
61
- const defaultSlackConfig = {
62
- bot_token_env: "SLACK_BOT_TOKEN",
63
- app_token_env: "SLACK_APP_TOKEN",
64
- session_expiry_hours: 24,
65
- log_level: "standard",
66
- channels: [{ id: "C0123456789", mode: "mention", context_messages: 10 }],
67
- };
68
- function createConfigWithAgents(...agents) {
69
- return {
70
- fleet: { name: "test-fleet" },
71
- agents,
72
- configPath: "/test/herdctl.yaml",
73
- configDir: "/test",
74
- };
75
- }
76
- // ---------------------------------------------------------------------------
77
- // Mock SlackConnector and SessionManager
78
- // ---------------------------------------------------------------------------
79
- function createMockConnector(agentName, sessionManager) {
80
- const connector = new EventEmitter();
81
- connector.agentName = agentName;
82
- connector.sessionManager = sessionManager;
83
- connector.connect = vi.fn().mockResolvedValue(undefined);
84
- connector.disconnect = vi.fn().mockResolvedValue(undefined);
85
- connector.isConnected = vi.fn().mockReturnValue(false);
86
- connector.getState = vi.fn().mockReturnValue({
87
- status: "disconnected",
88
- connectedAt: null,
89
- disconnectedAt: null,
90
- reconnectAttempts: 0,
91
- lastError: null,
92
- botUser: null,
93
- messageStats: { received: 0, sent: 0, ignored: 0 },
94
- });
95
- connector.uploadFile = vi.fn().mockResolvedValue({ fileId: "file-123" });
96
- return connector;
97
- }
98
- function createMockSessionManager(agentName) {
99
- return {
100
- agentName,
101
- getOrCreateSession: vi.fn().mockResolvedValue({ sessionId: "session-1", isNew: true }),
102
- getSession: vi.fn().mockResolvedValue(null),
103
- setSession: vi.fn().mockResolvedValue(undefined),
104
- touchSession: vi.fn().mockResolvedValue(undefined),
105
- clearSession: vi.fn().mockResolvedValue(true),
106
- cleanupExpiredSessions: vi.fn().mockResolvedValue(0),
107
- getActiveSessionCount: vi.fn().mockResolvedValue(0),
108
- };
109
- }
110
- // ---------------------------------------------------------------------------
111
- // Tests – No Mock (no mocked SlackConnector; tests basic init paths)
112
- // ---------------------------------------------------------------------------
113
- describe("SlackManager (no @herdctl/slack)", () => {
114
- beforeEach(() => {
115
- vi.clearAllMocks();
116
- vi.resetModules();
117
- // Mock @herdctl/slack to simulate it not being installed
118
- vi.doMock("@herdctl/slack", () => {
119
- throw new Error("Cannot find package '@herdctl/slack'");
120
- });
121
- });
122
- afterEach(() => {
123
- vi.restoreAllMocks();
124
- });
125
- // We import fresh each time to avoid stale module state
126
- async function getSlackManager() {
127
- const mod = await import("../slack-manager.js");
128
- return mod.SlackManager;
129
- }
130
- describe("constructor", () => {
131
- it("creates instance with context", async () => {
132
- const SlackManager = await getSlackManager();
133
- const ctx = createMockContext();
134
- const manager = new SlackManager(ctx);
135
- expect(manager).toBeDefined();
136
- });
137
- });
138
- describe("initialize", () => {
139
- it("skips initialization when no config is available", async () => {
140
- const SlackManager = await getSlackManager();
141
- const ctx = createMockContext(null);
142
- const manager = new SlackManager(ctx);
143
- await manager.initialize();
144
- expect(mockLogger.debug).toHaveBeenCalledWith("No config available, skipping Slack initialization");
145
- });
146
- it("skips when @herdctl/slack is not installed (no slack agents)", async () => {
147
- const SlackManager = await getSlackManager();
148
- const config = createConfigWithAgents(createNonSlackAgent("agent1"), createNonSlackAgent("agent2"));
149
- const ctx = createMockContext(config);
150
- const manager = new SlackManager(ctx);
151
- await manager.initialize();
152
- expect(mockLogger.debug).toHaveBeenCalledWith("@herdctl/slack not installed, skipping Slack connectors");
153
- });
154
- it("skips when @herdctl/slack is not installed (with slack agents)", async () => {
155
- const SlackManager = await getSlackManager();
156
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
157
- const ctx = createMockContext(config);
158
- const manager = new SlackManager(ctx);
159
- await manager.initialize();
160
- expect(mockLogger.debug).toHaveBeenCalledWith("@herdctl/slack not installed, skipping Slack connectors");
161
- });
162
- it("allows retry when no config (initialized not set)", async () => {
163
- const SlackManager = await getSlackManager();
164
- const ctx = createMockContext(null);
165
- const manager = new SlackManager(ctx);
166
- await manager.initialize();
167
- await manager.initialize();
168
- const calls = mockLogger.debug.mock.calls.filter((c) => c[0] === "No config available, skipping Slack initialization");
169
- expect(calls.length).toBe(2);
170
- });
171
- });
172
- describe("start", () => {
173
- it("does nothing when no connector exists", async () => {
174
- const SlackManager = await getSlackManager();
175
- const ctx = createMockContext(null);
176
- const manager = new SlackManager(ctx);
177
- await manager.initialize();
178
- await manager.start();
179
- expect(mockLogger.debug).toHaveBeenCalledWith("No Slack connectors to start");
180
- });
181
- });
182
- describe("stop", () => {
183
- it("does nothing when no connector exists", async () => {
184
- const SlackManager = await getSlackManager();
185
- const ctx = createMockContext(null);
186
- const manager = new SlackManager(ctx);
187
- await manager.initialize();
188
- await manager.stop();
189
- expect(mockLogger.debug).toHaveBeenCalledWith("No Slack connectors to stop");
190
- });
191
- });
192
- describe("hasAgent", () => {
193
- it("returns false when not initialized", async () => {
194
- const SlackManager = await getSlackManager();
195
- const ctx = createMockContext(null);
196
- const manager = new SlackManager(ctx);
197
- expect(manager.hasAgent("test-agent")).toBe(false);
198
- });
199
- });
200
- describe("getState", () => {
201
- it("returns null when no connector for agent", async () => {
202
- const SlackManager = await getSlackManager();
203
- const ctx = createMockContext(null);
204
- const manager = new SlackManager(ctx);
205
- expect(manager.getState("test-agent")).toBeNull();
206
- });
207
- });
208
- describe("getConnectedCount", () => {
209
- it("returns 0 when no connectors", async () => {
210
- const SlackManager = await getSlackManager();
211
- const ctx = createMockContext(null);
212
- const manager = new SlackManager(ctx);
213
- expect(manager.getConnectedCount()).toBe(0);
214
- });
215
- });
216
- describe("getConnector", () => {
217
- it("returns undefined when no connector for agent", async () => {
218
- const SlackManager = await getSlackManager();
219
- const ctx = createMockContext(null);
220
- const manager = new SlackManager(ctx);
221
- expect(manager.getConnector("test-agent")).toBeUndefined();
222
- });
223
- });
224
- describe("getConnectorNames", () => {
225
- it("returns empty array when not initialized", async () => {
226
- const SlackManager = await getSlackManager();
227
- const ctx = createMockContext(null);
228
- const manager = new SlackManager(ctx);
229
- expect(manager.getConnectorNames()).toEqual([]);
230
- });
231
- });
232
- describe("splitResponse", () => {
233
- it("returns single chunk for short text", async () => {
234
- const SlackManager = await getSlackManager();
235
- const ctx = createMockContext(null);
236
- const manager = new SlackManager(ctx);
237
- const result = manager.splitResponse("Hello, world!");
238
- expect(result).toEqual(["Hello, world!"]);
239
- });
240
- it("splits long text at natural breaks", async () => {
241
- const SlackManager = await getSlackManager();
242
- const ctx = createMockContext(null);
243
- const manager = new SlackManager(ctx);
244
- // Build a text larger than 4000 chars
245
- const line = "This is a test line that is moderately long. ";
246
- const longText = line.repeat(100); // ~4500 chars
247
- const chunks = manager.splitResponse(longText);
248
- expect(chunks.length).toBeGreaterThan(1);
249
- for (const chunk of chunks) {
250
- expect(chunk.length).toBeLessThanOrEqual(4000);
251
- }
252
- // All content preserved
253
- expect(chunks.join("")).toBe(longText);
254
- });
255
- it("splits at double newlines when available", async () => {
256
- const SlackManager = await getSlackManager();
257
- const ctx = createMockContext(null);
258
- const manager = new SlackManager(ctx);
259
- const part1 = "A".repeat(3800);
260
- const part2 = "B".repeat(200);
261
- const longText = part1 + "\n\n" + part2;
262
- const chunks = manager.splitResponse(longText);
263
- expect(chunks.length).toBe(2);
264
- expect(chunks[0]).toBe(part1 + "\n\n");
265
- });
266
- it("splits at single newline when no double newline", async () => {
267
- const SlackManager = await getSlackManager();
268
- const ctx = createMockContext(null);
269
- const manager = new SlackManager(ctx);
270
- const part1 = "A".repeat(3900);
271
- const part2 = "B".repeat(200);
272
- const longText = part1 + "\n" + part2;
273
- const chunks = manager.splitResponse(longText);
274
- expect(chunks.length).toBe(2);
275
- expect(chunks[0]).toBe(part1 + "\n");
276
- });
277
- it("splits at space when no newline", async () => {
278
- const SlackManager = await getSlackManager();
279
- const ctx = createMockContext(null);
280
- const manager = new SlackManager(ctx);
281
- const part1 = "A".repeat(3950);
282
- const part2 = "B".repeat(200);
283
- const longText = part1 + " " + part2;
284
- const chunks = manager.splitResponse(longText);
285
- expect(chunks.length).toBe(2);
286
- expect(chunks[0]).toBe(part1 + " ");
287
- });
288
- });
289
- describe("formatErrorMessage", () => {
290
- it("formats an error with !reset suggestion", async () => {
291
- const SlackManager = await getSlackManager();
292
- const ctx = createMockContext(null);
293
- const manager = new SlackManager(ctx);
294
- const result = manager.formatErrorMessage(new Error("Something broke"));
295
- expect(result).toContain("Something broke");
296
- expect(result).toContain("!reset");
297
- });
298
- });
299
- });
300
- // ---------------------------------------------------------------------------
301
- // Tests – With Mocked @herdctl/slack (full initialization paths)
302
- // ---------------------------------------------------------------------------
303
- describe("SlackManager (mocked @herdctl/slack)", () => {
304
- let mockConnectors;
305
- let mockSessionManagers;
306
- let MockSlackConnector;
307
- let MockSessionManager;
308
- let originalEnv;
309
- beforeEach(() => {
310
- vi.clearAllMocks();
311
- vi.resetModules();
312
- originalEnv = { ...process.env };
313
- // Set required env vars
314
- process.env.SLACK_BOT_TOKEN = "xoxb-test-bot-token";
315
- process.env.SLACK_APP_TOKEN = "xapp-test-app-token";
316
- // Create mock implementations - per-agent connectors
317
- mockConnectors = new Map();
318
- mockSessionManagers = new Map();
319
- // Must use function expressions (not arrows) so they work with `new`
320
- MockSessionManager = vi.fn().mockImplementation(function (opts) {
321
- const sessionMgr = createMockSessionManager(opts.agentName);
322
- mockSessionManagers.set(opts.agentName, sessionMgr);
323
- return sessionMgr;
324
- });
325
- MockSlackConnector = vi.fn().mockImplementation(function (opts) {
326
- const connector = createMockConnector(opts.agentName, opts.sessionManager);
327
- mockConnectors.set(opts.agentName, connector);
328
- return connector;
329
- });
330
- });
331
- afterEach(() => {
332
- vi.restoreAllMocks();
333
- process.env = originalEnv;
334
- });
335
- async function getSlackManagerWithMock() {
336
- // Mock the dynamic import. vi.resetModules() in beforeEach ensures
337
- // the import cache is cleared so our mock takes effect.
338
- vi.doMock("@herdctl/slack", () => ({
339
- SlackConnector: MockSlackConnector,
340
- SessionManager: MockSessionManager,
341
- }));
342
- // Force fresh import of the slack-manager module
343
- const mod = await import("../slack-manager.js");
344
- return mod.SlackManager;
345
- }
346
- describe("initialize", () => {
347
- it("creates connector when slack agents exist and tokens are set", async () => {
348
- const SlackManager = await getSlackManagerWithMock();
349
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
350
- const ctx = createMockContext(config);
351
- const manager = new SlackManager(ctx);
352
- await manager.initialize();
353
- expect(MockSessionManager).toHaveBeenCalledWith(expect.objectContaining({
354
- agentName: "agent1",
355
- stateDir: "/tmp/test-state",
356
- sessionExpiryHours: 24,
357
- }));
358
- expect(MockSlackConnector).toHaveBeenCalledWith(expect.objectContaining({
359
- agentName: "agent1",
360
- botToken: "xoxb-test-bot-token",
361
- appToken: "xapp-test-app-token",
362
- channels: [{ id: "C0123456789", mode: "mention" }],
363
- }));
364
- expect(manager.hasAgent("agent1")).toBe(true);
365
- expect(manager.getConnector("agent1")).toBe(mockConnectors.get("agent1"));
366
- });
367
- it("creates separate connectors for multiple agents", async () => {
368
- const SlackManager = await getSlackManagerWithMock();
369
- const config = createConfigWithAgents(createSlackAgent("agent1", {
370
- ...defaultSlackConfig,
371
- channels: [{ id: "C001", mode: "mention", context_messages: 10 }, { id: "C002", mode: "mention", context_messages: 10 }],
372
- }), createSlackAgent("agent2", {
373
- ...defaultSlackConfig,
374
- channels: [{ id: "C003", mode: "mention", context_messages: 10 }],
375
- }));
376
- const ctx = createMockContext(config);
377
- const manager = new SlackManager(ctx);
378
- await manager.initialize();
379
- expect(manager.getConnectorNames()).toEqual(["agent1", "agent2"]);
380
- expect(MockSlackConnector).toHaveBeenCalledTimes(2);
381
- expect(manager.getConnector("agent1")).toBeDefined();
382
- expect(manager.getConnector("agent2")).toBeDefined();
383
- });
384
- it("skips when no agents have Slack configured", async () => {
385
- const SlackManager = await getSlackManagerWithMock();
386
- const config = createConfigWithAgents(createNonSlackAgent("agent1"));
387
- const ctx = createMockContext(config);
388
- const manager = new SlackManager(ctx);
389
- await manager.initialize();
390
- expect(mockLogger.debug).toHaveBeenCalledWith("No agents with Slack configured");
391
- expect(MockSlackConnector).not.toHaveBeenCalled();
392
- });
393
- it("warns and skips agent when bot token env var is missing", async () => {
394
- delete process.env.SLACK_BOT_TOKEN;
395
- const SlackManager = await getSlackManagerWithMock();
396
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
397
- const ctx = createMockContext(config);
398
- const manager = new SlackManager(ctx);
399
- await manager.initialize();
400
- expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Slack bot token not found"));
401
- expect(MockSlackConnector).not.toHaveBeenCalled();
402
- });
403
- it("warns and skips agent when app token env var is missing", async () => {
404
- delete process.env.SLACK_APP_TOKEN;
405
- const SlackManager = await getSlackManagerWithMock();
406
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
407
- const ctx = createMockContext(config);
408
- const manager = new SlackManager(ctx);
409
- await manager.initialize();
410
- expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Slack app token not found"));
411
- expect(MockSlackConnector).not.toHaveBeenCalled();
412
- });
413
- it("is idempotent after successful initialization", async () => {
414
- const SlackManager = await getSlackManagerWithMock();
415
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
416
- const ctx = createMockContext(config);
417
- const manager = new SlackManager(ctx);
418
- await manager.initialize();
419
- await manager.initialize();
420
- // Constructor only called once
421
- expect(MockSlackConnector).toHaveBeenCalledTimes(1);
422
- });
423
- it("is idempotent after no-agents path", async () => {
424
- const SlackManager = await getSlackManagerWithMock();
425
- const config = createConfigWithAgents(createNonSlackAgent("agent1"));
426
- const ctx = createMockContext(config);
427
- const manager = new SlackManager(ctx);
428
- await manager.initialize();
429
- await manager.initialize();
430
- const calls = mockLogger.debug.mock.calls.filter((c) => c[0] === "No agents with Slack configured");
431
- expect(calls.length).toBe(1);
432
- });
433
- it("logs info about successful initialization", async () => {
434
- const SlackManager = await getSlackManagerWithMock();
435
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
436
- const ctx = createMockContext(config);
437
- const manager = new SlackManager(ctx);
438
- await manager.initialize();
439
- expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Slack manager initialized with 1 connector(s)"));
440
- });
441
- it("handles connector creation failure for one agent", async () => {
442
- MockSlackConnector.mockImplementationOnce(() => {
443
- throw new Error("Failed to create Bolt app");
444
- });
445
- const SlackManager = await getSlackManagerWithMock();
446
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
447
- const ctx = createMockContext(config);
448
- const manager = new SlackManager(ctx);
449
- await manager.initialize();
450
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to create Slack connector for agent 'agent1'"));
451
- expect(manager.getConnector("agent1")).toBeUndefined();
452
- });
453
- it("creates connectors for multiple agents", async () => {
454
- const SlackManager = await getSlackManagerWithMock();
455
- const config = createConfigWithAgents(createSlackAgent("agent1", {
456
- ...defaultSlackConfig,
457
- channels: [{ id: "C001", mode: "mention", context_messages: 10 }],
458
- }), createSlackAgent("agent2", {
459
- ...defaultSlackConfig,
460
- channels: [{ id: "C002", mode: "mention", context_messages: 10 }],
461
- }), createNonSlackAgent("agent3"));
462
- const ctx = createMockContext(config);
463
- const manager = new SlackManager(ctx);
464
- await manager.initialize();
465
- expect(MockSessionManager).toHaveBeenCalledTimes(2);
466
- expect(MockSlackConnector).toHaveBeenCalledTimes(2);
467
- expect(manager.hasAgent("agent1")).toBe(true);
468
- expect(manager.hasAgent("agent2")).toBe(true);
469
- expect(manager.hasAgent("agent3")).toBe(false);
470
- });
471
- });
472
- describe("start", () => {
473
- it("connects the connector and subscribes to events", async () => {
474
- const SlackManager = await getSlackManagerWithMock();
475
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
476
- const ctx = createMockContext(config);
477
- const manager = new SlackManager(ctx);
478
- await manager.initialize();
479
- await manager.start();
480
- const connector = mockConnectors.get("agent1");
481
- expect(connector?.connect).toHaveBeenCalledTimes(1);
482
- expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining("Slack connectors started"));
483
- });
484
- it("handles connection failure for one agent", async () => {
485
- const SlackManager = await getSlackManagerWithMock();
486
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
487
- const ctx = createMockContext(config);
488
- const manager = new SlackManager(ctx);
489
- await manager.initialize();
490
- // Make the connector fail to connect
491
- const connector = mockConnectors.get("agent1");
492
- connector?.connect.mockRejectedValue(new Error("Connection refused"));
493
- await manager.start();
494
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to connect Slack for agent 'agent1'"));
495
- });
496
- it("logs debug message when no connectors to start", async () => {
497
- const SlackManager = await getSlackManagerWithMock();
498
- const ctx = createMockContext(null);
499
- const manager = new SlackManager(ctx);
500
- await manager.start();
501
- expect(mockLogger.debug).toHaveBeenCalledWith("No Slack connectors to start");
502
- });
503
- });
504
- describe("stop", () => {
505
- it("disconnects all connectors", async () => {
506
- const SlackManager = await getSlackManagerWithMock();
507
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
508
- const ctx = createMockContext(config);
509
- const manager = new SlackManager(ctx);
510
- await manager.initialize();
511
- await manager.stop();
512
- const connector = mockConnectors.get("agent1");
513
- expect(connector?.disconnect).toHaveBeenCalledTimes(1);
514
- expect(mockLogger.debug).toHaveBeenCalledWith("All Slack connectors stopped");
515
- });
516
- it("handles disconnect failure for one agent", async () => {
517
- const SlackManager = await getSlackManagerWithMock();
518
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
519
- const ctx = createMockContext(config);
520
- const manager = new SlackManager(ctx);
521
- await manager.initialize();
522
- const connector = mockConnectors.get("agent1");
523
- connector?.disconnect.mockRejectedValue(new Error("Disconnect timeout"));
524
- await manager.stop();
525
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Error disconnecting Slack for agent 'agent1'"));
526
- });
527
- it("logs active session counts before stopping", async () => {
528
- const SlackManager = await getSlackManagerWithMock();
529
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
530
- const ctx = createMockContext(config);
531
- const manager = new SlackManager(ctx);
532
- await manager.initialize();
533
- // Update the session manager to return 3 active sessions
534
- const connector = mockConnectors.get("agent1");
535
- connector?.sessionManager.getActiveSessionCount.mockResolvedValue(3);
536
- await manager.stop();
537
- expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Preserving 3 active Slack session(s)"));
538
- });
539
- it("handles session count query failure gracefully", async () => {
540
- const SlackManager = await getSlackManagerWithMock();
541
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
542
- const ctx = createMockContext(config);
543
- const manager = new SlackManager(ctx);
544
- await manager.initialize();
545
- // Make the session manager fail
546
- const connector = mockConnectors.get("agent1");
547
- connector?.sessionManager.getActiveSessionCount.mockRejectedValue(new Error("File read error"));
548
- await manager.stop();
549
- expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to get Slack session count"));
550
- });
551
- });
552
- describe("getConnectedCount", () => {
553
- it("returns count of connected connectors", async () => {
554
- const SlackManager = await getSlackManagerWithMock();
555
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
556
- const ctx = createMockContext(config);
557
- const manager = new SlackManager(ctx);
558
- await manager.initialize();
559
- const connector = mockConnectors.get("agent1");
560
- connector?.isConnected.mockReturnValue(true);
561
- expect(manager.getConnectedCount()).toBe(1);
562
- });
563
- });
564
- describe("getState", () => {
565
- it("delegates to connector.getState() for specific agent", async () => {
566
- const state = {
567
- status: "connected",
568
- connectedAt: "2026-01-01T00:00:00Z",
569
- disconnectedAt: null,
570
- reconnectAttempts: 0,
571
- lastError: null,
572
- botUser: { id: "U123", username: "testbot" },
573
- messageStats: { received: 5, sent: 3, ignored: 1 },
574
- };
575
- const SlackManager = await getSlackManagerWithMock();
576
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
577
- const ctx = createMockContext(config);
578
- const manager = new SlackManager(ctx);
579
- await manager.initialize();
580
- const connector = mockConnectors.get("agent1");
581
- connector?.getState.mockReturnValue(state);
582
- expect(manager.getState("agent1")).toBe(state);
583
- });
584
- });
585
- describe("message handling (via connector events)", () => {
586
- it("emits slack:error event when connector error fires", async () => {
587
- const SlackManager = await getSlackManagerWithMock();
588
- const emitter = createMockEmitter();
589
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
590
- const ctx = createMockContext(config, emitter);
591
- const manager = new SlackManager(ctx);
592
- await manager.initialize();
593
- await manager.start();
594
- // Simulate error from connector (with agentName)
595
- const connector = mockConnectors.get("agent1");
596
- connector?.emit("error", {
597
- agentName: "agent1",
598
- error: new Error("Socket closed"),
599
- });
600
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Slack connector error for agent 'agent1'"));
601
- expect(emitter.emit).toHaveBeenCalledWith("slack:error", expect.objectContaining({
602
- agentName: "agent1",
603
- error: "Socket closed",
604
- }));
605
- });
606
- it("handles message with successful trigger", async () => {
607
- const SlackManager = await getSlackManagerWithMock();
608
- const emitter = createMockEmitter();
609
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
610
- // Add a trigger method to the emitter (FleetManager exposes this)
611
- const triggerMock = vi.fn().mockResolvedValue({
612
- jobId: "job-123",
613
- success: true,
614
- sessionId: "session-abc",
615
- });
616
- emitter.trigger = triggerMock;
617
- const ctx = createMockContext(config, emitter);
618
- const manager = new SlackManager(ctx);
619
- await manager.initialize();
620
- await manager.start();
621
- const replyFn = vi.fn().mockResolvedValue(undefined);
622
- const stopIndicator = vi.fn();
623
- const connector = mockConnectors.get("agent1");
624
- connector?.emit("message", {
625
- agentName: "agent1",
626
- prompt: "Help me with coding",
627
- metadata: {
628
- channelId: "C0123456789",
629
- messageTs: "1707930001.000000",
630
- userId: "U0123456789",
631
- wasMentioned: true,
632
- },
633
- reply: replyFn,
634
- startProcessingIndicator: () => stopIndicator,
635
- });
636
- await new Promise((r) => setTimeout(r, 50));
637
- expect(triggerMock).toHaveBeenCalledWith("agent1", undefined, expect.objectContaining({
638
- prompt: "Help me with coding",
639
- }));
640
- expect(stopIndicator).toHaveBeenCalled();
641
- expect(emitter.emit).toHaveBeenCalledWith("slack:message:handled", expect.objectContaining({
642
- agentName: "agent1",
643
- jobId: "job-123",
644
- }));
645
- });
646
- it("sends fallback when no messages streamed and job succeeds", async () => {
647
- const SlackManager = await getSlackManagerWithMock();
648
- const emitter = createMockEmitter();
649
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
650
- const triggerMock = vi.fn().mockResolvedValue({
651
- jobId: "job-123",
652
- success: true,
653
- sessionId: null,
654
- });
655
- emitter.trigger = triggerMock;
656
- const ctx = createMockContext(config, emitter);
657
- const manager = new SlackManager(ctx);
658
- await manager.initialize();
659
- await manager.start();
660
- const replyFn = vi.fn().mockResolvedValue(undefined);
661
- const connector = mockConnectors.get("agent1");
662
- connector?.emit("message", {
663
- agentName: "agent1",
664
- prompt: "Do something",
665
- metadata: {
666
- channelId: "C0123456789",
667
- messageTs: "1707930001.000000",
668
- userId: "U0123456789",
669
- wasMentioned: true,
670
- },
671
- reply: replyFn,
672
- startProcessingIndicator: () => () => { },
673
- });
674
- await new Promise((r) => setTimeout(r, 50));
675
- // Should send fallback message
676
- expect(replyFn).toHaveBeenCalledWith(expect.stringContaining("completed the task"));
677
- });
678
- it("sends error fallback when job fails and no messages streamed", async () => {
679
- const SlackManager = await getSlackManagerWithMock();
680
- const emitter = createMockEmitter();
681
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
682
- const triggerMock = vi.fn().mockResolvedValue({
683
- jobId: "job-123",
684
- success: false,
685
- error: new Error("API rate limit"),
686
- errorDetails: { message: "API rate limit exceeded" },
687
- });
688
- emitter.trigger = triggerMock;
689
- const ctx = createMockContext(config, emitter);
690
- const manager = new SlackManager(ctx);
691
- await manager.initialize();
692
- await manager.start();
693
- const replyFn = vi.fn().mockResolvedValue(undefined);
694
- const connector = mockConnectors.get("agent1");
695
- connector?.emit("message", {
696
- agentName: "agent1",
697
- prompt: "Do something",
698
- metadata: {
699
- channelId: "C0123456789",
700
- messageTs: "1707930001.000000",
701
- userId: "U0123456789",
702
- wasMentioned: true,
703
- },
704
- reply: replyFn,
705
- startProcessingIndicator: () => () => { },
706
- });
707
- await new Promise((r) => setTimeout(r, 50));
708
- expect(replyFn).toHaveBeenCalledWith(expect.stringContaining("API rate limit exceeded"));
709
- });
710
- it("handles trigger throw and sends error message", async () => {
711
- const SlackManager = await getSlackManagerWithMock();
712
- const emitter = createMockEmitter();
713
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
714
- const triggerMock = vi.fn().mockRejectedValue(new Error("Trigger failed"));
715
- emitter.trigger = triggerMock;
716
- const ctx = createMockContext(config, emitter);
717
- const manager = new SlackManager(ctx);
718
- await manager.initialize();
719
- await manager.start();
720
- const replyFn = vi.fn().mockResolvedValue(undefined);
721
- const connector = mockConnectors.get("agent1");
722
- connector?.emit("message", {
723
- agentName: "agent1",
724
- prompt: "Do something",
725
- metadata: {
726
- channelId: "C0123456789",
727
- messageTs: "1707930001.000000",
728
- userId: "U0123456789",
729
- wasMentioned: true,
730
- },
731
- reply: replyFn,
732
- startProcessingIndicator: () => () => { },
733
- });
734
- await new Promise((r) => setTimeout(r, 50));
735
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Slack message handling failed"));
736
- expect(replyFn).toHaveBeenCalledWith(expect.stringContaining("Trigger failed"));
737
- expect(emitter.emit).toHaveBeenCalledWith("slack:message:error", expect.objectContaining({
738
- agentName: "agent1",
739
- error: "Trigger failed",
740
- }));
741
- });
742
- it("resumes existing session when one exists", async () => {
743
- const SlackManager = await getSlackManagerWithMock();
744
- const emitter = createMockEmitter();
745
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
746
- const triggerMock = vi.fn().mockResolvedValue({
747
- jobId: "job-123",
748
- success: true,
749
- sessionId: "new-session-789",
750
- });
751
- emitter.trigger = triggerMock;
752
- const ctx = createMockContext(config, emitter);
753
- const manager = new SlackManager(ctx);
754
- await manager.initialize();
755
- // Set up the session manager to return an existing session
756
- const connector = mockConnectors.get("agent1");
757
- connector?.sessionManager.getSession.mockResolvedValue({
758
- sessionId: "existing-session-456",
759
- lastMessageAt: "2026-02-15T10:00:00Z",
760
- });
761
- await manager.start();
762
- const replyFn = vi.fn().mockResolvedValue(undefined);
763
- connector?.emit("message", {
764
- agentName: "agent1",
765
- prompt: "Continue our conversation",
766
- metadata: {
767
- channelId: "C0123456789",
768
- messageTs: "1707930001.000000",
769
- userId: "U0123456789",
770
- wasMentioned: false,
771
- },
772
- reply: replyFn,
773
- startProcessingIndicator: () => () => { },
774
- });
775
- await new Promise((r) => setTimeout(r, 50));
776
- // Should pass the existing session for resume
777
- expect(triggerMock).toHaveBeenCalledWith("agent1", undefined, expect.objectContaining({
778
- resume: "existing-session-456",
779
- }));
780
- // Should store the new session
781
- expect(connector?.sessionManager.setSession).toHaveBeenCalledWith("C0123456789", "new-session-789");
782
- });
783
- it("streams assistant messages via onMessage callback", async () => {
784
- const SlackManager = await getSlackManagerWithMock();
785
- const emitter = createMockEmitter();
786
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
787
- let capturedOnMessage = null;
788
- const triggerMock = vi.fn().mockImplementation(async (_name, _schedule, opts) => {
789
- capturedOnMessage = opts?.onMessage ?? null;
790
- // Simulate streaming messages
791
- if (capturedOnMessage) {
792
- await capturedOnMessage({ type: "assistant", content: "Hello from agent!" });
793
- }
794
- return { jobId: "job-123", success: true, sessionId: "s1" };
795
- });
796
- emitter.trigger = triggerMock;
797
- const ctx = createMockContext(config, emitter);
798
- const manager = new SlackManager(ctx);
799
- await manager.initialize();
800
- await manager.start();
801
- const replyFn = vi.fn().mockResolvedValue(undefined);
802
- const connector = mockConnectors.get("agent1");
803
- connector?.emit("message", {
804
- agentName: "agent1",
805
- prompt: "Say hello",
806
- metadata: {
807
- channelId: "C0123456789",
808
- messageTs: "1707930001.000000",
809
- userId: "U0123456789",
810
- wasMentioned: true,
811
- },
812
- reply: replyFn,
813
- startProcessingIndicator: () => () => { },
814
- });
815
- await new Promise((r) => setTimeout(r, 100));
816
- // Should have sent the streamed message
817
- expect(replyFn).toHaveBeenCalledWith("Hello from agent!");
818
- });
819
- it("handles non-Error string in error handler", async () => {
820
- const SlackManager = await getSlackManagerWithMock();
821
- const emitter = createMockEmitter();
822
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
823
- const ctx = createMockContext(config, emitter);
824
- const manager = new SlackManager(ctx);
825
- await manager.initialize();
826
- await manager.start();
827
- // Simulate non-Error (string) error from connector
828
- const connector = mockConnectors.get("agent1");
829
- connector?.emit("error", {
830
- agentName: "agent1",
831
- error: "string error",
832
- });
833
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Slack connector error for agent 'agent1': string error"));
834
- });
835
- it("handles reply failure during error handling gracefully", async () => {
836
- const SlackManager = await getSlackManagerWithMock();
837
- const emitter = createMockEmitter();
838
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
839
- const triggerMock = vi.fn().mockRejectedValue(new Error("Boom"));
840
- emitter.trigger = triggerMock;
841
- const ctx = createMockContext(config, emitter);
842
- const manager = new SlackManager(ctx);
843
- await manager.initialize();
844
- await manager.start();
845
- const replyFn = vi.fn().mockRejectedValue(new Error("Reply failed too"));
846
- const connector = mockConnectors.get("agent1");
847
- connector?.emit("message", {
848
- agentName: "agent1",
849
- prompt: "Do something",
850
- metadata: {
851
- channelId: "C0123456789",
852
- messageTs: "1707930001.000000",
853
- userId: "U0123456789",
854
- wasMentioned: true,
855
- },
856
- reply: replyFn,
857
- startProcessingIndicator: () => () => { },
858
- });
859
- await new Promise((r) => setTimeout(r, 50));
860
- // Should log both the original error and the reply failure
861
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Slack message handling failed"));
862
- expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to send error reply"));
863
- });
864
- it("extracts text from message.message.content array", async () => {
865
- const SlackManager = await getSlackManagerWithMock();
866
- const emitter = createMockEmitter();
867
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
868
- let capturedOnMessage = null;
869
- const triggerMock = vi.fn().mockImplementation(async (_name, _schedule, opts) => {
870
- capturedOnMessage = opts?.onMessage ?? null;
871
- if (capturedOnMessage) {
872
- // Simulate content array format
873
- await capturedOnMessage({
874
- type: "assistant",
875
- message: {
876
- content: [
877
- { type: "text", text: "Part 1 " },
878
- { type: "text", text: "Part 2" },
879
- ],
880
- },
881
- });
882
- }
883
- return { jobId: "job-123", success: true, sessionId: null };
884
- });
885
- emitter.trigger = triggerMock;
886
- const ctx = createMockContext(config, emitter);
887
- const manager = new SlackManager(ctx);
888
- await manager.initialize();
889
- await manager.start();
890
- const replyFn = vi.fn().mockResolvedValue(undefined);
891
- const connector = mockConnectors.get("agent1");
892
- connector?.emit("message", {
893
- agentName: "agent1",
894
- prompt: "Test",
895
- metadata: {
896
- channelId: "C0123456789",
897
- messageTs: "1707930001.000000",
898
- userId: "U0123456789",
899
- wasMentioned: true,
900
- },
901
- reply: replyFn,
902
- startProcessingIndicator: () => () => { },
903
- });
904
- await new Promise((r) => setTimeout(r, 100));
905
- expect(replyFn).toHaveBeenCalledWith("Part 1 Part 2");
906
- });
907
- it("extracts text from message.message.content string", async () => {
908
- const SlackManager = await getSlackManagerWithMock();
909
- const emitter = createMockEmitter();
910
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
911
- let capturedOnMessage = null;
912
- const triggerMock = vi.fn().mockImplementation(async (_name, _schedule, opts) => {
913
- capturedOnMessage = opts?.onMessage ?? null;
914
- if (capturedOnMessage) {
915
- await capturedOnMessage({
916
- type: "assistant",
917
- message: { content: "Direct string content" },
918
- });
919
- }
920
- return { jobId: "job-123", success: true, sessionId: null };
921
- });
922
- emitter.trigger = triggerMock;
923
- const ctx = createMockContext(config, emitter);
924
- const manager = new SlackManager(ctx);
925
- await manager.initialize();
926
- await manager.start();
927
- const replyFn = vi.fn().mockResolvedValue(undefined);
928
- const connector = mockConnectors.get("agent1");
929
- connector?.emit("message", {
930
- agentName: "agent1",
931
- prompt: "Test",
932
- metadata: {
933
- channelId: "C0123456789",
934
- messageTs: "1707930001.000000",
935
- userId: "U0123456789",
936
- wasMentioned: true,
937
- },
938
- reply: replyFn,
939
- startProcessingIndicator: () => () => { },
940
- });
941
- await new Promise((r) => setTimeout(r, 100));
942
- expect(replyFn).toHaveBeenCalledWith("Direct string content");
943
- });
944
- it("ignores non-assistant messages in onMessage callback", async () => {
945
- const SlackManager = await getSlackManagerWithMock();
946
- const emitter = createMockEmitter();
947
- const config = createConfigWithAgents(createSlackAgent("agent1", defaultSlackConfig));
948
- let capturedOnMessage = null;
949
- const triggerMock = vi.fn().mockImplementation(async (_name, _schedule, opts) => {
950
- capturedOnMessage = opts?.onMessage ?? null;
951
- if (capturedOnMessage) {
952
- await capturedOnMessage({ type: "system", content: "System msg" });
953
- await capturedOnMessage({ type: "assistant", content: "Real response" });
954
- }
955
- return { jobId: "job-123", success: true, sessionId: null };
956
- });
957
- emitter.trigger = triggerMock;
958
- const ctx = createMockContext(config, emitter);
959
- const manager = new SlackManager(ctx);
960
- await manager.initialize();
961
- await manager.start();
962
- const replyFn = vi.fn().mockResolvedValue(undefined);
963
- const connector = mockConnectors.get("agent1");
964
- connector?.emit("message", {
965
- agentName: "agent1",
966
- prompt: "Test",
967
- metadata: {
968
- channelId: "C0123456789",
969
- messageTs: "1707930001.000000",
970
- userId: "U0123456789",
971
- wasMentioned: true,
972
- },
973
- reply: replyFn,
974
- startProcessingIndicator: () => () => { },
975
- });
976
- await new Promise((r) => setTimeout(r, 100));
977
- // Should only send the assistant message, not the system one
978
- expect(replyFn).toHaveBeenCalledWith("Real response");
979
- expect(replyFn).not.toHaveBeenCalledWith("System msg");
980
- });
981
- });
982
- });
983
- //# sourceMappingURL=slack-manager.test.js.map