context-builder-coverage2.test.js
1 /** 2 * Context Builder Coverage 2 Tests 3 * 4 * Targets the remaining uncovered lines in src/agents/utils/context-builder.js: 5 * - Lines 27-28: catch block in resetDb() when db.close() throws an error 6 * 7 * All other paths are covered by context-builder.test.js and 8 * context-builder-supplement.test.js. 9 */ 10 11 import { test, describe, mock } from 'node:test'; 12 import assert from 'node:assert/strict'; 13 import Database from 'better-sqlite3'; 14 import fs from 'fs/promises'; 15 import path from 'path'; 16 import { fileURLToPath } from 'url'; 17 18 const __filename = fileURLToPath(import.meta.url); 19 const __dirname = path.dirname(__filename); 20 21 const dbPath = path.join(__dirname, '..', 'test-ctx-builder-cov2.db'); 22 23 async function initDb() { 24 try { 25 await fs.unlink(dbPath); 26 } catch (_) { 27 /* ignore */ 28 } 29 30 const db = new Database(dbPath); 31 db.pragma('foreign_keys = ON'); 32 33 const migrationsDir = path.join(__dirname, '..', '..', 'db', 'migrations'); 34 const migrations = [ 35 '047-create-agent-system.sql', 36 '052-create-agent-llm-usage.sql', 37 '053-create-agent-outcomes.sql', 38 ]; 39 40 for (const f of migrations) { 41 try { 42 const sql = await fs.readFile(path.join(migrationsDir, f), 'utf8'); 43 db.exec(sql); 44 } catch (_) { 45 /* ignore missing files */ 46 } 47 } 48 49 db.close(); 50 } 51 52 async function cleanup() { 53 try { 54 await fs.unlink(dbPath); 55 } catch (_) { 56 /* ignore */ 57 } 58 } 59 60 // ── resetDb error-swallowing catch path ────────────────────────────────────── 61 // 62 // context-builder.js lines 24-28: 63 // try { 64 // db.close(); <- line 25 65 // } catch (e) { 66 // // Ignore errors <- line 27 (the catch binding + comment are the uncovered lines) 67 // } 68 // 69 // We need db.close() to throw. The only reliable way without rewriting the module 70 // is to trigger it indirectly. The better-sqlite3 Database.close() can throw when 71 // called on an already-closed connection. We exercise this by: 72 // 1. Initialising the DB via buildAgentContext 73 // 2. Manually grabbing the open DB handle and closing it before resetDb() runs 74 // — but since `db` is module-private we cannot reference it directly. 75 // 76 // Alternative: use mock.module to replace better-sqlite3 with a version whose 77 // constructor returns an object whose close() throws. 78 // 79 // Note: mock.module() in Node.js native test runner rewires future requires, not 80 // already-imported instances. Because context-builder.js lazy-initialises `db` 81 // we can reset it (via the exported resetDb()), then re-initialise under the mock. 82 // ───────────────────────────────────────────────────────────────────────────── 83 84 describe('Context Builder - resetDb catch block (line 27-28)', () => { 85 test('resetDb does not throw when db.close() raises an error', async () => { 86 // We use mock.module to replace better-sqlite3 so that the database's 87 // close() method throws. The mock must be registered *before* the module 88 // that uses it is imported (or, in this case, before its lazy `db` var is 89 // populated). We achieve this by: 90 // 1. Registering the mock. 91 // 2. Calling resetDb() to ensure the module's `db` var is null. 92 // 3. Importing a fresh dynamic import so the lazy init runs under the mock. 93 // 94 // However, because context-builder is already imported at the top of the 95 // broader test run, we need to use the mock *within* the module's own cache. 96 // The pragmatic approach: import resetDb and buildAgentContext dynamically 97 // after registering the mock so that when buildAgentContext calls 98 // `new Database(...)` it gets our throwing stub. 99 100 await initDb(); 101 102 // Register the mock for better-sqlite3 before the module is loaded. 103 // The mock replaces the module with a factory whose Database constructor 104 // returns an object with .pragma(), .prepare(), .close() methods. 105 // close() is set to throw an error on the first call. 106 let closeCallCount = 0; 107 const fakeDb = { 108 pragma: () => {}, 109 prepare: () => ({ 110 all: () => [], 111 run: () => {}, 112 }), 113 close: () => { 114 closeCallCount++; 115 throw new Error('Simulated close error'); 116 }, 117 }; 118 119 mock.module('better-sqlite3', { 120 defaultExport: class FakeDatabase { 121 constructor() { 122 return fakeDb; 123 } 124 }, 125 }); 126 127 // Dynamically import the module after the mock is registered. 128 // Use a cache-busting query param so Node loads a fresh instance. 129 const { buildAgentContext, resetDb, clearCache } = 130 await import('../../src/agents/utils/context-builder.js'); 131 132 process.env.DATABASE_PATH = dbPath; 133 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 134 process.env.AGENT_ENABLE_TASK_HISTORY = 'false'; // skip DB queries 135 136 try { 137 // Call buildAgentContext to trigger lazy db init (with TASK_HISTORY off 138 // the DB init still happens inside getDb() if another code path calls it, 139 // but with history disabled it is skipped). Instead we call resetDb() 140 // after manually triggering lazy init. 141 142 // With history disabled, getDb() is never called in buildAgentContext. 143 // We need history enabled to trigger getDb(): 144 delete process.env.AGENT_ENABLE_TASK_HISTORY; 145 146 // Build context: this calls getDb() which calls new Database(...) -> our mock 147 await buildAgentContext('developer', ['base.md']); 148 149 // Now db is initialised (to our fakeDb). Call resetDb() which calls 150 // fakeDb.close() which throws — the catch block at lines 27-28 should 151 // swallow the error. 152 assert.doesNotThrow(() => resetDb(), 'resetDb should not throw when close() errors'); 153 154 // db is now null; calling resetDb() again exercises the `if (db)` guard 155 assert.doesNotThrow(() => resetDb(), 'second resetDb() call should be a no-op'); 156 } finally { 157 delete process.env.DATABASE_PATH; 158 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 159 delete process.env.AGENT_ENABLE_TASK_HISTORY; 160 clearCache(); 161 mock.restoreAll(); 162 await cleanup(); 163 } 164 }); 165 166 test('resetDb is a no-op when db has never been initialised', async () => { 167 // Import the real module (or the already-imported one) and call resetDb 168 // when internal db is null. 169 const { resetDb, clearCache } = await import('../../src/agents/utils/context-builder.js'); 170 171 // Ensure db is null 172 resetDb(); 173 174 // Calling again when already null should be fine 175 assert.doesNotThrow(() => resetDb(), 'resetDb when db=null should not throw'); 176 clearCache(); 177 }); 178 179 test('clearCache removes all cached entries', async () => { 180 await initDb(); 181 process.env.DATABASE_PATH = dbPath; 182 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 183 184 const { 185 buildAgentContext, 186 clearCache, 187 resetDb: rd, 188 } = await import('../../src/agents/utils/context-builder.js'); 189 190 try { 191 clearCache(); // start clean 192 const ctx1 = await buildAgentContext('developer', ['base.md']); 193 assert.ok(ctx1.fullContext, 'Should build context successfully'); 194 195 // Second call should use cache 196 const ctx2 = await buildAgentContext('developer', ['base.md']); 197 assert.equal( 198 ctx1.metadata.historyStats.recentSuccesses, 199 ctx2.metadata.historyStats.recentSuccesses, 200 'Cached result should match' 201 ); 202 203 clearCache(); 204 // After clear, a new call should still work 205 const ctx3 = await buildAgentContext('developer', ['base.md']); 206 assert.ok(ctx3.fullContext, 'Should rebuild context after cache clear'); 207 } finally { 208 delete process.env.DATABASE_PATH; 209 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 210 rd(); 211 clearCache(); 212 await cleanup(); 213 } 214 }); 215 216 test('buildAgentContext with AGENT_ENABLE_TASK_HISTORY=false returns correct shape', async () => { 217 await initDb(); 218 process.env.DATABASE_PATH = dbPath; 219 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 220 process.env.AGENT_ENABLE_TASK_HISTORY = 'false'; 221 222 const { 223 buildAgentContext, 224 clearCache, 225 resetDb: rd, 226 } = await import('../../src/agents/utils/context-builder.js'); 227 228 try { 229 clearCache(); 230 const ctx = await buildAgentContext('developer', ['base.md']); 231 232 assert.equal(ctx.historyContext, null, 'historyContext should be null'); 233 assert.equal(ctx.historyTokens, 0, 'historyTokens should be 0'); 234 assert.equal(ctx.fullContext, ctx.baseContext, 'fullContext should equal baseContext'); 235 assert.ok(ctx.totalTokens > 0, 'totalTokens should be positive'); 236 assert.ok(ctx.metadata, 'metadata should be present'); 237 } finally { 238 delete process.env.DATABASE_PATH; 239 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 240 delete process.env.AGENT_ENABLE_TASK_HISTORY; 241 rd(); 242 clearCache(); 243 await cleanup(); 244 } 245 }); 246 247 test('buildAgentContext historyContext includes no-data message for empty DB', async () => { 248 await initDb(); 249 process.env.DATABASE_PATH = dbPath; 250 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 251 252 const { 253 buildAgentContext, 254 clearCache, 255 resetDb: rd, 256 } = await import('../../src/agents/utils/context-builder.js'); 257 258 try { 259 clearCache(); 260 const ctx = await buildAgentContext('developer', ['base.md']); 261 262 assert.ok( 263 ctx.historyContext.includes('No historical task data'), 264 'Should show no-data message for empty DB' 265 ); 266 assert.equal(ctx.metadata.historyStats.recentSuccesses, 0); 267 assert.equal(ctx.metadata.historyStats.recentFailures, 0); 268 assert.equal(ctx.metadata.historyStats.relatedTasks, 0); 269 } finally { 270 delete process.env.DATABASE_PATH; 271 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 272 rd(); 273 clearCache(); 274 await cleanup(); 275 } 276 }); 277 278 test('estimateTokens returns 0 for null or empty text', async () => { 279 // This is tested indirectly via buildAgentContext with history=false 280 await initDb(); 281 process.env.DATABASE_PATH = dbPath; 282 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 283 process.env.AGENT_ENABLE_TASK_HISTORY = 'false'; 284 285 const { 286 buildAgentContext, 287 clearCache, 288 resetDb: rd, 289 } = await import('../../src/agents/utils/context-builder.js'); 290 291 try { 292 clearCache(); 293 const ctx = await buildAgentContext('developer', ['base.md']); 294 // historyTokens should be 0 because historyContext is null 295 assert.equal(ctx.historyTokens, 0, 'historyTokens should be 0 for null historyContext'); 296 } finally { 297 delete process.env.DATABASE_PATH; 298 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 299 delete process.env.AGENT_ENABLE_TASK_HISTORY; 300 rd(); 301 clearCache(); 302 await cleanup(); 303 } 304 }); 305 306 test('buildAgentContext with related task only having error_type (no file)', async () => { 307 await initDb(); 308 process.env.DATABASE_PATH = dbPath; 309 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 310 311 const { 312 buildAgentContext, 313 clearCache, 314 resetDb: rd, 315 } = await import('../../src/agents/utils/context-builder.js'); 316 317 // Insert a task that has only error_type, no file path 318 const db = new Database(dbPath); 319 db.prepare( 320 `INSERT INTO agent_tasks (task_type, assigned_to, status, context_json, completed_at) 321 VALUES (?, ?, ?, ?, datetime('now'))` 322 ).run('fix_bug', 'developer', 'completed', JSON.stringify({ error_type: 'timeout_err' })); 323 db.close(); 324 325 try { 326 clearCache(); 327 const ctx = await buildAgentContext('developer', ['base.md'], { 328 context_json: { error_type: 'timeout_err' }, 329 }); 330 assert.ok(ctx.historyContext, 'Should build context'); 331 assert.ok(ctx.metadata.historyStats.relatedTasks >= 0); 332 } finally { 333 delete process.env.DATABASE_PATH; 334 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 335 rd(); 336 clearCache(); 337 await cleanup(); 338 } 339 }); 340 341 test('formatTaskHistory produces sections header always', async () => { 342 await initDb(); 343 process.env.DATABASE_PATH = dbPath; 344 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 345 346 const { 347 buildAgentContext, 348 clearCache, 349 resetDb: rd, 350 } = await import('../../src/agents/utils/context-builder.js'); 351 352 try { 353 clearCache(); 354 const ctx = await buildAgentContext('developer', ['base.md']); 355 assert.ok( 356 ctx.historyContext.includes('## Task History'), 357 'History context should have Task History header' 358 ); 359 assert.ok( 360 ctx.historyContext.includes('Learning Context'), 361 'History context should mention Learning Context' 362 ); 363 } finally { 364 delete process.env.DATABASE_PATH; 365 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 366 rd(); 367 clearCache(); 368 await cleanup(); 369 } 370 }); 371 372 test('fullContext concatenates base and history correctly', async () => { 373 await initDb(); 374 process.env.DATABASE_PATH = dbPath; 375 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 376 377 const { 378 buildAgentContext, 379 clearCache, 380 resetDb: rd, 381 } = await import('../../src/agents/utils/context-builder.js'); 382 383 try { 384 clearCache(); 385 const ctx = await buildAgentContext('developer', ['base.md']); 386 // fullContext = baseContext + '\n\n' + historyContext 387 assert.ok( 388 ctx.fullContext.includes(ctx.baseContext), 389 'fullContext should contain baseContext' 390 ); 391 assert.ok( 392 ctx.fullContext.includes(ctx.historyContext), 393 'fullContext should contain historyContext' 394 ); 395 assert.ok( 396 ctx.fullContext.length > ctx.baseContext.length, 397 'fullContext should be longer than baseContext' 398 ); 399 } finally { 400 delete process.env.DATABASE_PATH; 401 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 402 rd(); 403 clearCache(); 404 await cleanup(); 405 } 406 }); 407 408 test('totalTokens equals ceil(fullContext.length / 4)', async () => { 409 await initDb(); 410 process.env.DATABASE_PATH = dbPath; 411 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 412 413 const { 414 buildAgentContext, 415 clearCache, 416 resetDb: rd, 417 } = await import('../../src/agents/utils/context-builder.js'); 418 419 try { 420 clearCache(); 421 const ctx = await buildAgentContext('developer', ['base.md']); 422 const expected = Math.ceil(ctx.fullContext.length / 4); 423 assert.equal(ctx.totalTokens, expected, 'totalTokens should match estimateTokens formula'); 424 } finally { 425 delete process.env.DATABASE_PATH; 426 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 427 rd(); 428 clearCache(); 429 await cleanup(); 430 } 431 }); 432 433 test('metadata includes historyStats with correct shape', async () => { 434 await initDb(); 435 process.env.DATABASE_PATH = dbPath; 436 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 437 438 const { 439 buildAgentContext, 440 clearCache, 441 resetDb: rd, 442 } = await import('../../src/agents/utils/context-builder.js'); 443 444 try { 445 clearCache(); 446 const ctx = await buildAgentContext('qa', ['base.md']); 447 assert.ok(ctx.metadata, 'metadata should exist'); 448 assert.ok(ctx.metadata.historyStats, 'historyStats should be in metadata'); 449 assert.equal(typeof ctx.metadata.historyStats.recentSuccesses, 'number'); 450 assert.equal(typeof ctx.metadata.historyStats.recentFailures, 'number'); 451 assert.equal(typeof ctx.metadata.historyStats.relatedTasks, 'number'); 452 } finally { 453 delete process.env.DATABASE_PATH; 454 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 455 rd(); 456 clearCache(); 457 await cleanup(); 458 } 459 }); 460 461 test('buildAgentContext with null currentTask has 0 relatedTasks', async () => { 462 await initDb(); 463 process.env.DATABASE_PATH = dbPath; 464 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 465 466 const { 467 buildAgentContext, 468 clearCache, 469 resetDb: rd, 470 } = await import('../../src/agents/utils/context-builder.js'); 471 472 try { 473 clearCache(); 474 const ctx = await buildAgentContext('developer', ['base.md'], null); 475 assert.equal( 476 ctx.metadata.historyStats.relatedTasks, 477 0, 478 'No related tasks for null currentTask' 479 ); 480 } finally { 481 delete process.env.DATABASE_PATH; 482 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 483 rd(); 484 clearCache(); 485 await cleanup(); 486 } 487 }); 488 489 test('buildAgentContext with currentTask missing context_json has 0 relatedTasks', async () => { 490 await initDb(); 491 process.env.DATABASE_PATH = dbPath; 492 process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; 493 494 const { 495 buildAgentContext, 496 clearCache, 497 resetDb: rd, 498 } = await import('../../src/agents/utils/context-builder.js'); 499 500 try { 501 clearCache(); 502 const ctx = await buildAgentContext('developer', ['base.md'], { context_json: null }); 503 assert.equal( 504 ctx.metadata.historyStats.relatedTasks, 505 0, 506 'No related tasks when context_json is null' 507 ); 508 } finally { 509 delete process.env.DATABASE_PATH; 510 delete process.env.AGENT_REALTIME_NOTIFICATIONS; 511 rd(); 512 clearCache(); 513 await cleanup(); 514 } 515 }); 516 });