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