onbuzz 3.6.1 → 3.6.3

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 (84) hide show
  1. package/package.json +1 -1
  2. package/src/__test-utils__/fixtures/malformedJson.js +31 -0
  3. package/src/__test-utils__/globalSetup.js +9 -0
  4. package/src/__test-utils__/globalTeardown.js +12 -0
  5. package/src/__test-utils__/mockFactories.js +101 -0
  6. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -0
  7. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -0
  8. package/src/analyzers/__tests__/ESLintAnalyzer.test.js +271 -0
  9. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -0
  10. package/src/analyzers/__tests__/PrettierFormatter.test.js +197 -0
  11. package/src/analyzers/__tests__/PythonAnalyzer.test.js +208 -0
  12. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -0
  13. package/src/analyzers/__tests__/SparrowAnalyzer.test.js +270 -0
  14. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -0
  15. package/src/core/__tests__/agentPool.test.js +601 -0
  16. package/src/core/__tests__/agentScheduler.test.js +576 -0
  17. package/src/core/__tests__/contextManager.test.js +252 -0
  18. package/src/core/__tests__/flowExecutor.test.js +262 -0
  19. package/src/core/__tests__/messageProcessor.test.js +627 -0
  20. package/src/core/__tests__/orchestrator.test.js +257 -0
  21. package/src/core/__tests__/stateManager.test.js +375 -0
  22. package/src/core/agentPool.js +11 -1
  23. package/src/index.js +25 -9
  24. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +3 -5
  25. package/src/services/__tests__/agentActivityService.test.js +319 -0
  26. package/src/services/__tests__/apiKeyManager.test.js +206 -0
  27. package/src/services/__tests__/benchmarkService.test.js +184 -0
  28. package/src/services/__tests__/budgetService.test.js +211 -0
  29. package/src/services/__tests__/contextInjectionService.test.js +205 -0
  30. package/src/services/__tests__/conversationCompactionService.test.js +280 -0
  31. package/src/services/__tests__/credentialVault.test.js +469 -0
  32. package/src/services/__tests__/errorHandler.test.js +314 -0
  33. package/src/services/__tests__/fileAttachmentService.test.js +278 -0
  34. package/src/services/__tests__/flowContextService.test.js +199 -0
  35. package/src/services/__tests__/memoryService.test.js +450 -0
  36. package/src/services/__tests__/modelRouterService.test.js +388 -0
  37. package/src/services/__tests__/modelsService.test.js +261 -0
  38. package/src/services/__tests__/portRegistry.test.js +123 -0
  39. package/src/services/__tests__/projectDetector.test.js +34 -0
  40. package/src/services/__tests__/promptService.test.js +242 -0
  41. package/src/services/__tests__/qualityInspector.test.js +97 -0
  42. package/src/services/__tests__/scheduleService.test.js +308 -0
  43. package/src/services/__tests__/serviceRegistry.test.js +74 -0
  44. package/src/services/__tests__/skillsService.test.js +402 -0
  45. package/src/services/__tests__/tokenCountingService.test.js +48 -0
  46. package/src/tools/__tests__/agentCommunicationTool.test.js +500 -0
  47. package/src/tools/__tests__/agentDelayTool.test.js +342 -0
  48. package/src/tools/__tests__/asyncToolManager.test.js +344 -0
  49. package/src/tools/__tests__/baseTool.test.js +420 -0
  50. package/src/tools/__tests__/codeMapTool.test.js +348 -0
  51. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -0
  52. package/src/tools/__tests__/fileSystemTool.test.js +717 -0
  53. package/src/tools/__tests__/fileTreeTool.test.js +274 -0
  54. package/src/tools/__tests__/helpTool.test.js +204 -0
  55. package/src/tools/__tests__/jobDoneTool.test.js +296 -0
  56. package/src/tools/__tests__/memoryTool.test.js +297 -0
  57. package/src/tools/__tests__/seekTool.test.js +282 -0
  58. package/src/tools/__tests__/skillsTool.test.js +226 -0
  59. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -0
  60. package/src/tools/__tests__/taskManagerTool.test.js +725 -0
  61. package/src/tools/__tests__/terminalTool.test.js +384 -0
  62. package/src/tools/__tests__/userPromptTool.test.js +297 -0
  63. package/src/tools/__tests__/webTool.e2e.test.js +25 -11
  64. package/src/tools/webTool.js +6 -12
  65. package/src/types/__tests__/agent.test.js +499 -0
  66. package/src/types/__tests__/contextReference.test.js +606 -0
  67. package/src/types/__tests__/conversation.test.js +555 -0
  68. package/src/types/__tests__/toolCommand.test.js +584 -0
  69. package/src/types/contextReference.js +1 -1
  70. package/src/utilities/__tests__/attachmentValidator.test.js +80 -0
  71. package/src/utilities/__tests__/configManager.test.js +397 -0
  72. package/src/utilities/__tests__/constants.test.js +49 -0
  73. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -0
  74. package/src/utilities/__tests__/fileProcessor.test.js +104 -0
  75. package/src/utilities/__tests__/jsonRepair.test.js +104 -0
  76. package/src/utilities/__tests__/logger.test.js +129 -0
  77. package/src/utilities/__tests__/platformUtils.test.js +87 -0
  78. package/src/utilities/__tests__/structuredFileValidator.test.js +263 -0
  79. package/src/utilities/__tests__/tagParser.test.js +887 -0
  80. package/src/utilities/__tests__/toolConstants.test.js +94 -0
  81. package/src/utilities/tagParser.js +2 -2
  82. package/src/tools/browserTool.js +0 -897
  83. package/src/utilities/platformUtils.test.js +0 -98
  84. /package/src/tools/{filesystemTool.js → fileSystemTool.js} +0 -0
