message-manager.test.js
1 /** 2 * Tests for Message Manager Utility 3 * 4 * Tests inter-agent messaging: send, receive, mark read, messages. 5 */ 6 7 import { test } from 'node:test'; 8 import assert from 'node:assert/strict'; 9 import Database from 'better-sqlite3'; 10 import { mkdtempSync, rmSync } from 'fs'; 11 import { tmpdir } from 'os'; 12 import { join } from 'path'; 13 import { 14 sendAgentMessage, 15 getUnreadMessages, 16 markMessageRead, 17 getTaskMessages, 18 sendQuestion, 19 sendAnswer, 20 sendHandoff, 21 sendNotification, 22 getConversationThread, 23 hasPendingQuestions, 24 getMessageStats, 25 resetDb, 26 } from '../../src/agents/utils/message-manager.js'; 27 28 let testDir; 29 let dbPath; 30 let db; 31 32 function initTestDb() { 33 testDir = mkdtempSync(join(tmpdir(), 'msg-test-')); 34 dbPath = join(testDir, 'test.db'); 35 process.env.DATABASE_PATH = dbPath; 36 37 db = new Database(dbPath); 38 db.pragma('foreign_keys = ON'); 39 40 // Create required tables 41 db.exec(` 42 CREATE TABLE agent_tasks ( 43 id INTEGER PRIMARY KEY AUTOINCREMENT, 44 task_type TEXT NOT NULL, 45 assigned_to TEXT NOT NULL, 46 created_by TEXT, 47 status TEXT DEFAULT 'pending', 48 priority INTEGER DEFAULT 5, 49 context_json TEXT, 50 result_json TEXT, 51 parent_task_id INTEGER, 52 error_message TEXT, 53 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 54 started_at DATETIME, 55 completed_at DATETIME, 56 retry_count INTEGER DEFAULT 0 57 ); 58 59 CREATE TABLE agent_messages ( 60 id INTEGER PRIMARY KEY AUTOINCREMENT, 61 task_id INTEGER REFERENCES agent_tasks(id), 62 from_agent TEXT NOT NULL, 63 to_agent TEXT NOT NULL, 64 message_type TEXT CHECK(message_type IN ('question', 'answer', 'handoff', 'notification')), 65 content TEXT NOT NULL, 66 metadata_json TEXT, 67 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 68 read_at DATETIME 69 ); 70 71 CREATE INDEX IF NOT EXISTS idx_agent_messages_to ON agent_messages(to_agent, read_at); 72 CREATE INDEX IF NOT EXISTS idx_agent_messages_task ON agent_messages(task_id); 73 `); 74 } 75 76 function cleanupTestDb() { 77 resetDb(); 78 if (db) { 79 try { 80 db.close(); 81 } catch (_e) { 82 /* ignore */ 83 } 84 db = null; 85 } 86 if (testDir) { 87 try { 88 rmSync(testDir, { recursive: true }); 89 } catch (_e) { 90 /* ignore */ 91 } 92 testDir = null; 93 } 94 delete process.env.DATABASE_PATH; 95 } 96 97 test('Message Manager - sendAgentMessage', async t => { 98 await t.beforeEach(() => { 99 initTestDb(); 100 }); 101 await t.afterEach(() => { 102 cleanupTestDb(); 103 }); 104 105 await t.test('should send a basic message', () => { 106 const msgId = sendAgentMessage({ 107 from_agent: 'developer', 108 to_agent: 'qa', 109 message_type: 'notification', 110 content: 'Bug fix deployed', 111 }); 112 113 assert.ok(typeof msgId === 'bigint' || typeof msgId === 'number', 'Should return message ID'); 114 assert.ok(msgId > 0, 'Message ID should be positive'); 115 }); 116 117 await t.test('should send message with task_id', () => { 118 const taskId = db 119 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 120 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 121 122 const msgId = sendAgentMessage({ 123 task_id: taskId, 124 from_agent: 'developer', 125 to_agent: 'qa', 126 message_type: 'handoff', 127 content: 'Fixed, ready for review', 128 metadata: { commit: 'abc123' }, 129 }); 130 131 assert.ok(msgId > 0, 'Should return message ID'); 132 133 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 134 assert.strictEqual(msg.task_id, Number(taskId)); 135 assert.ok(msg.metadata_json, 'Should have metadata'); 136 const metadata = JSON.parse(msg.metadata_json); 137 assert.strictEqual(metadata.commit, 'abc123'); 138 }); 139 140 await t.test('should reject missing required fields', () => { 141 assert.throws(() => { 142 sendAgentMessage({ 143 from_agent: 'developer', 144 // missing to_agent, message_type, content 145 }); 146 }, /required/); 147 }); 148 149 await t.test('should reject invalid message_type', () => { 150 assert.throws(() => { 151 sendAgentMessage({ 152 from_agent: 'developer', 153 to_agent: 'qa', 154 message_type: 'invalid_type', 155 content: 'Test', 156 }); 157 }, /Invalid message_type/); 158 }); 159 160 await t.test('should reject invalid from_agent', () => { 161 assert.throws(() => { 162 sendAgentMessage({ 163 from_agent: 'invalid_agent', 164 to_agent: 'qa', 165 message_type: 'notification', 166 content: 'Test', 167 }); 168 }, /Invalid from_agent/); 169 }); 170 171 await t.test('should reject invalid to_agent', () => { 172 assert.throws(() => { 173 sendAgentMessage({ 174 from_agent: 'developer', 175 to_agent: 'invalid_agent', 176 message_type: 'notification', 177 content: 'Test', 178 }); 179 }, /Invalid to_agent/); 180 }); 181 182 await t.test('should send all valid message types', () => { 183 const validTypes = ['question', 'answer', 'handoff', 'notification']; 184 for (const messageType of validTypes) { 185 const msgId = sendAgentMessage({ 186 from_agent: 'developer', 187 to_agent: 'qa', 188 message_type: messageType, 189 content: `Test ${messageType}`, 190 }); 191 assert.ok(msgId > 0, `Should accept ${messageType}`); 192 } 193 }); 194 195 await t.test('should send message without optional fields', () => { 196 const msgId = sendAgentMessage({ 197 from_agent: 'monitor', 198 to_agent: 'triage', 199 message_type: 'notification', 200 content: 'Error spike detected', 201 }); 202 assert.ok(msgId > 0); 203 204 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 205 assert.strictEqual(msg.task_id, null); 206 assert.strictEqual(msg.metadata_json, null); 207 }); 208 }); 209 210 test('Message Manager - getUnreadMessages', async t => { 211 await t.beforeEach(() => { 212 initTestDb(); 213 }); 214 await t.afterEach(() => { 215 cleanupTestDb(); 216 }); 217 218 await t.test('should return empty array when no messages', () => { 219 const messages = getUnreadMessages('developer'); 220 assert.deepEqual(messages, []); 221 }); 222 223 await t.test('should return unread messages for agent', () => { 224 // Send 2 messages to developer 225 sendAgentMessage({ 226 from_agent: 'qa', 227 to_agent: 'developer', 228 message_type: 'question', 229 content: 'Q1', 230 }); 231 sendAgentMessage({ 232 from_agent: 'qa', 233 to_agent: 'developer', 234 message_type: 'question', 235 content: 'Q2', 236 }); 237 // Send 1 to qa (should not be returned for developer) 238 sendAgentMessage({ 239 from_agent: 'developer', 240 to_agent: 'qa', 241 message_type: 'notification', 242 content: 'N1', 243 }); 244 245 const messages = getUnreadMessages('developer'); 246 assert.strictEqual(messages.length, 2); 247 assert.ok(messages.every(m => m.to_agent === 'developer')); 248 assert.ok(messages.every(m => m.read_at === null)); 249 }); 250 251 await t.test('should respect limit parameter', () => { 252 for (let i = 0; i < 5; i++) { 253 sendAgentMessage({ 254 from_agent: 'qa', 255 to_agent: 'developer', 256 message_type: 'notification', 257 content: `Msg ${i}`, 258 }); 259 } 260 261 const limited = getUnreadMessages('developer', 3); 262 assert.strictEqual(limited.length, 3); 263 }); 264 265 await t.test('should parse metadata_json', () => { 266 sendAgentMessage({ 267 from_agent: 'qa', 268 to_agent: 'developer', 269 message_type: 'notification', 270 content: 'Test', 271 metadata: { severity: 'high' }, 272 }); 273 274 const messages = getUnreadMessages('developer'); 275 assert.strictEqual(messages.length, 1); 276 assert.deepEqual(messages[0].metadata_json, { severity: 'high' }); 277 }); 278 279 await t.test('should not return already-read messages', () => { 280 const msgId = sendAgentMessage({ 281 from_agent: 'qa', 282 to_agent: 'developer', 283 message_type: 'notification', 284 content: 'Read me', 285 }); 286 287 markMessageRead(msgId); 288 289 const messages = getUnreadMessages('developer'); 290 assert.strictEqual(messages.length, 0); 291 }); 292 }); 293 294 test('Message Manager - markMessageRead', async t => { 295 await t.beforeEach(() => { 296 initTestDb(); 297 }); 298 await t.afterEach(() => { 299 cleanupTestDb(); 300 }); 301 302 await t.test('should mark a message as read', () => { 303 const msgId = sendAgentMessage({ 304 from_agent: 'qa', 305 to_agent: 'developer', 306 message_type: 'notification', 307 content: 'Test', 308 }); 309 310 markMessageRead(msgId); 311 312 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 313 assert.ok(msg.read_at !== null, 'read_at should be set'); 314 }); 315 316 await t.test('should set read_at timestamp', () => { 317 const beforeTime = new Date() 318 .toISOString() 319 .replace('T', ' ') 320 .replace(/\.\d+Z$/, ''); 321 const msgId = sendAgentMessage({ 322 from_agent: 'qa', 323 to_agent: 'developer', 324 message_type: 'notification', 325 content: 'Test', 326 }); 327 328 markMessageRead(msgId); 329 330 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 331 assert.ok(msg.read_at >= beforeTime, 'read_at should be after test start'); 332 }); 333 }); 334 335 test('Message Manager - getTaskMessages', async t => { 336 await t.beforeEach(() => { 337 initTestDb(); 338 }); 339 await t.afterEach(() => { 340 cleanupTestDb(); 341 }); 342 343 await t.test('should return all messages for a task', () => { 344 const taskId = db 345 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 346 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 347 348 sendAgentMessage({ 349 task_id: taskId, 350 from_agent: 'developer', 351 to_agent: 'qa', 352 message_type: 'handoff', 353 content: 'Done', 354 }); 355 sendAgentMessage({ 356 task_id: taskId, 357 from_agent: 'qa', 358 to_agent: 'developer', 359 message_type: 'question', 360 content: 'Q1', 361 }); 362 363 const messages = getTaskMessages(taskId); 364 assert.strictEqual(messages.length, 2); 365 assert.ok(messages.every(m => m.task_id === Number(taskId))); 366 }); 367 368 await t.test('should return empty array for task with no messages', () => { 369 const taskId = db 370 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 371 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 372 373 const messages = getTaskMessages(taskId); 374 assert.deepEqual(messages, []); 375 }); 376 377 await t.test('should not return messages from other tasks', () => { 378 const taskId1 = db 379 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 380 .run('task1', 'developer', 'pending').lastInsertRowid; 381 382 const taskId2 = db 383 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 384 .run('task2', 'developer', 'pending').lastInsertRowid; 385 386 sendAgentMessage({ 387 task_id: taskId1, 388 from_agent: 'developer', 389 to_agent: 'qa', 390 message_type: 'notification', 391 content: 'Task 1 msg', 392 }); 393 sendAgentMessage({ 394 task_id: taskId2, 395 from_agent: 'developer', 396 to_agent: 'qa', 397 message_type: 'notification', 398 content: 'Task 2 msg', 399 }); 400 401 const messages1 = getTaskMessages(taskId1); 402 assert.strictEqual(messages1.length, 1); 403 assert.strictEqual(messages1[0].content, 'Task 1 msg'); 404 }); 405 }); 406 407 test('Message Manager - convenience functions', async t => { 408 await t.beforeEach(() => { 409 initTestDb(); 410 }); 411 await t.afterEach(() => { 412 cleanupTestDb(); 413 }); 414 415 await t.test('sendQuestion should create question message', () => { 416 const taskId = db 417 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 418 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 419 420 const msgId = sendQuestion(taskId, 'qa', 'developer', 'Should this cover edge cases?'); 421 assert.ok(msgId > 0); 422 423 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 424 assert.strictEqual(msg.message_type, 'question'); 425 assert.strictEqual(msg.content, 'Should this cover edge cases?'); 426 assert.strictEqual(msg.from_agent, 'qa'); 427 assert.strictEqual(msg.to_agent, 'developer'); 428 }); 429 430 await t.test('sendAnswer should create answer message with in_reply_to', () => { 431 const taskId = db 432 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 433 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 434 435 const questionId = sendQuestion(taskId, 'qa', 'developer', 'Cover edge cases?'); 436 const answerId = sendAnswer(taskId, 'developer', 'qa', 'Yes, all covered', questionId); 437 438 assert.ok(answerId > 0); 439 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(answerId); 440 assert.strictEqual(msg.message_type, 'answer'); 441 const meta = JSON.parse(msg.metadata_json); 442 assert.strictEqual(meta.in_reply_to, Number(questionId)); 443 }); 444 445 await t.test('sendAnswer without questionMessageId should work', () => { 446 const taskId = db 447 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 448 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 449 450 const answerId = sendAnswer(taskId, 'developer', 'qa', 'Here is my answer'); 451 assert.ok(answerId > 0); 452 453 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(answerId); 454 assert.strictEqual(msg.message_type, 'answer'); 455 assert.strictEqual(msg.metadata_json, null); 456 }); 457 458 await t.test('sendHandoff should create handoff message', () => { 459 const taskId = db 460 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 461 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 462 463 const msgId = sendHandoff(taskId, 'developer', 'qa', 'Fix ready for review', { 464 commit: 'abc123', 465 }); 466 assert.ok(msgId > 0); 467 468 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 469 assert.strictEqual(msg.message_type, 'handoff'); 470 const meta = JSON.parse(msg.metadata_json); 471 assert.strictEqual(meta.commit, 'abc123'); 472 }); 473 474 await t.test('sendNotification should create notification message', () => { 475 const msgId = sendNotification('monitor', 'triage', 'Alert: Error spike detected', null, { 476 count: 50, 477 }); 478 assert.ok(msgId > 0); 479 480 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 481 assert.strictEqual(msg.message_type, 'notification'); 482 assert.strictEqual(msg.from_agent, 'monitor'); 483 assert.strictEqual(msg.to_agent, 'triage'); 484 assert.strictEqual(msg.task_id, null); 485 const meta = JSON.parse(msg.metadata_json); 486 assert.strictEqual(meta.count, 50); 487 }); 488 489 await t.test('sendNotification without optional params', () => { 490 const msgId = sendNotification('developer', 'qa', 'Simple notification'); 491 assert.ok(msgId > 0); 492 493 const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId); 494 assert.strictEqual(msg.message_type, 'notification'); 495 assert.strictEqual(msg.task_id, null); 496 assert.strictEqual(msg.metadata_json, null); 497 }); 498 }); 499 500 test('Message Manager - getConversationThread', async t => { 501 await t.beforeEach(() => { 502 initTestDb(); 503 }); 504 await t.afterEach(() => { 505 cleanupTestDb(); 506 }); 507 508 await t.test('should group questions with answers', () => { 509 const taskId = db 510 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 511 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 512 513 const questionId = sendQuestion(taskId, 'qa', 'developer', 'Should this test cover X?'); 514 sendAnswer(taskId, 'developer', 'qa', 'Yes, X is covered', questionId); 515 516 const thread = getConversationThread(taskId); 517 518 const question = thread.find(m => m.message_type === 'question'); 519 assert.ok(question, 'Should have question in thread'); 520 assert.ok(Array.isArray(question.answers), 'Question should have answers array'); 521 assert.strictEqual(question.answers.length, 1, 'Should have 1 answer'); 522 }); 523 524 await t.test('should include non-question messages', () => { 525 const taskId = db 526 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 527 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 528 529 sendHandoff(taskId, 'developer', 'qa', 'Ready for review'); 530 sendQuestion(taskId, 'qa', 'developer', 'Any edge cases?'); 531 532 const thread = getConversationThread(taskId); 533 assert.ok(thread.length >= 2); 534 }); 535 536 await t.test('should return empty thread for task with no messages', () => { 537 const taskId = db 538 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 539 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 540 541 const thread = getConversationThread(taskId); 542 assert.deepEqual(thread, []); 543 }); 544 545 await t.test('should add answer to thread top-level if question not found', () => { 546 const taskId = db 547 .prepare('INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES (?, ?, ?)') 548 .run('fix_bug', 'developer', 'pending').lastInsertRowid; 549 550 // Send answer with in_reply_to for non-existent question 551 sendAgentMessage({ 552 task_id: taskId, 553 from_agent: 'developer', 554 to_agent: 'qa', 555 message_type: 'answer', 556 content: 'Answer to unknown question', 557 metadata: { in_reply_to: 99999 }, 558 }); 559 560 const thread = getConversationThread(taskId); 561 assert.strictEqual(thread.length, 1); 562 assert.strictEqual(thread[0].message_type, 'answer'); 563 }); 564 }); 565 566 test('Message Manager - hasPendingQuestions', async t => { 567 await t.beforeEach(() => { 568 initTestDb(); 569 }); 570 await t.afterEach(() => { 571 cleanupTestDb(); 572 }); 573 574 await t.test('should return false when no pending questions', () => { 575 assert.strictEqual(hasPendingQuestions('developer'), false); 576 }); 577 578 await t.test('should return true when there are pending questions', () => { 579 sendAgentMessage({ 580 from_agent: 'qa', 581 to_agent: 'developer', 582 message_type: 'question', 583 content: 'Any bugs?', 584 }); 585 assert.strictEqual(hasPendingQuestions('developer'), true); 586 }); 587 588 await t.test('should return false after question is read', () => { 589 const msgId = sendAgentMessage({ 590 from_agent: 'qa', 591 to_agent: 'developer', 592 message_type: 'question', 593 content: 'Any bugs?', 594 }); 595 markMessageRead(msgId); 596 assert.strictEqual(hasPendingQuestions('developer'), false); 597 }); 598 599 await t.test('should not count notifications as pending questions', () => { 600 sendNotification('monitor', 'developer', 'System alert'); 601 assert.strictEqual(hasPendingQuestions('developer'), false); 602 }); 603 }); 604 605 test('Message Manager - getMessageStats', async t => { 606 await t.beforeEach(() => { 607 initTestDb(); 608 }); 609 await t.afterEach(() => { 610 cleanupTestDb(); 611 }); 612 613 await t.test('should return zero stats for agent with no messages', () => { 614 const stats = getMessageStats('developer'); 615 assert.strictEqual(Number(stats.sent), 0); 616 assert.strictEqual(Number(stats.received), 0); 617 assert.strictEqual(Number(stats.unread), 0); 618 assert.strictEqual(Number(stats.questions_sent), 0); 619 assert.strictEqual(Number(stats.questions_pending), 0); 620 }); 621 622 await t.test('should count sent messages', () => { 623 sendNotification('developer', 'qa', 'Deployed'); 624 sendNotification('developer', 'qa', 'Done'); 625 626 const stats = getMessageStats('developer'); 627 assert.strictEqual(Number(stats.sent), 2); 628 }); 629 630 await t.test('should count received messages', () => { 631 sendNotification('qa', 'developer', 'Review needed'); 632 sendAgentMessage({ 633 from_agent: 'qa', 634 to_agent: 'developer', 635 message_type: 'question', 636 content: 'Any edge cases?', 637 }); 638 639 const stats = getMessageStats('developer'); 640 assert.strictEqual(Number(stats.received), 2); 641 }); 642 643 await t.test('should count unread messages', () => { 644 const msgId1 = sendNotification('qa', 'developer', 'Msg 1'); 645 sendNotification('qa', 'developer', 'Msg 2'); 646 647 markMessageRead(msgId1); 648 649 const stats = getMessageStats('developer'); 650 assert.strictEqual(Number(stats.unread), 1); 651 }); 652 653 await t.test('should count pending questions', () => { 654 const q1 = sendAgentMessage({ 655 from_agent: 'qa', 656 to_agent: 'developer', 657 message_type: 'question', 658 content: 'Q1', 659 }); 660 sendAgentMessage({ 661 from_agent: 'qa', 662 to_agent: 'developer', 663 message_type: 'question', 664 content: 'Q2', 665 }); 666 667 markMessageRead(q1); 668 669 const stats = getMessageStats('developer'); 670 assert.strictEqual(Number(stats.questions_pending), 1); 671 }); 672 673 await t.test('should respect hours parameter', () => { 674 sendNotification('developer', 'qa', 'Recent'); 675 676 const statsRecent = getMessageStats('developer', 1); 677 assert.strictEqual(Number(statsRecent.sent), 1); 678 679 // Stats for 0 hours should be 0 (nothing in last 0 hours) 680 // Actually this might be implementation-dependent; just verify it returns something 681 const stats24h = getMessageStats('developer', 24); 682 assert.strictEqual(Number(stats24h.sent), 1); 683 }); 684 });