message-manager-gaps.test.js
1 /** 2 * Message Manager Gap Coverage Tests 3 * 4 * Targets uncovered paths in src/agents/utils/message-manager.js (63% → higher): 5 * - getConversationThread: answer with metadata_json but in_reply_to pointing to unknown id 6 * - getConversationThread: answer without metadata_json.in_reply_to (falls to else branch) 7 * - getConversationThread: multiple questions with multiple answers 8 * - sendQuestion: with metadata parameter 9 * - sendHandoff: without metadata parameter 10 * - sendNotification: with taskId parameter 11 * - getUnreadMessages: metadata_json null case (no metadata) 12 * - getMessageStats: hours parameter edge cases 13 * - sendAgentMessage: all six valid agents as from/to combinations 14 * 15 * DB setup: Uses pg-mock backed by in-memory SQLite with agent_tasks + agent_messages tables. 16 */ 17 18 import { test, describe, beforeEach, mock } from 'node:test'; 19 import assert from 'node:assert/strict'; 20 import Database from 'better-sqlite3'; 21 import { createPgMock } from '../helpers/pg-mock.js'; 22 23 // Shared in-memory database with all required agent tables 24 const db = new Database(':memory:'); 25 db.exec(` 26 CREATE TABLE IF NOT EXISTS agent_tasks ( 27 id INTEGER PRIMARY KEY AUTOINCREMENT, 28 task_type TEXT NOT NULL, 29 assigned_to TEXT NOT NULL, 30 created_by TEXT, 31 status TEXT DEFAULT 'pending', 32 priority INTEGER DEFAULT 5, 33 context_json TEXT, 34 result_json TEXT, 35 parent_task_id INTEGER, 36 error_message TEXT, 37 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 38 started_at DATETIME, 39 completed_at DATETIME, 40 retry_count INTEGER DEFAULT 0 41 ); 42 43 CREATE TABLE IF NOT EXISTS agent_messages ( 44 id INTEGER PRIMARY KEY AUTOINCREMENT, 45 task_id INTEGER, 46 from_agent TEXT NOT NULL, 47 to_agent TEXT NOT NULL, 48 message_type TEXT CHECK(message_type IN ('question', 'answer', 'handoff', 'notification')), 49 content TEXT NOT NULL, 50 metadata_json TEXT, 51 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 52 read_at DATETIME 53 ); 54 55 CREATE INDEX IF NOT EXISTS idx_agent_messages_to ON agent_messages(to_agent, read_at); 56 CREATE INDEX IF NOT EXISTS idx_agent_messages_task ON agent_messages(task_id); 57 `); 58 59 mock.module('../../src/utils/db.js', { namedExports: createPgMock(db) }); 60 61 const { 62 sendAgentMessage, 63 getUnreadMessages, 64 markMessageRead, 65 getTaskMessages, 66 sendQuestion, 67 sendAnswer, 68 sendHandoff, 69 sendNotification, 70 getConversationThread, 71 hasPendingQuestions, 72 getMessageStats, 73 } = await import('../../src/agents/utils/message-manager.js'); 74 75 function insertTask(type = 'fix_bug', agent = 'developer') { 76 return db.prepare( 77 'INSERT INTO agent_tasks (task_type, assigned_to) VALUES (?, ?)' 78 ).run(type, agent).lastInsertRowid; 79 } 80 81 describe('message-manager gap coverage', () => { 82 beforeEach(() => { 83 db.exec('DELETE FROM agent_messages; DELETE FROM agent_tasks;'); 84 }); 85 86 // ─── sendAgentMessage: agent validation exhaustive ───────────────────── 87 88 describe('sendAgentMessage - all valid agents', () => { 89 const validAgents = ['developer', 'qa', 'security', 'architect', 'triage', 'monitor']; 90 91 test('all valid agents can send messages to each other', async () => { 92 for (const from of validAgents) { 93 for (const to of validAgents) { 94 const msgId = await sendAgentMessage({ 95 from_agent: from, 96 to_agent: to, 97 message_type: 'notification', 98 content: `${from} to ${to}`, 99 }); 100 assert.ok(msgId > 0, `${from} -> ${to} should succeed`); 101 } 102 } 103 }); 104 105 test('rejects message with empty content', async () => { 106 await assert.rejects( 107 () => sendAgentMessage({ 108 from_agent: 'developer', 109 to_agent: 'qa', 110 message_type: 'notification', 111 content: '', 112 }), 113 /required/ 114 ); 115 }); 116 117 test('rejects message with undefined content', async () => { 118 await assert.rejects( 119 () => sendAgentMessage({ 120 from_agent: 'developer', 121 to_agent: 'qa', 122 message_type: 'notification', 123 }), 124 /required/ 125 ); 126 }); 127 }); 128 129 // ─── getConversationThread: edge cases ───────────────────────────────── 130 131 describe('getConversationThread - edge cases', () => { 132 test('answer without metadata_json falls to else branch', async () => { 133 const taskId = insertTask(); 134 135 // Send a plain answer (no in_reply_to) — goes to else branch 136 await sendAgentMessage({ 137 task_id: taskId, 138 from_agent: 'developer', 139 to_agent: 'qa', 140 message_type: 'answer', 141 content: 'standalone answer', 142 }); 143 144 const thread = await getConversationThread(taskId); 145 assert.equal(thread.length, 1); 146 assert.equal(thread[0].message_type, 'answer'); 147 assert.equal(thread[0].content, 'standalone answer'); 148 }); 149 150 test('answer with null metadata_json falls to else branch', async () => { 151 const taskId = insertTask(); 152 153 // sendAnswer without questionMessageId -> metadata is null 154 await sendAnswer(taskId, 'developer', 'qa', 'no metadata answer'); 155 156 const thread = await getConversationThread(taskId); 157 assert.equal(thread.length, 1); 158 assert.equal(thread[0].message_type, 'answer'); 159 }); 160 161 test('multiple questions with multiple answers are properly grouped', async () => { 162 const taskId = insertTask(); 163 164 const q1 = await sendQuestion(taskId, 'qa', 'developer', 'Question 1'); 165 const q2 = await sendQuestion(taskId, 'qa', 'developer', 'Question 2'); 166 167 await sendAnswer(taskId, 'developer', 'qa', 'Answer to Q1', q1); 168 await sendAnswer(taskId, 'developer', 'qa', 'Another answer to Q1', q1); 169 await sendAnswer(taskId, 'developer', 'qa', 'Answer to Q2', q2); 170 171 const thread = await getConversationThread(taskId); 172 173 const questions = thread.filter(m => m.message_type === 'question'); 174 assert.equal(questions.length, 2); 175 assert.equal(questions[0].answers.length, 2, 'Q1 should have 2 answers'); 176 assert.equal(questions[1].answers.length, 1, 'Q2 should have 1 answer'); 177 }); 178 179 test('mixed message types in conversation', async () => { 180 const taskId = insertTask(); 181 182 await sendHandoff(taskId, 'developer', 'qa', 'Code ready'); 183 const q = await sendQuestion(taskId, 'qa', 'developer', 'Any edge cases?'); 184 await sendAnswer(taskId, 'developer', 'qa', 'All covered', q); 185 await sendNotification('monitor', 'developer', 'Build passed', taskId); 186 187 const thread = await getConversationThread(taskId); 188 // handoff + question (with answer nested) + notification = 3 top-level 189 assert.equal(thread.length, 3); 190 assert.ok(thread.some(m => m.message_type === 'handoff')); 191 assert.ok(thread.some(m => m.message_type === 'notification')); 192 assert.ok(thread.some(m => m.message_type === 'question')); 193 }); 194 }); 195 196 // ─── sendQuestion: with metadata ────────────────────────────────────── 197 198 describe('sendQuestion - with metadata', () => { 199 test('sendQuestion passes metadata through', async () => { 200 const taskId = insertTask(); 201 202 const msgId = await sendQuestion(taskId, 'qa', 'developer', 'Test question?', { 203 priority: 'high', 204 context: 'unit test', 205 }); 206 207 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 208 assert.equal(msg.message_type, 'question'); 209 const meta = JSON.parse(msg.metadata_json); 210 assert.equal(meta.priority, 'high'); 211 assert.equal(meta.context, 'unit test'); 212 }); 213 }); 214 215 // ─── sendHandoff: without metadata ───────────────────────────────────── 216 217 describe('sendHandoff - without metadata', () => { 218 test('sendHandoff works without metadata parameter', async () => { 219 const taskId = insertTask(); 220 221 const msgId = await sendHandoff(taskId, 'developer', 'qa', 'Ready for review'); 222 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 223 assert.equal(msg.message_type, 'handoff'); 224 assert.equal(msg.metadata_json, null); 225 }); 226 }); 227 228 // ─── sendNotification: with taskId ───────────────────────────────────── 229 230 describe('sendNotification - with taskId', () => { 231 test('sendNotification includes taskId when provided', async () => { 232 const taskId = insertTask('check_agent_health', 'monitor'); 233 234 const msgId = await sendNotification('monitor', 'triage', 'Alert fired', taskId, { 235 severity: 'critical', 236 }); 237 238 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 239 assert.equal(msg.task_id, Number(taskId)); 240 assert.equal(msg.message_type, 'notification'); 241 const meta = JSON.parse(msg.metadata_json); 242 assert.equal(meta.severity, 'critical'); 243 }); 244 }); 245 246 // ─── getUnreadMessages: metadata parsing ─────────────────────────────── 247 248 describe('getUnreadMessages - metadata parsing', () => { 249 test('returns null metadata_json when message has no metadata', async () => { 250 await sendAgentMessage({ 251 from_agent: 'qa', 252 to_agent: 'developer', 253 message_type: 'notification', 254 content: 'No meta', 255 }); 256 257 const messages = await getUnreadMessages('developer'); 258 assert.equal(messages.length, 1); 259 assert.equal(messages[0].metadata_json, null); 260 }); 261 262 test('parses complex metadata_json correctly', async () => { 263 await sendAgentMessage({ 264 from_agent: 'qa', 265 to_agent: 'developer', 266 message_type: 'notification', 267 content: 'Complex meta', 268 metadata: { nested: { key: 'value' }, array: [1, 2, 3] }, 269 }); 270 271 const messages = await getUnreadMessages('developer'); 272 assert.equal(messages.length, 1); 273 assert.deepEqual(messages[0].metadata_json.nested, { key: 'value' }); 274 assert.deepEqual(messages[0].metadata_json.array, [1, 2, 3]); 275 }); 276 }); 277 278 // ─── getTaskMessages: metadata parsing ───────────────────────────────── 279 280 describe('getTaskMessages - metadata parsing', () => { 281 test('parses metadata_json for task messages', async () => { 282 const taskId = insertTask(); 283 284 await sendAgentMessage({ 285 task_id: taskId, 286 from_agent: 'developer', 287 to_agent: 'qa', 288 message_type: 'handoff', 289 content: 'Done', 290 metadata: { commit: 'abc123', files_changed: 3 }, 291 }); 292 293 const messages = await getTaskMessages(taskId); 294 assert.equal(messages.length, 1); 295 assert.equal(messages[0].metadata_json.commit, 'abc123'); 296 assert.equal(messages[0].metadata_json.files_changed, 3); 297 }); 298 299 test('returns null metadata_json for messages without metadata', async () => { 300 const taskId = insertTask(); 301 302 await sendAgentMessage({ 303 task_id: taskId, 304 from_agent: 'developer', 305 to_agent: 'qa', 306 message_type: 'notification', 307 content: 'No meta', 308 }); 309 310 const messages = await getTaskMessages(taskId); 311 assert.equal(messages.length, 1); 312 assert.equal(messages[0].metadata_json, null); 313 }); 314 }); 315 316 // ─── markMessageRead: verification ───────────────────────────────────── 317 318 describe('markMessageRead - verification', () => { 319 test('sets read_at to a valid datetime', async () => { 320 const msgId = await sendAgentMessage({ 321 from_agent: 'qa', 322 to_agent: 'developer', 323 message_type: 'notification', 324 content: 'Read me', 325 }); 326 327 await markMessageRead(msgId); 328 329 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 330 assert.ok(msg.read_at, 'read_at should be set'); 331 assert.ok(msg.read_at.includes('-'), 'read_at should contain date parts'); 332 }); 333 334 test('marking already-read message updates read_at', async () => { 335 const msgId = await sendAgentMessage({ 336 from_agent: 'qa', 337 to_agent: 'developer', 338 message_type: 'notification', 339 content: 'Read twice', 340 }); 341 342 await markMessageRead(msgId); 343 const first = db.prepare('SELECT read_at FROM agent_messages WHERE id = ?').get(msgId); 344 345 await markMessageRead(msgId); 346 const second = db.prepare('SELECT read_at FROM agent_messages WHERE id = ?').get(msgId); 347 348 assert.ok(first.read_at); 349 assert.ok(second.read_at); 350 }); 351 352 test('marking non-existent message does not throw', async () => { 353 await assert.doesNotReject(() => markMessageRead(99999)); 354 }); 355 }); 356 357 // ─── hasPendingQuestions: with other message types ───────────────────── 358 359 describe('hasPendingQuestions - mixed types', () => { 360 test('ignores handoff messages', async () => { 361 await sendAgentMessage({ 362 from_agent: 'qa', 363 to_agent: 'developer', 364 message_type: 'handoff', 365 content: 'handoff msg', 366 }); 367 assert.equal(await hasPendingQuestions('developer'), false); 368 }); 369 370 test('ignores answer messages', async () => { 371 await sendAgentMessage({ 372 from_agent: 'qa', 373 to_agent: 'developer', 374 message_type: 'answer', 375 content: 'answer msg', 376 }); 377 assert.equal(await hasPendingQuestions('developer'), false); 378 }); 379 380 test('counts only unread questions for the specific agent', async () => { 381 await sendAgentMessage({ 382 from_agent: 'qa', 383 to_agent: 'developer', 384 message_type: 'question', 385 content: 'Q for dev', 386 }); 387 await sendAgentMessage({ 388 from_agent: 'developer', 389 to_agent: 'qa', 390 message_type: 'question', 391 content: 'Q for qa', 392 }); 393 394 assert.equal(await hasPendingQuestions('developer'), true); 395 assert.equal(await hasPendingQuestions('qa'), true); 396 assert.equal(await hasPendingQuestions('security'), false); 397 }); 398 }); 399 400 // ─── getMessageStats: comprehensive ──────────────────────────────────── 401 402 describe('getMessageStats - comprehensive', () => { 403 test('counts questions_sent for agent', async () => { 404 await sendAgentMessage({ 405 from_agent: 'developer', 406 to_agent: 'qa', 407 message_type: 'question', 408 content: 'Dev question', 409 }); 410 await sendAgentMessage({ 411 from_agent: 'developer', 412 to_agent: 'security', 413 message_type: 'notification', 414 content: 'Not a question', 415 }); 416 417 const stats = await getMessageStats('developer'); 418 assert.equal(Number(stats.questions_sent), 1); 419 assert.equal(Number(stats.sent), 2); 420 }); 421 422 test('mixed sent and received counts', async () => { 423 await sendAgentMessage({ 424 from_agent: 'developer', 425 to_agent: 'qa', 426 message_type: 'notification', 427 content: 'sent 1', 428 }); 429 await sendAgentMessage({ 430 from_agent: 'qa', 431 to_agent: 'developer', 432 message_type: 'notification', 433 content: 'received 1', 434 }); 435 await sendAgentMessage({ 436 from_agent: 'security', 437 to_agent: 'developer', 438 message_type: 'question', 439 content: 'received 2', 440 }); 441 442 const stats = await getMessageStats('developer'); 443 assert.equal(Number(stats.sent), 1); 444 assert.equal(Number(stats.received), 2); 445 assert.equal(Number(stats.unread), 2); 446 assert.equal(Number(stats.questions_pending), 1); 447 }); 448 }); 449 450 // ─── sendAgentMessage: validation edge cases ─────────────────────────── 451 452 describe('sendAgentMessage - validation edge cases', () => { 453 test('rejects null from_agent', async () => { 454 await assert.rejects( 455 () => sendAgentMessage({ 456 from_agent: null, 457 to_agent: 'qa', 458 message_type: 'notification', 459 content: 'test', 460 }), 461 /required/ 462 ); 463 }); 464 465 test('rejects undefined to_agent', async () => { 466 await assert.rejects( 467 () => sendAgentMessage({ 468 from_agent: 'developer', 469 message_type: 'notification', 470 content: 'test', 471 }), 472 /required/ 473 ); 474 }); 475 476 test('each valid message_type is accepted', async () => { 477 const types = ['question', 'answer', 'handoff', 'notification']; 478 for (const t of types) { 479 const msgId = await sendAgentMessage({ 480 from_agent: 'developer', 481 to_agent: 'qa', 482 message_type: t, 483 content: `testing ${t}`, 484 }); 485 assert.ok(msgId > 0, `message_type '${t}' should be accepted`); 486 } 487 }); 488 }); 489 });