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