message-manager.test.js
1 /** 2 * Message Manager — additional branch coverage tests 3 * 4 * Targets uncovered lines/branches in src/agents/utils/message-manager.js: 5 * - resetDb() catch block (lines 383-384): db.close() throwing an error 6 * - getConversationThread: answer without in_reply_to metadata goes to top-level 7 * - getMessageStats with custom hours parameter edge cases 8 * - sendAnswer with and without questionMessageId (metadata null branch) 9 * - getUnreadMessages metadata_json null branch 10 */ 11 12 import { test, describe } from 'node:test'; 13 import assert from 'node:assert/strict'; 14 import Database from 'better-sqlite3'; 15 import { mkdtempSync, rmSync } from 'fs'; 16 import { tmpdir } from 'os'; 17 import { join } from 'path'; 18 import { 19 sendAgentMessage, 20 getUnreadMessages, 21 markMessageRead, 22 getTaskMessages, 23 sendQuestion, 24 sendAnswer, 25 sendHandoff, 26 sendNotification, 27 getConversationThread, 28 hasPendingQuestions, 29 getMessageStats, 30 resetDb, 31 } from '../../../src/agents/utils/message-manager.js'; 32 33 let testDir; 34 let dbPath; 35 let db; 36 37 function initTestDb() { 38 resetDb(); // ensure previous connection is closed 39 testDir = mkdtempSync(join(tmpdir(), 'msg-utils-test-')); 40 dbPath = join(testDir, 'test.db'); 41 process.env.DATABASE_PATH = dbPath; 42 43 db = new Database(dbPath); 44 db.pragma('foreign_keys = ON'); 45 46 db.exec(` 47 CREATE TABLE agent_tasks ( 48 id INTEGER PRIMARY KEY AUTOINCREMENT, 49 task_type TEXT NOT NULL, 50 assigned_to TEXT NOT NULL, 51 created_by TEXT, 52 status TEXT DEFAULT 'pending', 53 priority INTEGER DEFAULT 5, 54 context_json TEXT, 55 result_json TEXT, 56 parent_task_id INTEGER, 57 error_message TEXT, 58 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 59 started_at DATETIME, 60 completed_at DATETIME, 61 retry_count INTEGER DEFAULT 0 62 ); 63 64 CREATE TABLE agent_messages ( 65 id INTEGER PRIMARY KEY AUTOINCREMENT, 66 task_id INTEGER REFERENCES agent_tasks(id), 67 from_agent TEXT NOT NULL, 68 to_agent TEXT NOT NULL, 69 message_type TEXT CHECK(message_type IN ('question', 'answer', 'handoff', 'notification')), 70 content TEXT NOT NULL, 71 metadata_json TEXT, 72 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 73 read_at DATETIME 74 ); 75 76 CREATE INDEX IF NOT EXISTS idx_agent_messages_to ON agent_messages(to_agent, read_at); 77 CREATE INDEX IF NOT EXISTS idx_agent_messages_task ON agent_messages(task_id); 78 `); 79 } 80 81 function cleanupTestDb() { 82 resetDb(); 83 if (db) { 84 try { 85 db.close(); 86 } catch (_) { 87 /* ignore */ 88 } 89 db = null; 90 } 91 if (testDir) { 92 try { 93 rmSync(testDir, { recursive: true }); 94 } catch (_) { 95 /* ignore */ 96 } 97 testDir = null; 98 } 99 delete process.env.DATABASE_PATH; 100 } 101 102 // ── resetDb catch block (lines 383-384) ───────────────────────────────────── 103 104 describe('resetDb edge cases', () => { 105 test('resetDb when db is null does nothing', () => { 106 // Ensure db is null first 107 resetDb(); 108 // Calling again when null should not throw 109 resetDb(); 110 assert.ok(true, 'resetDb with null db should not throw'); 111 }); 112 113 test('resetDb after database operations closes cleanly', () => { 114 initTestDb(); 115 // Perform an operation to ensure db is initialized inside the module 116 sendNotification('developer', 'qa', 'Test message'); 117 // Now reset - this closes the module-internal db 118 resetDb(); 119 // Reset again to verify it handles already-closed state 120 resetDb(); 121 assert.ok(true, 'Double resetDb should not throw'); 122 cleanupTestDb(); 123 }); 124 }); 125 126 // ── getConversationThread — answer without in_reply_to goes to top level ──── 127 128 describe('getConversationThread — uncovered branches', () => { 129 test('answer with metadata but no in_reply_to goes to top-level thread', () => { 130 initTestDb(); 131 try { 132 const taskId = db 133 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 134 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 135 136 // Send answer with metadata that does NOT have in_reply_to 137 sendAgentMessage({ 138 task_id: taskId, 139 from_agent: 'developer', 140 to_agent: 'qa', 141 message_type: 'answer', 142 content: 'Standalone answer', 143 metadata: { note: 'no question referenced' }, 144 }); 145 146 const thread = getConversationThread(taskId); 147 assert.strictEqual(thread.length, 1); 148 assert.strictEqual(thread[0].message_type, 'answer'); 149 assert.strictEqual(thread[0].content, 'Standalone answer'); 150 } finally { 151 cleanupTestDb(); 152 } 153 }); 154 155 test('answer without metadata goes to top-level thread', () => { 156 initTestDb(); 157 try { 158 const taskId = db 159 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 160 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 161 162 // Answer with no metadata at all (metadata_json is null) 163 sendAgentMessage({ 164 task_id: taskId, 165 from_agent: 'developer', 166 to_agent: 'qa', 167 message_type: 'answer', 168 content: 'Answer with no metadata', 169 }); 170 171 const thread = getConversationThread(taskId); 172 assert.strictEqual(thread.length, 1); 173 // msg.metadata_json is null so msg.metadata_json?.in_reply_to is undefined 174 // => goes to else branch (line 309) 175 assert.strictEqual(thread[0].message_type, 'answer'); 176 } finally { 177 cleanupTestDb(); 178 } 179 }); 180 181 test('multiple questions with answers are properly grouped', () => { 182 initTestDb(); 183 try { 184 const taskId = db 185 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 186 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 187 188 const q1 = sendQuestion(taskId, 'qa', 'developer', 'Question 1?'); 189 const q2 = sendQuestion(taskId, 'qa', 'developer', 'Question 2?'); 190 sendAnswer(taskId, 'developer', 'qa', 'Answer to Q1', q1); 191 sendAnswer(taskId, 'developer', 'qa', 'Answer to Q2', q2); 192 // Handoff goes to thread as non-question/non-answer 193 sendHandoff(taskId, 'developer', 'qa', 'All done'); 194 195 const thread = getConversationThread(taskId); 196 // Should have 2 questions (with answers) + 1 handoff 197 const questions = thread.filter(m => m.message_type === 'question'); 198 assert.strictEqual(questions.length, 2); 199 assert.strictEqual(questions[0].answers.length, 1); 200 assert.strictEqual(questions[1].answers.length, 1); 201 const handoffs = thread.filter(m => m.message_type === 'handoff'); 202 assert.strictEqual(handoffs.length, 1); 203 } finally { 204 cleanupTestDb(); 205 } 206 }); 207 }); 208 209 // ── getUnreadMessages — metadata parsing branches ─────────────────────────── 210 211 describe('getUnreadMessages — metadata parsing', () => { 212 test('returns null metadata_json when message has no metadata', () => { 213 initTestDb(); 214 try { 215 sendAgentMessage({ 216 from_agent: 'qa', 217 to_agent: 'developer', 218 message_type: 'notification', 219 content: 'Plain message', 220 }); 221 222 const messages = getUnreadMessages('developer'); 223 assert.strictEqual(messages.length, 1); 224 assert.strictEqual(messages[0].metadata_json, null); 225 } finally { 226 cleanupTestDb(); 227 } 228 }); 229 230 test('parses nested metadata_json correctly', () => { 231 initTestDb(); 232 try { 233 sendAgentMessage({ 234 from_agent: 'qa', 235 to_agent: 'developer', 236 message_type: 'notification', 237 content: 'Complex metadata', 238 metadata: { nested: { deep: true }, count: 42 }, 239 }); 240 241 const messages = getUnreadMessages('developer'); 242 assert.strictEqual(messages.length, 1); 243 assert.deepStrictEqual(messages[0].metadata_json, { nested: { deep: true }, count: 42 }); 244 } finally { 245 cleanupTestDb(); 246 } 247 }); 248 }); 249 250 // ── getMessageStats — edge cases ──────────────────────────────────────────── 251 252 describe('getMessageStats — edge cases', () => { 253 test('counts questions_sent correctly', () => { 254 initTestDb(); 255 try { 256 // developer sends 2 questions to qa 257 sendAgentMessage({ 258 from_agent: 'developer', 259 to_agent: 'qa', 260 message_type: 'question', 261 content: 'Q1', 262 }); 263 sendAgentMessage({ 264 from_agent: 'developer', 265 to_agent: 'qa', 266 message_type: 'question', 267 content: 'Q2', 268 }); 269 // developer sends 1 notification (not a question) 270 sendNotification('developer', 'qa', 'FYI'); 271 272 const stats = getMessageStats('developer'); 273 assert.strictEqual(Number(stats.sent), 3); 274 assert.strictEqual(Number(stats.questions_sent), 2); 275 } finally { 276 cleanupTestDb(); 277 } 278 }); 279 280 test('handles very short hours window', () => { 281 initTestDb(); 282 try { 283 sendNotification('developer', 'qa', 'Recent msg'); 284 285 // 0 hours window might still include messages created "now" 286 // since SQLite datetime('now', '-0 hours') == datetime('now') 287 const stats = getMessageStats('developer', 0); 288 // Implementation-dependent, just verify it doesn't throw 289 assert.ok(stats, 'Should return stats object'); 290 } finally { 291 cleanupTestDb(); 292 } 293 }); 294 }); 295 296 // ── sendAgentMessage — all valid agent combinations ───────────────────────── 297 298 describe('sendAgentMessage — agent validation', () => { 299 test('accepts all valid agent names as from_agent', () => { 300 initTestDb(); 301 try { 302 const agents = ['developer', 'qa', 'security', 'architect', 'triage', 'monitor']; 303 for (const agent of agents) { 304 const msgId = sendAgentMessage({ 305 from_agent: agent, 306 to_agent: 'developer', 307 message_type: 'notification', 308 content: `From ${agent}`, 309 }); 310 assert.ok(msgId > 0, `Should accept ${agent} as from_agent`); 311 } 312 } finally { 313 cleanupTestDb(); 314 } 315 }); 316 317 test('accepts all valid agent names as to_agent', () => { 318 initTestDb(); 319 try { 320 const agents = ['developer', 'qa', 'security', 'architect', 'triage', 'monitor']; 321 for (const agent of agents) { 322 const msgId = sendAgentMessage({ 323 from_agent: 'developer', 324 to_agent: agent, 325 message_type: 'notification', 326 content: `To ${agent}`, 327 }); 328 assert.ok(msgId > 0, `Should accept ${agent} as to_agent`); 329 } 330 } finally { 331 cleanupTestDb(); 332 } 333 }); 334 }); 335 336 // ── sendAnswer — metadata branches ────────────────────────────────────────── 337 338 describe('sendAnswer — metadata branch', () => { 339 test('sendAnswer with questionMessageId=0 (falsy) results in null metadata', () => { 340 initTestDb(); 341 try { 342 const taskId = db 343 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 344 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 345 346 // 0 is falsy so metadata should be null 347 const answerId = sendAnswer(taskId, 'developer', 'qa', 'Answer', 0); 348 assert.ok(answerId > 0); 349 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(answerId); 350 assert.strictEqual( 351 msg.metadata_json, 352 null, 353 'Falsy questionMessageId should produce null metadata' 354 ); 355 } finally { 356 cleanupTestDb(); 357 } 358 }); 359 360 test('sendAnswer with explicit null questionMessageId', () => { 361 initTestDb(); 362 try { 363 const taskId = db 364 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 365 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 366 367 const answerId = sendAnswer(taskId, 'developer', 'qa', 'Answer', null); 368 assert.ok(answerId > 0); 369 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(answerId); 370 assert.strictEqual(msg.metadata_json, null); 371 } finally { 372 cleanupTestDb(); 373 } 374 }); 375 }); 376 377 // ── sendHandoff — with and without metadata ───────────────────────────────── 378 379 describe('sendHandoff — metadata variations', () => { 380 test('sendHandoff without metadata', () => { 381 initTestDb(); 382 try { 383 const taskId = db 384 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 385 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 386 387 const msgId = sendHandoff(taskId, 'developer', 'qa', 'Done'); 388 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 389 assert.strictEqual(msg.metadata_json, null); 390 } finally { 391 cleanupTestDb(); 392 } 393 }); 394 }); 395 396 // ── sendNotification — with taskId ────────────────────────────────────────── 397 398 describe('sendNotification — with taskId', () => { 399 test('sendNotification with explicit taskId', () => { 400 initTestDb(); 401 try { 402 const taskId = db 403 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 404 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 405 406 const msgId = sendNotification('monitor', 'triage', 'Alert', taskId, { level: 'high' }); 407 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 408 assert.strictEqual(msg.task_id, Number(taskId)); 409 const meta = JSON.parse(msg.metadata_json); 410 assert.strictEqual(meta.level, 'high'); 411 } finally { 412 cleanupTestDb(); 413 } 414 }); 415 }); 416 417 // ── getTaskMessages — metadata parsing ────────────────────────────────────── 418 419 describe('getTaskMessages — metadata parsing', () => { 420 test('parses metadata_json in task messages', () => { 421 initTestDb(); 422 try { 423 const taskId = db 424 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 425 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 426 427 sendAgentMessage({ 428 task_id: taskId, 429 from_agent: 'developer', 430 to_agent: 'qa', 431 message_type: 'handoff', 432 content: 'Done', 433 metadata: { commit: 'abc123', files: ['a.js', 'b.js'] }, 434 }); 435 436 const messages = getTaskMessages(taskId); 437 assert.strictEqual(messages.length, 1); 438 assert.deepStrictEqual(messages[0].metadata_json, { 439 commit: 'abc123', 440 files: ['a.js', 'b.js'], 441 }); 442 } finally { 443 cleanupTestDb(); 444 } 445 }); 446 447 test('returns null metadata_json for messages without metadata', () => { 448 initTestDb(); 449 try { 450 const taskId = db 451 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 452 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 453 454 sendAgentMessage({ 455 task_id: taskId, 456 from_agent: 'developer', 457 to_agent: 'qa', 458 message_type: 'notification', 459 content: 'No metadata', 460 }); 461 462 const messages = getTaskMessages(taskId); 463 assert.strictEqual(messages.length, 1); 464 assert.strictEqual(messages[0].metadata_json, null); 465 } finally { 466 cleanupTestDb(); 467 } 468 }); 469 });