/ __quarantined_tests__ / agents / message-manager.test.js
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  });