context-builder.test.js
1 /** 2 * Tests for Context Builder 3 * 4 * Verifies task history integration for agent learning. 5 */ 6 7 import { test } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import Database from 'better-sqlite3'; 10 import fs from 'fs/promises'; 11 import path from 'path'; 12 import { fileURLToPath } from 'url'; 13 import { buildAgentContext, clearCache, resetDb } from '../../src/agents/utils/context-builder.js'; 14 import { 15 createAgentTask, 16 completeTask, 17 failTask, 18 resetDb as resetTaskManagerDb, 19 } from '../../src/agents/utils/task-manager.js'; 20 21 const __filename = fileURLToPath(import.meta.url); 22 const __dirname = path.dirname(__filename); 23 24 // Test database path 25 const testDbPath = path.join(__dirname, '..', 'test-context-builder.db'); 26 27 // Initialize test database using migration files (agent tables are NOT in schema.sql) 28 async function initTestDb() { 29 // Remove old test DB 30 try { 31 await fs.unlink(testDbPath); 32 } catch (e) { 33 // Ignore if doesn't exist 34 } 35 36 const db = new Database(testDbPath); 37 db.pragma('foreign_keys = ON'); 38 39 // Load tables from migration files (agent tables are not in schema.sql) 40 const migrationsDir = path.join(__dirname, '..', '..', 'db', 'migrations'); 41 const migrationFiles = [ 42 '047-create-agent-system.sql', 43 '052-create-agent-llm-usage.sql', 44 '053-create-agent-outcomes.sql', 45 ]; 46 47 for (const migFile of migrationFiles) { 48 try { 49 const sql = await fs.readFile(path.join(migrationsDir, migFile), 'utf8'); 50 db.exec(sql); 51 } catch (e) { 52 // Ignore missing migration files 53 } 54 } 55 56 db.close(); 57 } 58 59 // Cleanup test database 60 async function cleanupTestDb() { 61 try { 62 await fs.unlink(testDbPath); 63 } catch (e) { 64 // Ignore 65 } 66 } 67 68 test('Context Builder - buildAgentContext with no history', async t => { 69 await initTestDb(); 70 process.env.DATABASE_PATH = testDbPath; 71 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 72 73 try { 74 const context = await buildAgentContext('developer', ['base.md', 'developer.md']); 75 76 assert.ok(context.fullContext, 'Should have full context'); 77 assert.ok(context.baseContext, 'Should have base context'); 78 assert.ok(context.historyContext, 'Should have history context'); 79 assert.strictEqual(context.metadata.historyStats.recentSuccesses, 0, 'No successes yet'); 80 assert.strictEqual(context.metadata.historyStats.recentFailures, 0, 'No failures yet'); 81 assert.strictEqual(context.metadata.historyStats.relatedTasks, 0, 'No related tasks yet'); 82 assert.ok( 83 context.historyContext.includes('No historical task data available'), 84 'Should indicate no history' 85 ); 86 } finally { 87 delete process.env.DATABASE_PATH; 88 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 89 resetDb(); 90 resetTaskManagerDb(); 91 clearCache(); 92 await cleanupTestDb(); 93 } 94 }); 95 96 test('Context Builder - buildAgentContext with successful tasks', async t => { 97 await initTestDb(); 98 process.env.DATABASE_PATH = testDbPath; 99 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 100 101 try { 102 // Create and complete a successful task 103 const taskId = await createAgentTask({ 104 task_type: 'fix_bug', 105 assigned_to: 'developer', 106 context: { 107 error_type: 'null_pointer', 108 file_path: 'src/scoring.js', 109 }, 110 }); 111 112 completeTask(taskId, { 113 files_changed: ['src/scoring.js'], 114 approach: 'Added null check before accessing property', 115 }); 116 117 // Record outcome 118 const db = new Database(testDbPath); 119 db.prepare( 120 ` 121 INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, duration_ms, result_json) 122 VALUES (?, ?, ?, ?, ?, ?) 123 ` 124 ).run( 125 taskId, 126 'developer', 127 'fix_bug', 128 'success', 129 1500, 130 JSON.stringify({ approach: 'Added null check before accessing property' }) 131 ); 132 db.close(); 133 134 // Clear cache to force reload 135 clearCache(); 136 137 // Build context 138 const context = await buildAgentContext('developer', ['base.md', 'developer.md']); 139 140 assert.strictEqual(context.metadata.historyStats.recentSuccesses, 1, 'Should have 1 success'); 141 assert.ok( 142 context.historyContext.includes('Recent Successful Approaches'), 143 'Should include success section' 144 ); 145 assert.ok(context.historyContext.includes('fix_bug'), 'Should mention task type'); 146 assert.ok(context.historyContext.includes('src/scoring.js'), 'Should mention affected file'); 147 } finally { 148 delete process.env.DATABASE_PATH; 149 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 150 resetDb(); 151 resetTaskManagerDb(); 152 clearCache(); 153 await cleanupTestDb(); 154 } 155 }); 156 157 test('Context Builder - buildAgentContext with failed tasks', async t => { 158 await initTestDb(); 159 process.env.DATABASE_PATH = testDbPath; 160 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 161 162 try { 163 // Create and fail a task 164 const taskId = await createAgentTask({ 165 task_type: 'fix_bug', 166 assigned_to: 'developer', 167 context: { 168 error_type: 'api_timeout', 169 file_path: 'src/outreach/sms.js', 170 }, 171 }); 172 173 failTask(taskId, 'Twilio API timeout after 30 seconds'); 174 175 // Record outcome 176 const db = new Database(testDbPath); 177 db.prepare( 178 ` 179 INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, duration_ms, context_json) 180 VALUES (?, ?, ?, ?, ?, ?) 181 ` 182 ).run( 183 taskId, 184 'developer', 185 'fix_bug', 186 'failure', 187 30000, 188 JSON.stringify({ 189 error_type: 'api_timeout', 190 error: 'Twilio API timeout after 30 seconds', 191 }) 192 ); 193 db.close(); 194 195 // Clear cache 196 clearCache(); 197 198 // Build context 199 const context = await buildAgentContext('developer', ['base.md', 'developer.md']); 200 201 assert.strictEqual(context.metadata.historyStats.recentFailures, 1, 'Should have 1 failure'); 202 assert.ok( 203 context.historyContext.includes('Past Failures to Avoid'), 204 'Should include failure section' 205 ); 206 assert.ok(context.historyContext.includes('api_timeout'), 'Should mention error type'); 207 } finally { 208 delete process.env.DATABASE_PATH; 209 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 210 resetDb(); 211 resetTaskManagerDb(); 212 clearCache(); 213 await cleanupTestDb(); 214 } 215 }); 216 217 test('Context Builder - buildAgentContext with related tasks', async t => { 218 await initTestDb(); 219 process.env.DATABASE_PATH = testDbPath; 220 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 221 222 try { 223 // Create a successful task for file X 224 const task1 = await createAgentTask({ 225 task_type: 'fix_bug', 226 assigned_to: 'developer', 227 context: { 228 error_type: 'null_pointer', 229 file_path: 'src/capture.js', 230 }, 231 }); 232 233 completeTask(task1, { 234 files_changed: ['src/capture.js'], 235 approach: 'Added null check', 236 }); 237 238 // Record outcome 239 const db = new Database(testDbPath); 240 db.prepare( 241 ` 242 INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, result_json) 243 VALUES (?, ?, ?, ?, ?) 244 ` 245 ).run( 246 task1, 247 'developer', 248 'fix_bug', 249 'success', 250 JSON.stringify({ approach: 'Added null check' }) 251 ); 252 db.close(); 253 254 // Clear cache 255 clearCache(); 256 257 // Build context with current task for same file 258 const currentTask = { 259 context_json: { 260 error_type: 'undefined_reference', 261 file_path: 'src/capture.js', 262 }, 263 }; 264 265 const context = await buildAgentContext('developer', ['base.md', 'developer.md'], currentTask); 266 267 assert.strictEqual(context.metadata.historyStats.relatedTasks, 1, 'Should have 1 related task'); 268 assert.ok( 269 context.historyContext.includes('Related Tasks'), 270 'Should include related tasks section' 271 ); 272 assert.ok(context.historyContext.includes('src/capture.js'), 'Should mention the related file'); 273 } finally { 274 delete process.env.DATABASE_PATH; 275 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 276 resetDb(); 277 resetTaskManagerDb(); 278 clearCache(); 279 await cleanupTestDb(); 280 } 281 }); 282 283 test('Context Builder - caching behavior', async t => { 284 await initTestDb(); 285 process.env.DATABASE_PATH = testDbPath; 286 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 287 288 try { 289 // Create a task 290 const taskId = await createAgentTask({ 291 task_type: 'fix_bug', 292 assigned_to: 'developer', 293 }); 294 295 completeTask(taskId, { files_changed: ['test.js'] }); 296 297 // First call - should query DB 298 const context1 = await buildAgentContext('developer', ['base.md', 'developer.md']); 299 300 // Second call - should use cache 301 const context2 = await buildAgentContext('developer', ['base.md', 'developer.md']); 302 303 assert.strictEqual( 304 context1.metadata.historyStats.recentSuccesses, 305 context2.metadata.historyStats.recentSuccesses, 306 'Cached results should match' 307 ); 308 309 // Clear cache 310 clearCache(); 311 312 // Third call - should re-query DB 313 const context3 = await buildAgentContext('developer', ['base.md', 'developer.md']); 314 315 assert.strictEqual( 316 context1.metadata.historyStats.recentSuccesses, 317 context3.metadata.historyStats.recentSuccesses, 318 'Results after cache clear should still match' 319 ); 320 } finally { 321 delete process.env.DATABASE_PATH; 322 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 323 resetDb(); 324 resetTaskManagerDb(); 325 clearCache(); 326 await cleanupTestDb(); 327 } 328 }); 329 330 test('Context Builder - disabled via env var', async t => { 331 await initTestDb(); 332 process.env.DATABASE_PATH = testDbPath; 333 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 334 process.env.AGENT_ENABLE_TASK_HISTORY = 'false'; 335 336 try { 337 const context = await buildAgentContext('developer', ['base.md', 'developer.md']); 338 339 assert.strictEqual(context.historyContext, null, 'History should be null when disabled'); 340 assert.strictEqual(context.historyTokens, 0, 'History tokens should be 0'); 341 assert.ok( 342 context.fullContext === context.baseContext, 343 'Full context should equal base context' 344 ); 345 } finally { 346 delete process.env.DATABASE_PATH; 347 delete process.env.AGENT_ENABLE_TASK_HISTORY; 348 resetDb(); 349 resetTaskManagerDb(); 350 clearCache(); 351 await cleanupTestDb(); 352 } 353 }); 354 355 test('Context Builder - token estimation', async t => { 356 await initTestDb(); 357 process.env.DATABASE_PATH = testDbPath; 358 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 359 360 try { 361 const context = await buildAgentContext('developer', ['base.md', 'developer.md']); 362 363 assert.ok(context.totalTokens > 0, 'Should estimate total tokens'); 364 assert.ok(context.historyTokens >= 0, 'Should estimate history tokens'); 365 assert.ok( 366 context.totalTokens >= context.historyTokens, 367 'Total tokens should be >= history tokens' 368 ); 369 } finally { 370 delete process.env.DATABASE_PATH; 371 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 372 resetDb(); 373 resetTaskManagerDb(); 374 clearCache(); 375 await cleanupTestDb(); 376 } 377 }); 378 379 test('Context Builder - mixed success/failure history', async t => { 380 await initTestDb(); 381 process.env.DATABASE_PATH = testDbPath; 382 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 383 384 try { 385 // Create 3 successful tasks 386 for (let i = 0; i < 3; i++) { 387 const taskId = await createAgentTask({ 388 task_type: 'fix_bug', 389 assigned_to: 'developer', 390 }); 391 completeTask(taskId, { approach: `Fix ${i}` }); 392 393 const db = new Database(testDbPath); 394 db.prepare( 395 ` 396 INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome) 397 VALUES (?, ?, ?, ?) 398 ` 399 ).run(taskId, 'developer', 'fix_bug', 'success'); 400 db.close(); 401 } 402 403 // Create 2 failed tasks 404 for (let i = 0; i < 2; i++) { 405 const taskId = await createAgentTask({ 406 task_type: 'fix_bug', 407 assigned_to: 'developer', 408 }); 409 failTask(taskId, `Error ${i}`); 410 411 const db = new Database(testDbPath); 412 db.prepare( 413 ` 414 INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome) 415 VALUES (?, ?, ?, ?) 416 ` 417 ).run(taskId, 'developer', 'fix_bug', 'failure'); 418 db.close(); 419 } 420 421 // Clear cache 422 clearCache(); 423 424 // Build context 425 const context = await buildAgentContext('developer', ['base.md', 'developer.md']); 426 427 assert.strictEqual(context.metadata.historyStats.recentSuccesses, 3, 'Should have 3 successes'); 428 assert.strictEqual(context.metadata.historyStats.recentFailures, 2, 'Should have 2 failures'); 429 assert.ok( 430 context.historyContext.includes('Recent Successful Approaches'), 431 'Should include successes' 432 ); 433 assert.ok(context.historyContext.includes('Past Failures to Avoid'), 'Should include failures'); 434 } finally { 435 delete process.env.DATABASE_PATH; 436 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 437 resetDb(); 438 resetTaskManagerDb(); 439 clearCache(); 440 await cleanupTestDb(); 441 } 442 }); 443 444 test('Context Builder - resetDb handles close error gracefully', async t => { 445 // resetDb should work when db is open and when db is null 446 await initTestDb(); 447 process.env.DATABASE_PATH = testDbPath; 448 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 449 450 try { 451 // First call triggers db initialization 452 await buildAgentContext('developer', ['base.md']); 453 454 // resetDb when db is open - exercises db.close() path (lines 24-28) 455 resetDb(); 456 // Second resetDb when db is null - exercises null guard 457 resetDb(); 458 assert.ok(true, 'resetDb completed without error both times'); 459 } finally { 460 delete process.env.DATABASE_PATH; 461 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 462 resetDb(); 463 resetTaskManagerDb(); 464 clearCache(); 465 await cleanupTestDb(); 466 } 467 }); 468 469 test('Context Builder - formatSuccessfulTask uses file_path when no files_changed', async t => { 470 await initTestDb(); 471 process.env.DATABASE_PATH = testDbPath; 472 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 473 474 try { 475 const taskId = await createAgentTask({ 476 task_type: 'write_tests', 477 assigned_to: 'developer', 478 context: { file_path: 'src/score.js' }, 479 }); 480 completeTask(taskId, { file_path: 'src/score.js', action_taken: 'Wrote unit tests' }); 481 482 // result_json has file_path (not files_changed) - covers lines 344-346 483 const db = new Database(testDbPath); 484 db.prepare( 485 `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, result_json) 486 VALUES (?, ?, ?, ?, ?)` 487 ).run( 488 taskId, 489 'developer', 490 'write_tests', 491 'success', 492 JSON.stringify({ file_path: 'src/score.js', action_taken: 'Wrote unit tests' }) 493 ); 494 db.close(); 495 496 clearCache(); 497 498 const context = await buildAgentContext('developer', ['base.md']); 499 assert.ok(context.historyContext.includes('src/score.js'), 'Should show file_path from result'); 500 assert.ok( 501 context.historyContext.includes('Wrote unit tests'), 502 'Should show action_taken as Approach' 503 ); 504 } finally { 505 delete process.env.DATABASE_PATH; 506 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 507 resetDb(); 508 resetTaskManagerDb(); 509 clearCache(); 510 await cleanupTestDb(); 511 } 512 }); 513 514 test('Context Builder - related task insight branches (what worked / what failed)', async t => { 515 await initTestDb(); 516 process.env.DATABASE_PATH = testDbPath; 517 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 518 519 try { 520 // Task completed with approach - covers extractRelatedInsight lines 395-397 521 const taskId1 = await createAgentTask({ 522 task_type: 'fix_bug', 523 assigned_to: 'qa', 524 context: { file_path: 'src/scrape.js', error_type: 'timeout' }, 525 }); 526 completeTask(taskId1, { approach: 'Increased timeout to 30 seconds' }); 527 528 const db = new Database(testDbPath); 529 db.prepare( 530 `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, result_json) 531 VALUES (?, ?, ?, ?, ?)` 532 ).run( 533 taskId1, 534 'qa', 535 'fix_bug', 536 'success', 537 JSON.stringify({ approach: 'Increased timeout to 30 seconds' }) 538 ); 539 540 // Failed task with context.error - covers extractRelatedInsight lines 398-400 541 const taskId2 = await createAgentTask({ 542 task_type: 'fix_bug', 543 assigned_to: 'qa', 544 context: { file_path: 'src/scrape.js', error_type: 'timeout', error: 'Connection refused' }, 545 }); 546 failTask(taskId2, 'Connection refused after retry'); 547 548 db.prepare( 549 `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, context_json) 550 VALUES (?, ?, ?, ?, ?)` 551 ).run( 552 taskId2, 553 'qa', 554 'fix_bug', 555 'failure', 556 JSON.stringify({ file_path: 'src/scrape.js', error: 'Connection refused' }) 557 ); 558 db.close(); 559 560 clearCache(); 561 562 const currentTask = { 563 context_json: { file_path: 'src/scrape.js', error_type: 'timeout' }, 564 }; 565 566 const context = await buildAgentContext('qa', ['base.md'], currentTask); 567 assert.ok(context.historyContext.includes('Related Tasks'), 'Should have related tasks'); 568 // insight branches should render content (lines 422-424) 569 assert.ok( 570 context.historyContext.includes('scrape') || context.historyContext.includes('timeout'), 571 'Should show related task details' 572 ); 573 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 574 } finally { 575 delete process.env.DATABASE_PATH; 576 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 577 resetDb(); 578 resetTaskManagerDb(); 579 clearCache(); 580 await cleanupTestDb(); 581 } 582 }); 583 584 test('Context Builder - extractFilePathFromContext from error_message and stack_trace', async t => { 585 await initTestDb(); 586 process.env.DATABASE_PATH = testDbPath; 587 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 588 589 try { 590 // Task context with error_message containing a JS file path - covers lines 454-457 591 const taskId1 = await createAgentTask({ 592 task_type: 'fix_bug', 593 assigned_to: 'developer', 594 context: { 595 error_type: 'parse_error', 596 error_message: 'SyntaxError at /home/user/code/src/capture.js:42:3', 597 }, 598 }); 599 completeTask(taskId1, { approach: 'Fixed syntax error' }); 600 601 // Task context with stack_trace containing a JS file path - covers lines 459-462 602 const taskId2 = await createAgentTask({ 603 task_type: 'fix_bug', 604 assigned_to: 'developer', 605 context: { 606 error_type: 'parse_error', 607 stack_trace: 'Error\n at Object.<anonymous> (/home/user/code/src/scrape.js:10:5)', 608 }, 609 }); 610 completeTask(taskId2, { approach: 'Fixed stack issue' }); 611 612 clearCache(); 613 614 const currentTask = { 615 context_json: { error_type: 'parse_error' }, 616 }; 617 618 const context = await buildAgentContext('developer', ['base.md'], currentTask); 619 assert.ok(context.historyContext, 'Should build context with error_message/stack_trace tasks'); 620 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 621 } finally { 622 delete process.env.DATABASE_PATH; 623 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 624 resetDb(); 625 resetTaskManagerDb(); 626 clearCache(); 627 await cleanupTestDb(); 628 } 629 }); 630 631 test('Context Builder - invalid JSON in task records is handled gracefully', async t => { 632 await initTestDb(); 633 process.env.DATABASE_PATH = testDbPath; 634 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 635 636 try { 637 // Insert tasks with malformed JSON directly to trigger tryParseJSON catch (lines 507-509) 638 const db = new Database(testDbPath); 639 db.prepare( 640 `INSERT INTO agent_tasks (task_type, assigned_to, status, context_json, completed_at) 641 VALUES (?, ?, ?, ?, datetime('now'))` 642 ).run('fix_bug', 'developer', 'completed', '{not valid json}'); 643 644 db.prepare( 645 `INSERT INTO agent_tasks (task_type, assigned_to, status, error_message, context_json, completed_at) 646 VALUES (?, ?, ?, ?, ?, datetime('now'))` 647 ).run('fix_bug', 'developer', 'failed', 'Something went wrong', '{bad json here}'); 648 db.close(); 649 650 clearCache(); 651 652 // Should not throw - tryParseJSON returns null on invalid JSON 653 const context = await buildAgentContext('developer', ['base.md']); 654 assert.ok(context.fullContext, 'Should build context even with malformed JSON in tasks'); 655 } finally { 656 delete process.env.DATABASE_PATH; 657 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 658 resetDb(); 659 resetTaskManagerDb(); 660 clearCache(); 661 await cleanupTestDb(); 662 } 663 }); 664 665 test('Context Builder - cache hit on second call (TTL not expired)', async t => { 666 await initTestDb(); 667 process.env.DATABASE_PATH = testDbPath; 668 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 669 670 try { 671 const taskId = await createAgentTask({ 672 task_type: 'fix_bug', 673 assigned_to: 'developer', 674 }); 675 completeTask(taskId, { files_changed: ['src/test.js'] }); 676 677 clearCache(); 678 679 // First call populates cache 680 const ctx1 = await buildAgentContext('developer', ['base.md']); 681 // Second call hits cache (covers getCached return path, lines 518-528 hit branch) 682 const ctx2 = await buildAgentContext('developer', ['base.md']); 683 684 assert.strictEqual( 685 ctx1.metadata.historyStats.recentSuccesses, 686 ctx2.metadata.historyStats.recentSuccesses, 687 'Cached result should match original' 688 ); 689 } finally { 690 delete process.env.DATABASE_PATH; 691 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 692 resetDb(); 693 resetTaskManagerDb(); 694 clearCache(); 695 await cleanupTestDb(); 696 } 697 }); 698 699 test('Context Builder - extractFilePathFromContext uses filePath field', async t => { 700 await initTestDb(); 701 process.env.DATABASE_PATH = testDbPath; 702 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 703 704 try { 705 // Create a task where context only has 'filePath' (camelCase) - not 'file_path' 706 // This exercises extractFilePathFromContext lines 442 and 448-450 707 const taskId = await createAgentTask({ 708 task_type: 'fix_bug', 709 assigned_to: 'developer', 710 context: { error_type: 'null_ref', filePath: 'src/enrich.js' }, 711 }); 712 completeTask(taskId, { approach: 'Added null guard' }); 713 714 clearCache(); 715 716 // Current task with only error_type (no file_path/file), but existing task has filePath 717 // getRelatedTasks will use error_type to match, then formatRelatedTask will find filePath 718 const currentTask = { 719 context_json: { error_type: 'null_ref' }, 720 }; 721 722 const context = await buildAgentContext('developer', ['base.md'], currentTask); 723 assert.ok(context.historyContext, 'Should build context'); 724 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 725 } finally { 726 delete process.env.DATABASE_PATH; 727 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 728 resetDb(); 729 resetTaskManagerDb(); 730 clearCache(); 731 await cleanupTestDb(); 732 } 733 }); 734 735 test('Context Builder - extractFilePathFromContext uses affected_file and files_changed', async t => { 736 await initTestDb(); 737 process.env.DATABASE_PATH = testDbPath; 738 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 739 740 try { 741 // Task with affected_file in context 742 const taskId1 = await createAgentTask({ 743 task_type: 'fix_bug', 744 assigned_to: 'developer', 745 context: { error_type: 'io_error', affected_file: 'src/capture.js' }, 746 }); 747 completeTask(taskId1, { approach: 'Fixed IO' }); 748 749 // Task with files_changed array in context 750 const taskId2 = await createAgentTask({ 751 task_type: 'fix_bug', 752 assigned_to: 'developer', 753 context: { error_type: 'io_error', files_changed: ['src/score.js', 'src/rescore.js'] }, 754 }); 755 completeTask(taskId2, { approach: 'Fixed scoring' }); 756 757 clearCache(); 758 759 const currentTask = { 760 context_json: { error_type: 'io_error' }, 761 }; 762 763 const context = await buildAgentContext('developer', ['base.md'], currentTask); 764 assert.ok(context.historyContext, 'Should build context with affected_file/files_changed'); 765 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 766 } finally { 767 delete process.env.DATABASE_PATH; 768 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 769 resetDb(); 770 resetTaskManagerDb(); 771 clearCache(); 772 await cleanupTestDb(); 773 } 774 }); 775 776 test('Context Builder - related task with failed status (outcome === failed branch)', async t => { 777 await initTestDb(); 778 process.env.DATABASE_PATH = testDbPath; 779 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 780 781 try { 782 // Insert a task directly with status='failed' so task.outcome is null, task.status is 'failed' 783 // This makes formatRelatedTask compute outcome='failed' (task.outcome || task.status) 784 // Then extractRelatedInsight checks outcome === 'failed' && context?.error -> lines 398-400 785 const db = new Database(testDbPath); 786 db.prepare( 787 `INSERT INTO agent_tasks (task_type, assigned_to, status, error_message, context_json, completed_at) 788 VALUES (?, ?, ?, ?, ?, datetime('now'))` 789 ).run( 790 'fix_bug', 791 'developer', 792 'failed', 793 'Network error', 794 JSON.stringify({ file_path: 'src/outreach.js', error_type: 'net_err', error: 'ECONNREFUSED' }) 795 ); 796 db.close(); 797 798 clearCache(); 799 800 // Current task with matching file_path and error_type to trigger related lookup 801 const currentTask = { 802 context_json: { file_path: 'src/outreach.js', error_type: 'net_err' }, 803 }; 804 805 const context = await buildAgentContext('developer', ['base.md'], currentTask); 806 assert.ok(context.historyContext, 'Should build context'); 807 // The insight branch for failed status with context.error should be exercised 808 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 809 } finally { 810 delete process.env.DATABASE_PATH; 811 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 812 resetDb(); 813 resetTaskManagerDb(); 814 clearCache(); 815 await cleanupTestDb(); 816 } 817 }); 818 819 test('Context Builder - extractFilePathFromContext in current task context (filePath camelCase)', async t => { 820 await initTestDb(); 821 process.env.DATABASE_PATH = testDbPath; 822 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 823 824 try { 825 // Insert a completed task so there's something to correlate against 826 const taskId = await createAgentTask({ 827 task_type: 'fix_bug', 828 assigned_to: 'developer', 829 context: { filePath: 'src/enrich.js', error_type: 'io' }, 830 }); 831 completeTask(taskId, { approach: 'Fixed' }); 832 833 clearCache(); 834 835 // Current task context has 'filePath' (camelCase), NOT 'file_path' or 'file' 836 // This causes extractFilePathFromContext to be called and find filePath at line 442/449-450 837 const currentTask = { 838 context_json: { filePath: 'src/enrich.js' }, 839 }; 840 841 const context = await buildAgentContext('developer', ['base.md'], currentTask); 842 assert.ok(context.historyContext, 'Should build context'); 843 // Related tasks should be found via filePath extraction 844 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 845 } finally { 846 delete process.env.DATABASE_PATH; 847 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 848 resetDb(); 849 resetTaskManagerDb(); 850 clearCache(); 851 await cleanupTestDb(); 852 } 853 }); 854 855 test('Context Builder - extractFilePathFromContext in current task context (affected_file)', async t => { 856 await initTestDb(); 857 process.env.DATABASE_PATH = testDbPath; 858 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 859 860 try { 861 const taskId = await createAgentTask({ 862 task_type: 'fix_bug', 863 assigned_to: 'developer', 864 context: { affected_file: 'src/score.js' }, 865 }); 866 completeTask(taskId, { approach: 'Fixed scorer' }); 867 868 clearCache(); 869 870 // Current task with affected_file (no file_path/file) -> extractFilePathFromContext at line 443/449-450 871 const currentTask = { 872 context_json: { affected_file: 'src/score.js' }, 873 }; 874 875 const context = await buildAgentContext('developer', ['base.md'], currentTask); 876 assert.ok(context.historyContext, 'Should build context'); 877 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 878 } finally { 879 delete process.env.DATABASE_PATH; 880 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 881 resetDb(); 882 resetTaskManagerDb(); 883 clearCache(); 884 await cleanupTestDb(); 885 } 886 }); 887 888 test('Context Builder - extractFilePathFromContext from current task error_message', async t => { 889 await initTestDb(); 890 process.env.DATABASE_PATH = testDbPath; 891 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 892 893 try { 894 // Insert a stored task with a matching path in context 895 const taskId = await createAgentTask({ 896 task_type: 'fix_bug', 897 assigned_to: 'developer', 898 context: { error_message: 'Error at /home/user/src/capture.js:42' }, 899 }); 900 completeTask(taskId, { approach: 'Fixed it' }); 901 902 clearCache(); 903 904 // Current task context has error_message with a JS path (no file_path/file/filePath etc.) 905 // This triggers extractFilePathFromContext to match via error_message regex (lines 454-457) 906 const currentTask = { 907 context_json: { error_message: 'Failed at /home/user/src/capture.js:10:3' }, 908 }; 909 910 const context = await buildAgentContext('developer', ['base.md'], currentTask); 911 assert.ok(context.historyContext, 'Should build context'); 912 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 913 } finally { 914 delete process.env.DATABASE_PATH; 915 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 916 resetDb(); 917 resetTaskManagerDb(); 918 clearCache(); 919 await cleanupTestDb(); 920 } 921 }); 922 923 test('Context Builder - extractFilePathFromContext from current task stack_trace', async t => { 924 await initTestDb(); 925 process.env.DATABASE_PATH = testDbPath; 926 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 927 928 try { 929 // Stored task that relates via file path 930 const taskId = await createAgentTask({ 931 task_type: 'fix_bug', 932 assigned_to: 'developer', 933 context: { stack_trace: 'at fn (/home/user/src/scrape.js:5:3)' }, 934 }); 935 completeTask(taskId, { approach: 'Fixed scrape' }); 936 937 clearCache(); 938 939 // Current task context has stack_trace with a JS path (no other file fields) 940 // This triggers lines 459-462 in extractFilePathFromContext 941 const currentTask = { 942 context_json: { stack_trace: 'Error\n at handler (/home/user/src/scrape.js:20:7)' }, 943 }; 944 945 const context = await buildAgentContext('developer', ['base.md'], currentTask); 946 assert.ok(context.historyContext, 'Should build context'); 947 assert.ok(typeof context.metadata.historyStats.relatedTasks === 'number'); 948 } finally { 949 delete process.env.DATABASE_PATH; 950 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 951 resetDb(); 952 resetTaskManagerDb(); 953 clearCache(); 954 await cleanupTestDb(); 955 } 956 }); 957 958 test('Context Builder - cache TTL expiry evicts stale entry (lines 524-526)', async t => { 959 // Use mock.timers to advance Date.now() past CACHE_TTL_MS (30 minutes). 960 // IMPORTANT: mock.timers.enable() must be called BEFORE the first buildAgentContext call 961 // so that the timestamp stored in the cache uses the mocked time base. 962 // After ticking 31 minutes, Date.now() returns a value > (timestamp + TTL), 963 // which triggers getCached lines 523-525 (delete + return null -> cache miss). 964 const { mock } = await import('node:test'); 965 966 await initTestDb(); 967 process.env.DATABASE_PATH = testDbPath; 968 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 969 970 // Enable Date mocking BEFORE making any calls so timestamps are consistent 971 mock.timers.enable({ apis: ['Date'] }); 972 973 try { 974 clearCache(); 975 976 // First call: cache entries are stored with mocked Date.now() as timestamp 977 const ctx1 = await buildAgentContext('developer', ['base.md']); 978 assert.ok(ctx1.fullContext, 'First call should succeed'); 979 980 // Advance time past 30-minute TTL (CACHE_TTL_MS = 30 * 60 * 1000) 981 mock.timers.tick(31 * 60 * 1000); // advance 31 minutes 982 983 // Second call: getCached finds entries with timestamp now > 31 minutes old 984 // -> Date.now() - timestamp > CACHE_TTL_MS is true 985 // -> lines 524-526 execute: taskHistoryCache.delete(key); return null; 986 // -> cache miss -> re-queries DB 987 const ctx2 = await buildAgentContext('developer', ['base.md']); 988 assert.ok(ctx2.fullContext, 'Second call after TTL expiry should succeed'); 989 assert.ok( 990 ctx2.metadata.historyStats.recentSuccesses >= 0, 991 'Should have valid stats after cache eviction' 992 ); 993 } finally { 994 mock.timers.reset(); 995 delete process.env.DATABASE_PATH; 996 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 997 resetDb(); 998 resetTaskManagerDb(); 999 clearCache(); 1000 await cleanupTestDb(); 1001 } 1002 }); 1003 1004 test('Context Builder - formatFailedTask returns null when no error_message (line 366)', async t => { 1005 await initTestDb(); 1006 process.env.DATABASE_PATH = testDbPath; 1007 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 1008 1009 try { 1010 // Insert a task with status='failed' but NO error_message 1011 // This makes formatFailedTask return null (line 366: if (!task.error_message) return null) 1012 const db = new Database(testDbPath); 1013 db.prepare( 1014 `INSERT INTO agent_tasks (task_type, assigned_to, status, error_message, context_json, completed_at) 1015 VALUES (?, ?, ?, ?, ?, datetime('now'))` 1016 ).run('fix_bug', 'developer', 'failed', null, JSON.stringify({ file_path: 'src/test.js' })); 1017 db.close(); 1018 1019 clearCache(); 1020 1021 // buildAgentContext fetches failed tasks; formatFailedTask is called with this record 1022 // Since error_message is null, formatFailedTask returns null -> filtered out from display 1023 const context = await buildAgentContext('developer', ['base.md']); 1024 assert.ok(context.fullContext, 'Should build context even with null error_message task'); 1025 // The failed task with null error_message should not appear in history context 1026 // (formatFailedTask returns null -> filtered from list -> recentFailures stays 0 or 1027 // the task shows as failed but has no message to format) 1028 assert.ok(typeof context.metadata.historyStats.recentFailures === 'number'); 1029 } finally { 1030 delete process.env.DATABASE_PATH; 1031 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 1032 resetDb(); 1033 resetTaskManagerDb(); 1034 clearCache(); 1035 await cleanupTestDb(); 1036 } 1037 }); 1038 1039 test('Context Builder - getRelatedTasks with currentTask having null context_json (line 203)', async t => { 1040 await initTestDb(); 1041 process.env.DATABASE_PATH = testDbPath; 1042 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 1043 1044 try { 1045 clearCache(); 1046 1047 // currentTask with null context_json triggers early return at line 203 1048 const currentTask = { context_json: null }; 1049 const context = await buildAgentContext('developer', ['base.md'], currentTask); 1050 assert.ok(context.fullContext, 'Should build context with null context_json task'); 1051 assert.strictEqual( 1052 context.metadata.historyStats.relatedTasks, 1053 0, 1054 'Should have 0 related tasks when context_json is null' 1055 ); 1056 } finally { 1057 delete process.env.DATABASE_PATH; 1058 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 1059 resetDb(); 1060 resetTaskManagerDb(); 1061 clearCache(); 1062 await cleanupTestDb(); 1063 } 1064 }); 1065 1066 test('Context Builder - getRelatedTasks returns [] when no filePath and no errorType (line 209)', async t => { 1067 await initTestDb(); 1068 process.env.DATABASE_PATH = testDbPath; 1069 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 1070 1071 try { 1072 clearCache(); 1073 1074 // currentTask has context_json with no file_path, file, filePath, affected_file, 1075 // files_changed, error_message, stack_trace, or error_type fields. 1076 // extractFilePathFromContext returns null, errorType is undefined -> both falsy 1077 // -> getRelatedTasks returns [] at line 209 1078 const currentTask = { context_json: { some_other_field: 'value' } }; 1079 const context = await buildAgentContext('developer', ['base.md'], currentTask); 1080 assert.ok(context.fullContext, 'Should build context when no path/error_type'); 1081 assert.strictEqual( 1082 context.metadata.historyStats.relatedTasks, 1083 0, 1084 'Should have 0 related tasks when no file path or error type' 1085 ); 1086 } finally { 1087 delete process.env.DATABASE_PATH; 1088 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 1089 resetDb(); 1090 resetTaskManagerDb(); 1091 clearCache(); 1092 await cleanupTestDb(); 1093 } 1094 }); 1095 1096 test('Context Builder - formatFailedTask with context having only file (not file_path) (line 382)', async t => { 1097 await initTestDb(); 1098 process.env.DATABASE_PATH = testDbPath; 1099 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 1100 1101 try { 1102 // Insert a failed task where context uses 'file' key (not 'file_path') 1103 // This exercises the `context.file_path || context.file` branch at line 382 1104 const db = new Database(testDbPath); 1105 db.prepare( 1106 `INSERT INTO agent_tasks (task_type, assigned_to, status, error_message, context_json, completed_at) 1107 VALUES (?, ?, ?, ?, ?, datetime('now'))` 1108 ).run( 1109 'fix_bug', 1110 'developer', 1111 'failed', 1112 'Operation failed due to network error', 1113 JSON.stringify({ file: 'src/outreach/sms.js', error_type: 'network' }) 1114 ); 1115 db.close(); 1116 1117 clearCache(); 1118 1119 const context = await buildAgentContext('developer', ['base.md']); 1120 assert.ok(context.fullContext, 'Should build context'); 1121 // The failed task uses 'file' key -> line 382 branch `context.file_path || context.file` 1122 // evaluates to context.file -> 'src/outreach/sms.js' -> adds File line to output 1123 assert.ok(typeof context.metadata.historyStats.recentFailures === 'number'); 1124 } finally { 1125 delete process.env.DATABASE_PATH; 1126 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 1127 resetDb(); 1128 resetTaskManagerDb(); 1129 clearCache(); 1130 await cleanupTestDb(); 1131 } 1132 });