immediate-invocation.test.js
1 /** 2 * Tests for immediate agent invocation (event-driven workflow) 3 * 4 * Verifies that agents invoke each other immediately instead of waiting for cron cycles. 5 */ 6 7 import { test, describe, beforeEach, afterEach, mock } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import Database from 'better-sqlite3'; 10 import fs from 'fs'; 11 import { BaseAgent, resetDb as resetBaseAgentDb } from '../../src/agents/base-agent.js'; 12 import { DeveloperAgent } from '../../src/agents/developer.js'; 13 import { QAAgent } from '../../src/agents/qa.js'; 14 import { 15 createAgentTask, 16 getTaskById, 17 resetDb as resetTaskManagerDb, 18 } from '../../src/agents/utils/task-manager.js'; 19 20 // Use unique database per test to avoid SQLITE_READONLY_DBMOVED errors 21 const getTestDbPath = () => `./test-immediate-invocation-${Date.now()}-${Math.random()}.db`; 22 const SCHEMA_PATH = './db/schema.sql'; 23 24 /** 25 * Simple test agent for testing immediate invocation 26 */ 27 class TestAgent extends BaseAgent { 28 constructor() { 29 super('monitor', ['base.md']); 30 // Skip context loading in tests 31 this.context = 'Test context'; 32 this.contextMetadata = { context: 'Test context', sizeKB: 1, files: ['base.md'] }; 33 this.isInitialized = true; 34 // Initialize agent_state row so acquireLock() can succeed 35 this.updateAgentState('idle'); 36 } 37 38 async processTask(task) { 39 // Record that we processed the task 40 this.updateTask(task.id, { result: { processed: true } }); 41 await this.completeTask(task.id, { success: true }); 42 } 43 } 44 45 describe('Immediate Agent Invocation', () => { 46 let db; 47 let testDbPath; 48 49 beforeEach(() => { 50 // Use unique database path per test 51 testDbPath = getTestDbPath(); 52 53 // Clean up old test database 54 if (fs.existsSync(testDbPath)) { 55 fs.unlinkSync(testDbPath); 56 } 57 58 // Create test database 59 db = new Database(testDbPath); 60 db.pragma('foreign_keys = ON'); 61 62 // Load schema 63 const schema = fs.readFileSync(SCHEMA_PATH, 'utf8'); 64 db.exec(schema); 65 66 // Set test database path 67 process.env.DATABASE_PATH = testDbPath; 68 69 // Enable immediate invocation 70 process.env.AGENT_IMMEDIATE_INVOCATION = 'true'; 71 process.env.AGENT_MAX_CHAIN_DEPTH = '10'; 72 73 // Disable real-time notifications (we test in-process invocation, not spawning) 74 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 75 76 // Reset database connections 77 resetBaseAgentDb(); 78 resetTaskManagerDb(); 79 }); 80 81 afterEach(() => { 82 // Clean up 83 if (db) { 84 try { 85 db.close(); 86 } catch (e) { 87 // Ignore close errors 88 } 89 } 90 91 if (testDbPath && fs.existsSync(testDbPath)) { 92 try { 93 fs.unlinkSync(testDbPath); 94 } catch (e) { 95 // Ignore unlink errors 96 } 97 } 98 99 delete process.env.DATABASE_PATH; 100 delete process.env.AGENT_IMMEDIATE_INVOCATION; 101 delete process.env.AGENT_MAX_CHAIN_DEPTH; 102 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 103 delete process.env.AGENT_INVOCATION_DEPTH; 104 }); 105 106 test('handoff() immediately invokes receiving agent', async () => { 107 const agent1 = new TestAgent(); 108 // Skip initialize() - already done in constructor 109 110 // Create a task and hand it off (assign to 'triage' so the mocked TestAgent can pick it up) 111 const taskId = await agent1.createTask({ 112 task_type: 'test_task', 113 assigned_to: 'triage', 114 priority: 5, 115 context: { step: 1 }, 116 }); 117 118 // Mock getAgentClass to return TestAgent (a 'monitor' named agent) 119 // Override so TestAgent can process tasks assigned to any agent 120 agent1.getAgentClass = async () => { 121 // Return a TestAgent subclass with matching name 122 class TempAgent extends BaseAgent { 123 constructor() { 124 super('triage', ['base.md']); 125 this.context = 'Test context'; 126 this.contextMetadata = { context: 'Test context', sizeKB: 1, files: ['base.md'] }; 127 this.isInitialized = true; 128 } 129 async processTask(task) { 130 this.updateTask(task.id, { result: { processed: true } }); 131 await this.completeTask(task.id, { success: true }); 132 } 133 } 134 return TempAgent; 135 }; 136 137 // Handoff to another agent 138 const startTime = Date.now(); 139 await agent1.handoff(taskId, 'triage', 'Passing control'); 140 const handoffTime = Date.now() - startTime; 141 142 // Verify handoff was fast (< 1 second) 143 assert.ok(handoffTime < 1000, `Handoff took ${handoffTime}ms, expected < 1000ms`); 144 145 // Verify task was processed 146 const task = getTaskById(taskId); 147 assert.equal(task.status, 'completed', 'Task should be completed immediately'); 148 }); 149 150 test('createTask() immediately invokes assigned agent', async () => { 151 const agent = new TestAgent(); 152 // Skip initialize() - already done in constructor 153 154 // Mock getAgentClass to return TestAgent 155 agent.getAgentClass = async () => TestAgent; 156 157 // Create task (assign to monitor so TestAgent - which has name 'monitor' - can pick it up) 158 const startTime = Date.now(); 159 const taskId = await agent.createTask({ 160 task_type: 'test_task', 161 assigned_to: 'monitor', 162 priority: 5, 163 context: { data: 'test' }, 164 }); 165 const createTime = Date.now() - startTime; 166 167 // Verify creation was fast (< 1 second) 168 assert.ok(createTime < 1000, `Task creation took ${createTime}ms, expected < 1000ms`); 169 170 // Verify task was processed 171 const task = getTaskById(taskId); 172 assert.equal(task.status, 'completed', 'Task should be completed immediately'); 173 }); 174 175 test('immediate invocation respects max chain depth', async () => { 176 process.env.AGENT_MAX_CHAIN_DEPTH = '3'; 177 178 const agent = new TestAgent(); 179 // Skip initialize() - already done in constructor 180 181 // Mock getAgentClass to return TestAgent 182 agent.getAgentClass = async () => TestAgent; 183 184 // Create tasks in a chain 185 const task1 = await agent.createTask({ 186 task_type: 'test_task', 187 assigned_to: 'developer', 188 priority: 5, 189 }); 190 191 // Simulate deep chain 192 process.env.AGENT_INVOCATION_DEPTH = '3'; 193 194 // This should not invoke immediately (depth limit reached) 195 const task2 = await agent.createTask({ 196 task_type: 'test_task', 197 assigned_to: 'developer', 198 priority: 5, 199 }); 200 201 const task = getTaskById(task2); 202 203 // Task should be pending (not processed due to depth limit) 204 assert.equal(task.status, 'pending', 'Task should remain pending when depth limit reached'); 205 }); 206 207 test('immediate invocation can be disabled', async () => { 208 process.env.AGENT_IMMEDIATE_INVOCATION = 'false'; 209 210 const agent = new TestAgent(); 211 // Skip initialize() - already done in constructor 212 213 // Mock getAgentClass 214 agent.getAgentClass = async () => TestAgent; 215 216 // Create task 217 const taskId = await agent.createTask({ 218 task_type: 'test_task', 219 assigned_to: 'developer', 220 priority: 5, 221 }); 222 223 // Task should remain pending (immediate invocation disabled) 224 const task = getTaskById(taskId); 225 assert.equal( 226 task.status, 227 'pending', 228 'Task should remain pending when immediate invocation is disabled' 229 ); 230 }); 231 232 test('immediate invocation handles errors gracefully', async () => { 233 const agent = new TestAgent(); 234 // Skip initialize() - already done in constructor 235 236 // Mock getAgentClass to throw error 237 agent.getAgentClass = async () => { 238 throw new Error('Agent not found'); 239 }; 240 241 // Create task (should not throw, should fall back to cron) 242 const taskId = await agent.createTask({ 243 task_type: 'test_task', 244 assigned_to: 'monitor', 245 priority: 5, 246 }); 247 248 // Task should remain pending (error handled gracefully) 249 const task = getTaskById(taskId); 250 assert.equal( 251 task.status, 252 'pending', 253 'Task should remain pending when immediate invocation fails' 254 ); 255 }); 256 257 test('workflow chain completes in under 2 minutes', async () => { 258 // This test simulates the bug fix workflow: 259 // Monitor → Triage → Developer → QA 260 261 // Create minimal test agents 262 class MonitorAgent extends BaseAgent { 263 constructor() { 264 super('monitor', ['base.md']); 265 // Skip context loading in tests 266 this.context = 'Test context'; 267 this.contextMetadata = { context: 'Test context', sizeKB: 1, files: ['base.md'] }; 268 this.isInitialized = true; 269 } 270 async processTask(task) { 271 // Create triage task 272 await this.createTask({ 273 task_type: 'classify_error', 274 assigned_to: 'triage', 275 priority: 7, 276 context: { error: 'Test error' }, 277 }); 278 await this.completeTask(task.id); 279 } 280 } 281 282 class TriageAgent extends BaseAgent { 283 constructor() { 284 super('triage', ['base.md']); 285 // Skip context loading in tests 286 this.context = 'Test context'; 287 this.contextMetadata = { context: 'Test context', sizeKB: 1, files: ['base.md'] }; 288 this.isInitialized = true; 289 } 290 async processTask(task) { 291 // Hand off to developer 292 await this.handoff(task.id, 'developer', 'Bug classified'); 293 await this.completeTask(task.id); 294 } 295 } 296 297 // Set up agents 298 const monitor = new MonitorAgent(); 299 // Skip initialize() - already done in constructor 300 301 // Mock getAgentClass to return appropriate agent 302 const getAgentClass = async agentName => { 303 if (agentName === 'triage') return TriageAgent; 304 if (agentName === 'developer') return TestAgent; 305 if (agentName === 'qa') return TestAgent; 306 return TestAgent; 307 }; 308 309 monitor.getAgentClass = getAgentClass; 310 311 // Create initial monitor task 312 const startTime = Date.now(); 313 314 const monitorTaskId = await monitor.createTask({ 315 task_type: 'analyze_logs', 316 assigned_to: 'monitor', 317 priority: 7, 318 }); 319 320 // Process the workflow chain 321 await monitor.pollTasks(1); 322 323 const duration = Date.now() - startTime; 324 325 // Workflow should complete in < 2 minutes (120,000ms) 326 // With immediate invocation, should be < 5 seconds 327 assert.ok(duration < 120000, `Workflow took ${duration}ms, expected < 120000ms`); 328 assert.ok( 329 duration < 5000, 330 `Workflow took ${duration}ms, expected < 5000ms with immediate invocation` 331 ); 332 }); 333 334 test('invocation depth increments and resets correctly', async () => { 335 const agent = new TestAgent(); 336 // Skip initialize() - already done in constructor 337 338 // Mock getAgentClass 339 agent.getAgentClass = async () => TestAgent; 340 341 // Verify initial depth is 0 342 assert.equal( 343 process.env.AGENT_INVOCATION_DEPTH, 344 undefined, 345 'Initial depth should be undefined' 346 ); 347 348 // Create task (triggers immediate invocation) 349 await agent.createTask({ 350 task_type: 'test_task', 351 assigned_to: 'developer', 352 priority: 5, 353 }); 354 355 // After invocation completes, depth should be reset 356 assert.equal( 357 process.env.AGENT_INVOCATION_DEPTH, 358 undefined, 359 'Depth should be reset after invocation' 360 ); 361 }); 362 });