@@ -0,0 +1,576 @@
1
+ /**
2
+ * AgentScheduler - Comprehensive unit tests (target: 80%+ line coverage)
3
+ * Focuses on hash/loop detection, scheduling lifecycle, processing guards,
4
+ * and agent session management.
5
+ */
6
+ import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
7
+ import { createMockLogger } from '../../__test-utils__/mockFactories.js';
8
+
9
+ // ── Mock dependencies ────────────────────────────────────────────────────────
10
+ jest.unstable_mockModule('../../services/contextInjectionService.js', () => ({
11
+ default: jest.fn().mockImplementation(() => ({
12
+ injectContext: jest.fn().mockResolvedValue({ messages: [], injected: false })
13
+ }))
14
+ }));
15
+
16
+ jest.unstable_mockModule('../../services/flowContextService.js', () => ({
17
+ default: jest.fn().mockImplementation(() => ({
18
+ injectFlowContext: jest.fn().mockResolvedValue({ messages: [] })
19
+ }))
20
+ }));
21
+
22
+ jest.unstable_mockModule('../../services/tokenCountingService.js', () => ({
23
+ default: jest.fn().mockImplementation(() => ({
24
+ countTokens: jest.fn().mockReturnValue(100),
25
+ cleanup: jest.fn()
26
+ }))
27
+ }));
28
+
29
+ jest.unstable_mockModule('../../services/conversationCompactionService.js', () => ({
30
+ default: jest.fn().mockImplementation(() => ({
31
+ shouldCompact: jest.fn().mockReturnValue(false),
32
+ compact: jest.fn().mockResolvedValue(null),
33
+ setModelsService: jest.fn()
34
+ }))
35
+ }));
36
+
37
+ const mockShouldAgentBeActive = jest.fn().mockReturnValue({ active: false, reason: 'no-messages' });
38
+ const mockGetActiveAgents = jest.fn().mockReturnValue([]);
39
+ const mockShouldSkipIteration = jest.fn().mockReturnValue({ skip: false });
40
+
41
+ jest.unstable_mockModule('../../services/agentActivityService.js', () => ({
42
+ shouldAgentBeActive: mockShouldAgentBeActive,
43
+ getActiveAgents: mockGetActiveAgents,
44
+ shouldSkipIteration: mockShouldSkipIteration
45
+ }));
46
+
47
+ const { default: AgentScheduler } = await import('../agentScheduler.js');
48
+
49
+ // ── Helpers ──────────────────────────────────────────────────────────────────
50
+ function makeAgent(overrides = {}) {
51
+ return {
52
+ id: overrides.id || 'agent-test-1',
53
+ name: overrides.name || 'TestAgent',
54
+ mode: overrides.mode || 'chat',
55
+ status: 'active',
56
+ stopRequested: false,
57
+ preferredModel: 'test-model',
58
+ currentModel: 'test-model',
59
+ sessionId: 'sess-1',
60
+ iterationCount: 0,
61
+ maxIterations: 10,
62
+ delayEndTime: null,
63
+ ttl: null,
64
+ conversations: {
65
+ full: { messages: overrides.messages || [], lastUpdated: new Date().toISOString() },
66
+ 'test-model': { messages: overrides.messages || [], lastUpdated: new Date().toISOString() }
67
+ },
68
+ messageQueues: {
69
+ userMessages: overrides.userMessages || [],
70
+ interAgentMessages: [],
71
+ toolResults: []
72
+ },
73
+ taskList: { tasks: [], lastUpdated: new Date().toISOString() },
74
+ directoryAccess: { workingDirectory: '/tmp' },
75
+ ...overrides
76
+ };
77
+ }
78
+
79
+ function makeScheduler() {
80
+ const logger = createMockLogger();
81
+ const agentPool = {
82
+ getAgent: jest.fn().mockResolvedValue(null),
83
+ getAllAgents: jest.fn().mockResolvedValue([]),
84
+ persistAgentState: jest.fn().mockResolvedValue(undefined),
85
+ addToolResult: jest.fn().mockResolvedValue(undefined),
86
+ getCompactionMetadata: jest.fn().mockResolvedValue(null),
87
+ getMessagesForAI: jest.fn().mockResolvedValue([])
88
+ };
89
+ const messageProcessor = {
90
+ extractAndExecuteTools: jest.fn().mockResolvedValue([]),
91
+ extractToolCommands: jest.fn().mockResolvedValue([])
92
+ };
93
+ const aiService = {
94
+ sendMessage: jest.fn().mockResolvedValue({ content: 'AI response', tokenUsage: { total_tokens: 100 } }),
95
+ abortRequest: jest.fn().mockReturnValue(false)
96
+ };
97
+ const scheduler = new AgentScheduler(agentPool, messageProcessor, aiService, logger);
98
+ return { scheduler, agentPool, messageProcessor, aiService, logger };
99
+ }
100
+
101
+ // ── Tests ────────────────────────────────────────────────────────────────────
102
+ describe('AgentScheduler', () => {
103
+ let scheduler, agentPool, logger;
104
+
105
+ beforeEach(() => {
106
+ jest.clearAllMocks();
107
+ ({ scheduler, agentPool, logger } = makeScheduler());
108
+ });
109
+
110
+ afterEach(() => {
111
+ scheduler.stop();
112
+ });
113
+
114
+ // ─── generateAgentStateHash ────────────────────────────────────────────
115
+ describe('generateAgentStateHash', () => {
116
+ test('generates consistent hash for same conversation state', () => {
117
+ const agent = makeAgent({
118
+ messages: [
119
+ { role: 'assistant', content: 'Hello world' }
120
+ ]
121
+ });
122
+ const hash1 = scheduler.generateAgentStateHash(agent);
123
+ const hash2 = scheduler.generateAgentStateHash(agent);
124
+ expect(hash1).toBe(hash2);
125
+ });
126
+
127
+ test('generates different hash for different content', () => {
128
+ const agent1 = makeAgent({ messages: [{ role: 'assistant', content: 'Hello' }] });
129
+ const agent2 = makeAgent({ messages: [{ role: 'assistant', content: 'Goodbye' }] });
130
+ const hash1 = scheduler.generateAgentStateHash(agent1);
131
+ const hash2 = scheduler.generateAgentStateHash(agent2);
132
+ expect(hash1).not.toBe(hash2);
133
+ });
134
+
135
+ test('only considers last 3 assistant messages', () => {
136
+ const msgs = Array.from({ length: 10 }, (_, i) => ({
137
+ role: 'assistant', content: `Message ${i}`
138
+ }));
139
+ const agent = makeAgent({ messages: msgs });
140
+ const hash = scheduler.generateAgentStateHash(agent);
141
+ expect(hash).toBeDefined();
142
+ expect(typeof hash).toBe('string');
143
+ });
144
+
145
+ test('includes tool calls in hash', () => {
146
+ const agent1 = makeAgent({
147
+ messages: [{ role: 'assistant', content: 'X', toolCalls: [{ toolId: 'terminal', parameters: { cmd: 'ls' } }] }]
148
+ });
149
+ const agent2 = makeAgent({
150
+ messages: [{ role: 'assistant', content: 'X', toolCalls: [{ toolId: 'filesystem', parameters: { action: 'read' } }] }]
151
+ });
152
+ expect(scheduler.generateAgentStateHash(agent1)).not.toBe(scheduler.generateAgentStateHash(agent2));
153
+ });
154
+
155
+ test('handles empty conversation gracefully', () => {
156
+ const agent = makeAgent({ messages: [] });
157
+ const hash = scheduler.generateAgentStateHash(agent);
158
+ expect(hash).toBeDefined();
159
+ });
160
+
161
+ test('handles missing conversations gracefully', () => {
162
+ const agent = { conversations: {} };
163
+ const hash = scheduler.generateAgentStateHash(agent);
164
+ expect(hash).toBeDefined();
165
+ });
166
+
167
+ test('filters to only assistant messages, ignores user messages', () => {
168
+ const agent1 = makeAgent({ messages: [
169
+ { role: 'user', content: 'question' },
170
+ { role: 'assistant', content: 'answer' }
171
+ ]});
172
+ const agent2 = makeAgent({ messages: [
173
+ { role: 'user', content: 'different question' },
174
+ { role: 'assistant', content: 'answer' }
175
+ ]});
176
+ // Same assistant content should produce same hash
177
+ expect(scheduler.generateAgentStateHash(agent1)).toBe(scheduler.generateAgentStateHash(agent2));
178
+ });
179
+ });
180
+
181
+ // ─── detectRepetitiveLoop ──────────────────────────────────────────────
182
+ describe('detectRepetitiveLoop', () => {
183
+ test('returns no loop for empty history', () => {
184
+ const result = scheduler.detectRepetitiveLoop('agent-1', 'hash-abc');
185
+ expect(result.isLoop).toBe(false);
186
+ expect(result.isImmediateDuplicate).toBe(false);
187
+ expect(result.occurrences).toBe(0);
188
+ });
189
+
190
+ test('detects immediate duplicate', () => {
191
+ scheduler.stateHashHistory.set('agent-1', [
192
+ { hash: 'hash-abc', timestamp: Date.now() }
193
+ ]);
194
+ const result = scheduler.detectRepetitiveLoop('agent-1', 'hash-abc');
195
+ expect(result.isImmediateDuplicate).toBe(true);
196
+ expect(result.occurrences).toBe(1);
197
+ });
198
+
199
+ test('detects loop when threshold is met', () => {
200
+ // Fill with enough repeated hashes to trigger loop (threshold = 5)
201
+ const history = Array.from({ length: 5 }, () => ({ hash: 'repeat-hash', timestamp: Date.now() }));
202
+ scheduler.stateHashHistory.set('agent-1', history);
203
+ const result = scheduler.detectRepetitiveLoop('agent-1', 'repeat-hash');
204
+ expect(result.isLoop).toBe(true);
205
+ expect(result.occurrences).toBe(5);
206
+ });
207
+
208
+ test('does not detect loop below threshold', () => {
209
+ const history = Array.from({ length: 3 }, () => ({ hash: 'some-hash', timestamp: Date.now() }));
210
+ scheduler.stateHashHistory.set('agent-1', history);
211
+ const result = scheduler.detectRepetitiveLoop('agent-1', 'some-hash');
212
+ expect(result.isLoop).toBe(false);
213
+ expect(result.occurrences).toBe(3);
214
+ });
215
+
216
+ test('uses sliding window - old entries are not counted', () => {
217
+ // Create more entries than window size, with repeated hash only in oldest entries
218
+ const history = [];
219
+ for (let i = 0; i < 25; i++) {
220
+ history.push({ hash: i < 5 ? 'old-hash' : `unique-${i}`, timestamp: Date.now() });
221
+ }
222
+ scheduler.stateHashHistory.set('agent-1', history);
223
+ // The 'old-hash' entries are outside the window (last 20)
224
+ const result = scheduler.detectRepetitiveLoop('agent-1', 'old-hash');
225
+ expect(result.isLoop).toBe(false);
226
+ });
227
+ });
228
+
229
+ // ─── recordStateHash ───────────────────────────────────────────────────
230
+ describe('recordStateHash', () => {
231
+ test('adds hash entry to history', () => {
232
+ scheduler.recordStateHash('agent-1', 'hash-123');
233
+ const history = scheduler.stateHashHistory.get('agent-1');
234
+ expect(history).toHaveLength(1);
235
+ expect(history[0].hash).toBe('hash-123');
236
+ expect(history[0].timestamp).toBeDefined();
237
+ });
238
+
239
+ test('creates history array if not exists', () => {
240
+ expect(scheduler.stateHashHistory.has('new-agent')).toBe(false);
241
+ scheduler.recordStateHash('new-agent', 'hash-x');
242
+ expect(scheduler.stateHashHistory.has('new-agent')).toBe(true);
243
+ });
244
+
245
+ test('trims history when exceeding max size (2x window)', () => {
246
+ // Window is 20, so max history is 40
247
+ const history = Array.from({ length: 45 }, (_, i) => ({
248
+ hash: `hash-${i}`, timestamp: Date.now()
249
+ }));
250
+ scheduler.stateHashHistory.set('agent-1', history);
251
+ scheduler.recordStateHash('agent-1', 'new-hash');
252
+ const updated = scheduler.stateHashHistory.get('agent-1');
253
+ expect(updated.length).toBeLessThanOrEqual(41); // 40 max + 1 new
254
+ });
255
+ });
256
+
257
+ // ─── handleRepetitiveLoop ──────────────────────────────────────────────
258
+ describe('handleRepetitiveLoop', () => {
259
+ test('switches agent to chat mode and adds intervention message', async () => {
260
+ const agent = makeAgent({ id: 'agent-loop' });
261
+ agentPool.getAgent.mockResolvedValue(agent);
262
+
263
+ await scheduler.handleRepetitiveLoop('agent-loop', { occurrences: 5, windowSize: 20 });
264
+
265
+ expect(agent.mode).toBe('chat');
266
+ expect(agent.conversations.full.messages.length).toBe(1);
267
+ const msg = agent.conversations.full.messages[0];
268
+ expect(msg.role).toBe('assistant');
269
+ expect(msg.content).toContain('similar responses repeatedly');
270
+ expect(msg.loopDetection.occurrences).toBe(5);
271
+ expect(agentPool.persistAgentState).toHaveBeenCalledWith('agent-loop');
272
+ });
273
+
274
+ test('clears hash history after handling', async () => {
275
+ const agent = makeAgent({ id: 'agent-loop' });
276
+ agentPool.getAgent.mockResolvedValue(agent);
277
+ scheduler.stateHashHistory.set('agent-loop', [{ hash: 'x', timestamp: Date.now() }]);
278
+
279
+ await scheduler.handleRepetitiveLoop('agent-loop', { occurrences: 5, windowSize: 20 });
280
+
281
+ expect(scheduler.stateHashHistory.has('agent-loop')).toBe(false);
282
+ });
283
+
284
+ test('does nothing when agent not found', async () => {
285
+ agentPool.getAgent.mockResolvedValue(null);
286
+ await scheduler.handleRepetitiveLoop('nonexistent', { occurrences: 5, windowSize: 20 });
287
+ expect(agentPool.persistAgentState).not.toHaveBeenCalled();
288
+ });
289
+
290
+ test('broadcasts to websocket if available', async () => {
291
+ const agent = makeAgent({ id: 'agent-loop', sessionId: 'sess-abc' });
292
+ agentPool.getAgent.mockResolvedValue(agent);
293
+ const ws = { broadcastToSession: jest.fn() };
294
+ scheduler.webSocketManager = ws;
295
+ scheduler.agentSessionMap.set('agent-loop', 'sess-abc');
296
+
297
+ await scheduler.handleRepetitiveLoop('agent-loop', { occurrences: 5, windowSize: 20 });
298
+
299
+ expect(ws.broadcastToSession).toHaveBeenCalled();
300
+ });
301
+ });
302
+
303
+ // ─── clearHashHistory ──────────────────────────────────────────────────
304
+ describe('clearHashHistory', () => {
305
+ test('empties history for agent', () => {
306
+ scheduler.stateHashHistory.set('agent-1', [{ hash: 'x', timestamp: 1 }]);
307
+ scheduler.clearHashHistory('agent-1');
308
+ expect(scheduler.stateHashHistory.get('agent-1')).toEqual([]);
309
+ });
310
+
311
+ test('no-op when agent has no history', () => {
312
+ scheduler.clearHashHistory('nonexistent');
313
+ expect(scheduler.stateHashHistory.has('nonexistent')).toBe(false);
314
+ });
315
+ });
316
+
317
+ // ─── start / stop ─────────────────────────────────────────────────────
318
+ describe('start / stop lifecycle', () => {
319
+ test('start sets isRunning and creates interval', () => {
320
+ scheduler.start();
321
+ expect(scheduler.isRunning).toBe(true);
322
+ expect(scheduler.scheduleInterval).not.toBeNull();
323
+ });
324
+
325
+ test('start is idempotent when already running', () => {
326
+ scheduler.start();
327
+ const firstInterval = scheduler.scheduleInterval;
328
+ scheduler.start();
329
+ expect(scheduler.scheduleInterval).toBe(firstInterval);
330
+ });
331
+
332
+ test('stop clears running state and interval', () => {
333
+ scheduler.start();
334
+ scheduler.stop();
335
+ expect(scheduler.isRunning).toBe(false);
336
+ expect(scheduler.scheduleInterval).toBeNull();
337
+ });
338
+
339
+ test('stop clears all tracking maps', () => {
340
+ // Must be running for stop() to execute
341
+ scheduler.isRunning = true;
342
+ scheduler.agentSessionMap.set('a', 's');
343
+ scheduler.stateHashHistory.set('a', []);
344
+ scheduler.agentProcessingLocks.set('a', true);
345
+ scheduler.consecutiveNoToolMessages.set('a', 3);
346
+ scheduler.stop();
347
+ expect(scheduler.agentProcessingLocks.size).toBe(0);
348
+ expect(scheduler.stateHashHistory.size).toBe(0);
349
+ expect(scheduler.consecutiveNoToolMessages.size).toBe(0);
350
+ expect(scheduler.agentSessionMap.size).toBe(0);
351
+ });
352
+
353
+ test('stop is a no-op when not running', () => {
354
+ scheduler.stop(); // should not throw
355
+ expect(scheduler.isRunning).toBe(false);
356
+ });
357
+ });
358
+
359
+ // ─── addAgent / removeAgent ───────────────────────────────────────────
360
+ describe('addAgent', () => {
361
+ test('registers session ID for agent', async () => {
362
+ await scheduler.addAgent('agent-1', { sessionId: 'sess-abc', triggeredBy: 'user-message' });
363
+ expect(scheduler.getAgentSession('agent-1')).toBe('sess-abc');
364
+ });
365
+
366
+ test('clears hash history on user-message trigger', async () => {
367
+ scheduler.stateHashHistory.set('agent-1', [{ hash: 'x', timestamp: 1 }]);
368
+ await scheduler.addAgent('agent-1', { sessionId: 'sess-1', triggeredBy: 'user-message' });
369
+ expect(scheduler.stateHashHistory.get('agent-1')).toEqual([]);
370
+ });
371
+
372
+ test('resets no-tool counter on user-message trigger', async () => {
373
+ scheduler.consecutiveNoToolMessages.set('agent-1', 5);
374
+ await scheduler.addAgent('agent-1', { sessionId: 'sess-1', triggeredBy: 'user-message' });
375
+ expect(scheduler.consecutiveNoToolMessages.get('agent-1')).toBe(0);
376
+ });
377
+
378
+ test('initializes hash history if not exists', async () => {
379
+ await scheduler.addAgent('agent-new', { sessionId: 'sess-1' });
380
+ expect(scheduler.stateHashHistory.has('agent-new')).toBe(true);
381
+ });
382
+
383
+ test('starts scheduler if not running', async () => {
384
+ expect(scheduler.isRunning).toBe(false);
385
+ await scheduler.addAgent('agent-1', { sessionId: 'sess-1' });
386
+ expect(scheduler.isRunning).toBe(true);
387
+ });
388
+
389
+ test('warns when no sessionId provided', async () => {
390
+ await scheduler.addAgent('agent-1', { triggeredBy: 'unknown' });
391
+ expect(logger.warn).toHaveBeenCalled();
392
+ });
393
+ });
394
+
395
+ describe('removeAgent', () => {
396
+ test('cleans up all tracking for agent', () => {
397
+ scheduler.agentSessionMap.set('agent-1', 'sess-1');
398
+ scheduler.stateHashHistory.set('agent-1', []);
399
+ scheduler.agentProcessingLocks.set('agent-1', true);
400
+ scheduler.consecutiveNoToolMessages.set('agent-1', 3);
401
+
402
+ scheduler.removeAgent('agent-1', 'completed');
403
+
404
+ expect(scheduler.agentSessionMap.has('agent-1')).toBe(false);
405
+ expect(scheduler.stateHashHistory.has('agent-1')).toBe(false);
406
+ expect(scheduler.agentProcessingLocks.has('agent-1')).toBe(false);
407
+ expect(scheduler.consecutiveNoToolMessages.has('agent-1')).toBe(false);
408
+ });
409
+ });
410
+
411
+ // ─── processingCycle ──────────────────────────────────────────────────
412
+ describe('processingCycle', () => {
413
+ test('returns early when no active agents', async () => {
414
+ mockGetActiveAgents.mockReturnValue([]);
415
+ agentPool.getAllAgents.mockResolvedValue([]);
416
+ await scheduler.processingCycle();
417
+ // Should not throw, and no agents processed
418
+ });
419
+
420
+ test('skips agents already locked for processing', async () => {
421
+ const agent = makeAgent({ id: 'agent-locked' });
422
+ mockGetActiveAgents.mockReturnValue([{ agentId: 'agent-locked', reason: 'messages' }]);
423
+ agentPool.getAllAgents.mockResolvedValue([agent]);
424
+ scheduler.agentProcessingLocks.set('agent-locked', true);
425
+
426
+ await scheduler.processingCycle();
427
+ // Should not process the locked agent
428
+ expect(agentPool.getAgent).not.toHaveBeenCalled();
429
+ });
430
+
431
+ test('respects concurrency cap', async () => {
432
+ // Fill up processing locks to max
433
+ scheduler.agentProcessingLocks.set('in-flight-1', true);
434
+ scheduler.agentProcessingLocks.set('in-flight-2', true);
435
+ scheduler.agentProcessingLocks.set('in-flight-3', true);
436
+
437
+ mockGetActiveAgents.mockReturnValue([{ agentId: 'agent-new', reason: 'messages' }]);
438
+ agentPool.getAllAgents.mockResolvedValue([makeAgent({ id: 'agent-new' })]);
439
+
440
+ await scheduler.processingCycle();
441
+ // With 3 already in-flight and max 3, no new agents should launch
442
+ });
443
+ });
444
+
445
+ // ─── registerAgentSession / getAgentSession ────────────────────────────
446
+ describe('session management', () => {
447
+ test('registerAgentSession stores session', () => {
448
+ scheduler.registerAgentSession('a1', 'sess-1');
449
+ expect(scheduler.getAgentSession('a1')).toBe('sess-1');
450
+ });
451
+
452
+ test('getAgentSession returns undefined for unregistered', () => {
453
+ expect(scheduler.getAgentSession('unknown')).toBeUndefined();
454
+ });
455
+
456
+ test('registerAgentSession ignores null agentId', () => {
457
+ scheduler.registerAgentSession(null, 'sess-1');
458
+ expect(scheduler.agentSessionMap.size).toBe(0);
459
+ });
460
+ });
461
+
462
+ // ─── getStatus ─────────────────────────────────────────────────────────
463
+ describe('getStatus', () => {
464
+ test('returns scheduler status with active agents', async () => {
465
+ scheduler.start();
466
+ mockGetActiveAgents.mockReturnValue([
467
+ { agentId: 'a1', reason: 'messages' }
468
+ ]);
469
+ agentPool.getAllAgents.mockResolvedValue([makeAgent({ id: 'a1' })]);
470
+
471
+ const status = await scheduler.getStatus();
472
+ expect(status.isRunning).toBe(true);
473
+ expect(status.agentCount).toBe(1);
474
+ expect(status.activeAgents[0].agentId).toBe('a1');
475
+ });
476
+
477
+ test('returns empty when no active agents', async () => {
478
+ mockGetActiveAgents.mockReturnValue([]);
479
+ agentPool.getAllAgents.mockResolvedValue([]);
480
+ const status = await scheduler.getStatus();
481
+ expect(status.agentCount).toBe(0);
482
+ });
483
+ });
484
+
485
+ // ─── stopAgentExecution ───────────────────────────────────────────────
486
+ describe('stopAgentExecution', () => {
487
+ test('stops agent and switches to chat mode', async () => {
488
+ const agent = makeAgent({ id: 'agent-stop', mode: 'agent' });
489
+ agentPool.getAgent.mockResolvedValue(agent);
490
+
491
+ const result = await scheduler.stopAgentExecution('agent-stop');
492
+ expect(result.success).toBe(true);
493
+ expect(agent.mode).toBe('chat');
494
+ expect(agent.delayEndTime).toBeNull();
495
+ expect(agentPool.persistAgentState).toHaveBeenCalled();
496
+ });
497
+
498
+ test('returns error for non-existent agent', async () => {
499
+ agentPool.getAgent.mockResolvedValue(null);
500
+ const result = await scheduler.stopAgentExecution('nonexistent');
501
+ expect(result.success).toBe(false);
502
+ expect(result.error).toBe('Agent not found');
503
+ });
504
+
505
+ test('aborts active AI request if abortRequest is available', async () => {
506
+ const agent = makeAgent({ id: 'agent-abort' });
507
+ agentPool.getAgent.mockResolvedValue(agent);
508
+ scheduler.aiService = { abortRequest: jest.fn().mockReturnValue(true) };
509
+
510
+ await scheduler.stopAgentExecution('agent-abort');
511
+ expect(scheduler.aiService.abortRequest).toHaveBeenCalledWith('agent-abort');
512
+ });
513
+ });
514
+
515
+ // ─── isAgentInScheduler ───────────────────────────────────────────────
516
+ describe('isAgentInScheduler', () => {
517
+ test('returns false when agent not found', async () => {
518
+ agentPool.getAgent.mockResolvedValue(null);
519
+ expect(await scheduler.isAgentInScheduler('missing')).toBe(false);
520
+ });
521
+
522
+ test('delegates to shouldAgentBeActive', async () => {
523
+ const agent = makeAgent();
524
+ agentPool.getAgent.mockResolvedValue(agent);
525
+ mockShouldAgentBeActive.mockReturnValue({ active: true });
526
+ expect(await scheduler.isAgentInScheduler('agent-test-1')).toBe(true);
527
+ });
528
+ });
529
+
530
+ // ─── formatToolResult ─────────────────────────────────────────────────
531
+ describe('formatToolResult', () => {
532
+ test('formats completed object result', () => {
533
+ const result = scheduler.formatToolResult({ toolId: 'fs', status: 'completed', result: { data: 'ok' } });
534
+ expect(result).toContain('[fs]');
535
+ expect(result).toContain('"data"');
536
+ });
537
+
538
+ test('formats completed string result', () => {
539
+ const result = scheduler.formatToolResult({ toolId: 'terminal', status: 'completed', result: 'done' });
540
+ expect(result).toContain('[terminal]');
541
+ expect(result).toContain('done');
542
+ });
543
+
544
+ test('formats completed with no result', () => {
545
+ const result = scheduler.formatToolResult({ toolId: 'x', status: 'completed', result: null });
546
+ expect(typeof result).toBe('string');
547
+ expect(result.length).toBeGreaterThan(0);
548
+ });
549
+
550
+ test('formats failed result', () => {
551
+ const result = scheduler.formatToolResult({ toolId: 'x', status: 'failed', error: 'boom' });
552
+ expect(result).toContain('failed');
553
+ expect(result).toContain('boom');
554
+ });
555
+
556
+ test('formats failed without error message', () => {
557
+ const result = scheduler.formatToolResult({ toolId: 'x', status: 'failed' });
558
+ expect(result).toContain('Unknown error');
559
+ });
560
+
561
+ test('formats other status with result', () => {
562
+ const result = scheduler.formatToolResult({ toolId: 'x', status: 'warning', result: 'careful' });
563
+ expect(result).toContain('careful');
564
+ });
565
+
566
+ test('formats unknown status', () => {
567
+ const result = scheduler.formatToolResult({ toolId: 'x', status: 'unknown' });
568
+ expect(result).toContain('status: unknown');
569
+ });
570
+
571
+ test('handles missing toolId gracefully', () => {
572
+ const result = scheduler.formatToolResult({ status: 'completed', result: 'ok' });
573
+ expect(result).toContain('ok');
574
+ });
575
+ });
576
+ });