/ __quarantined_tests__ / agents / base-agent.test.js
base-agent.test.js
   1  /**
   2   * Base Agent Unit Tests
   3   *
   4   * Tests the abstract BaseAgent class: constructor, initialize, locking,
   5   * task lifecycle (complete/fail/block), messaging, logging, workflow validation,
   6   * and createTask helpers.
   7   */
   8  
   9  import { test, describe, beforeEach, afterEach } from 'node:test';
  10  import assert from 'node:assert/strict';
  11  import Database from 'better-sqlite3';
  12  import { BaseAgent, resetDb } from '../../src/agents/base-agent.js';
  13  import { resetDb as resetTaskDb } from '../../src/agents/utils/task-manager.js';
  14  import { resetDb as resetMessageDb } from '../../src/agents/utils/message-manager.js';
  15  import fsp from 'fs/promises';
  16  
  17  // Full test DB schema including all tables used by BaseAgent and utilities
  18  const TEST_SCHEMA = `
  19    CREATE TABLE IF NOT EXISTS agent_tasks (
  20      id INTEGER PRIMARY KEY AUTOINCREMENT,
  21      task_type TEXT NOT NULL,
  22      assigned_to TEXT NOT NULL,
  23      created_by TEXT,
  24      status TEXT DEFAULT 'pending',
  25      priority INTEGER DEFAULT 5,
  26      context_json TEXT,
  27      result_json TEXT,
  28      approval_json TEXT,
  29      reviewed_by TEXT,
  30      parent_task_id INTEGER,
  31      error_message TEXT,
  32      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  33      started_at DATETIME,
  34      completed_at DATETIME,
  35      retry_count INTEGER DEFAULT 0
  36    );
  37  
  38    CREATE TABLE IF NOT EXISTS agent_messages (
  39      id INTEGER PRIMARY KEY AUTOINCREMENT,
  40      task_id INTEGER,
  41      from_agent TEXT NOT NULL,
  42      to_agent TEXT NOT NULL,
  43      message_type TEXT,
  44      content TEXT NOT NULL,
  45      metadata_json TEXT,
  46      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  47      read_at DATETIME
  48    );
  49  
  50    CREATE TABLE IF NOT EXISTS agent_logs (
  51      id INTEGER PRIMARY KEY AUTOINCREMENT,
  52      task_id INTEGER,
  53      agent_name TEXT NOT NULL,
  54      log_level TEXT,
  55      message TEXT,
  56      data_json TEXT,
  57      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  58    );
  59  
  60    CREATE TABLE IF NOT EXISTS agent_state (
  61      agent_name TEXT PRIMARY KEY,
  62      last_active DATETIME DEFAULT CURRENT_TIMESTAMP,
  63      current_task_id INTEGER,
  64      status TEXT DEFAULT 'idle',
  65      metrics_json TEXT
  66    );
  67  
  68    CREATE TABLE IF NOT EXISTS agent_outcomes (
  69      id INTEGER PRIMARY KEY AUTOINCREMENT,
  70      task_id INTEGER NOT NULL,
  71      agent_name TEXT NOT NULL,
  72      task_type TEXT NOT NULL,
  73      outcome TEXT NOT NULL,
  74      context_json TEXT,
  75      result_json TEXT,
  76      duration_ms INTEGER,
  77      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  78    );
  79  
  80    CREATE TABLE IF NOT EXISTS agent_llm_usage (
  81      id INTEGER PRIMARY KEY AUTOINCREMENT,
  82      agent_name TEXT NOT NULL,
  83      task_id INTEGER,
  84      model TEXT NOT NULL,
  85      prompt_tokens INTEGER NOT NULL,
  86      completion_tokens INTEGER NOT NULL,
  87      cost_usd DECIMAL(10, 6) NOT NULL,
  88      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  89    );
  90  
  91    CREATE TABLE IF NOT EXISTS human_review_queue (
  92      id INTEGER PRIMARY KEY AUTOINCREMENT,
  93      file TEXT NOT NULL,
  94      reason TEXT NOT NULL,
  95      type TEXT NOT NULL,
  96      priority TEXT NOT NULL,
  97      metadata TEXT,
  98      status TEXT DEFAULT 'pending',
  99      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 100    );
 101  
 102    CREATE TABLE IF NOT EXISTS structured_logs (
 103      id INTEGER PRIMARY KEY AUTOINCREMENT,
 104      agent_name TEXT,
 105      task_id INTEGER,
 106      level TEXT,
 107      message TEXT,
 108      data_json TEXT,
 109      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
 110    );
 111  `;
 112  
 113  const TEST_DB_PATH = './tests/agents/test-base-agent.db';
 114  let db;
 115  let agent;
 116  
 117  // Minimal concrete subclass for testing abstract base
 118  class TestAgent extends BaseAgent {
 119    constructor() {
 120      super('developer', ['base.md', 'developer.md']);
 121    }
 122  
 123    async processTask(task) {
 124      await this.completeTask(task.id, { processed: true });
 125    }
 126  }
 127  
 128  beforeEach(async () => {
 129    try {
 130      await fsp.unlink(TEST_DB_PATH);
 131    } catch (_e) {
 132      // OK if missing
 133    }
 134  
 135    db = new Database(TEST_DB_PATH);
 136    process.env.DATABASE_PATH = TEST_DB_PATH;
 137    process.env.AGENT_IMMEDIATE_INVOCATION = 'false'; // Prevent subprocess spawning in tests
 138    process.env.AGENT_REALTIME_NOTIFICATIONS = 'false'; // Prevent npm subprocess spawning
 139    db.exec(TEST_SCHEMA);
 140  
 141    agent = new TestAgent();
 142    await agent.initialize();
 143  });
 144  
 145  afterEach(async () => {
 146    resetDb();
 147    resetTaskDb();
 148    resetMessageDb();
 149  
 150    if (db) {
 151      try {
 152        db.close();
 153      } catch (_e) {
 154        /* ignore */
 155      }
 156    }
 157  
 158    try {
 159      await fsp.unlink(TEST_DB_PATH);
 160    } catch (_e) {
 161      /* ignore */
 162    }
 163  });
 164  
 165  // ─── Constructor ───────────────────────────────────────────────────────────────
 166  
 167  describe('BaseAgent - constructor', () => {
 168    test('throws when agentName is null', () => {
 169      class BadAgent extends BaseAgent {
 170        constructor() {
 171          super(null, ['base.md']);
 172        }
 173        processTask() {}
 174      }
 175      assert.throws(() => new BadAgent(), /agentName and contextFiles are required/);
 176    });
 177  
 178    test('throws when contextFiles is null', () => {
 179      class BadAgent extends BaseAgent {
 180        constructor() {
 181          super('developer', null);
 182        }
 183        processTask() {}
 184      }
 185      assert.throws(() => new BadAgent(), /agentName and contextFiles are required/);
 186    });
 187  
 188    test('stores agentName and contextFiles', () => {
 189      assert.strictEqual(agent.agentName, 'developer');
 190      assert.deepStrictEqual(agent.contextFiles, ['base.md', 'developer.md']);
 191    });
 192  
 193    test('starts with isInitialized false before initialize()', () => {
 194      const fresh = new TestAgent();
 195      assert.strictEqual(fresh.isInitialized, false);
 196      assert.strictEqual(fresh.context, null);
 197    });
 198  });
 199  
 200  // ─── initialize() ──────────────────────────────────────────────────────────────
 201  
 202  describe('BaseAgent - initialize()', () => {
 203    test('sets isInitialized to true', async () => {
 204      const fresh = new TestAgent();
 205      assert.strictEqual(fresh.isInitialized, false);
 206      await fresh.initialize();
 207      assert.strictEqual(fresh.isInitialized, true);
 208      resetDb();
 209      resetTaskDb();
 210      resetMessageDb();
 211    });
 212  
 213    test('is idempotent - multiple calls do not error', async () => {
 214      await agent.initialize();
 215      await agent.initialize();
 216      assert.strictEqual(agent.isInitialized, true);
 217    });
 218  
 219    test('sets contextMetadata after initialize', () => {
 220      assert.ok(agent.contextMetadata !== null && agent.contextMetadata !== undefined);
 221      assert.ok(typeof agent.contextMetadata === 'object');
 222    });
 223  });
 224  
 225  // ─── processTask abstract ──────────────────────────────────────────────────────
 226  
 227  describe('BaseAgent - processTask() abstract method', () => {
 228    test('throws when not overridden in subclass', () => {
 229      class BareAgent extends BaseAgent {
 230        constructor() {
 231          super('developer', ['base.md']);
 232        }
 233        processTask() {
 234          throw new Error('processTask() must be implemented by subclass');
 235        }
 236      }
 237      const bare = new BareAgent();
 238      assert.throws(() => bare.processTask({}), /processTask\(\) must be implemented by subclass/);
 239    });
 240  });
 241  
 242  // ─── completeTask() ────────────────────────────────────────────────────────────
 243  
 244  describe('BaseAgent - completeTask()', () => {
 245    test('marks task as completed', async () => {
 246      const taskId = db
 247        .prepare(
 248          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 249        )
 250        .run().lastInsertRowid;
 251  
 252      await agent.completeTask(taskId, { summary: 'done' });
 253  
 254      const row = db.prepare('SELECT status, result_json FROM agent_tasks WHERE id = ?').get(taskId);
 255      assert.strictEqual(row.status, 'completed');
 256      const result = JSON.parse(row.result_json);
 257      assert.strictEqual(result.summary, 'done');
 258    });
 259  
 260    test('works with null result', async () => {
 261      const taskId = db
 262        .prepare(
 263          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 264        )
 265        .run().lastInsertRowid;
 266  
 267      await agent.completeTask(taskId, null);
 268  
 269      const row = db.prepare('SELECT status FROM agent_tasks WHERE id = ?').get(taskId);
 270      assert.strictEqual(row.status, 'completed');
 271    });
 272  
 273    test('writes a log entry for completion', async () => {
 274      const taskId = db
 275        .prepare(
 276          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 277        )
 278        .run().lastInsertRowid;
 279  
 280      await agent.completeTask(taskId, { result: 'ok' });
 281  
 282      const logs = db
 283        .prepare(`SELECT * FROM agent_logs WHERE agent_name = 'developer' AND task_id = ?`)
 284        .all(taskId);
 285      assert.ok(logs.length > 0);
 286      const completedLog = logs.find(l => l.message.toLowerCase().includes('complete'));
 287      assert.ok(completedLog, 'Expected a completion log entry');
 288    });
 289  });
 290  
 291  // ─── failTask() ────────────────────────────────────────────────────────────────
 292  
 293  describe('BaseAgent - failTask()', () => {
 294    test('marks task as failed with error message', async () => {
 295      const taskId = db
 296        .prepare(
 297          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 298        )
 299        .run().lastInsertRowid;
 300  
 301      await agent.failTask(taskId, 'Something went wrong');
 302  
 303      const row = db
 304        .prepare('SELECT status, error_message FROM agent_tasks WHERE id = ?')
 305        .get(taskId);
 306      assert.strictEqual(row.status, 'failed');
 307      assert.match(row.error_message, /Something went wrong/);
 308    });
 309  
 310    test('writes an error-level log entry', async () => {
 311      const taskId = db
 312        .prepare(
 313          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 314        )
 315        .run().lastInsertRowid;
 316  
 317      await agent.failTask(taskId, 'Test failure');
 318  
 319      const logs = db
 320        .prepare(`SELECT * FROM agent_logs WHERE agent_name = 'developer' AND log_level = 'error'`)
 321        .all();
 322      assert.ok(logs.length > 0);
 323    });
 324  });
 325  
 326  // ─── blockTask() ───────────────────────────────────────────────────────────────
 327  
 328  describe('BaseAgent - blockTask()', () => {
 329    test('marks task as blocked', async () => {
 330      const taskId = db
 331        .prepare(
 332          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 333        )
 334        .run().lastInsertRowid;
 335  
 336      await agent.blockTask(taskId, 'Waiting for approval');
 337  
 338      const row = db
 339        .prepare('SELECT status, error_message FROM agent_tasks WHERE id = ?')
 340        .get(taskId);
 341      assert.strictEqual(row.status, 'blocked');
 342      assert.match(row.error_message, /Waiting for approval/);
 343    });
 344  
 345    test('writes a warn-level log entry', async () => {
 346      const taskId = db
 347        .prepare(
 348          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 349        )
 350        .run().lastInsertRowid;
 351  
 352      await agent.blockTask(taskId, 'needs more info');
 353  
 354      const logs = db
 355        .prepare(`SELECT * FROM agent_logs WHERE agent_name = 'developer' AND log_level = 'warn'`)
 356        .all();
 357      assert.ok(logs.length > 0);
 358    });
 359  });
 360  
 361  // ─── askQuestion() ─────────────────────────────────────────────────────────────
 362  
 363  describe('BaseAgent - askQuestion()', () => {
 364    test('creates a question message to target agent', async () => {
 365      const taskId = db
 366        .prepare(
 367          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 368        )
 369        .run().lastInsertRowid;
 370  
 371      await agent.askQuestion(taskId, 'triage', 'Which file has the bug?');
 372  
 373      const msgs = db
 374        .prepare(
 375          `SELECT * FROM agent_messages
 376         WHERE from_agent = 'developer' AND to_agent = 'triage' AND message_type = 'question'`
 377        )
 378        .all();
 379      assert.strictEqual(msgs.length, 1);
 380      assert.match(msgs[0].content, /Which file has the bug/);
 381    });
 382  
 383    test('stores metadata JSON when provided', async () => {
 384      const taskId = db
 385        .prepare(
 386          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 387        )
 388        .run().lastInsertRowid;
 389  
 390      await agent.askQuestion(taskId, 'triage', 'Need help', { urgency: 'high' });
 391  
 392      const msg = db
 393        .prepare(`SELECT metadata_json FROM agent_messages WHERE message_type = 'question'`)
 394        .get();
 395      const meta = JSON.parse(msg.metadata_json);
 396      assert.strictEqual(meta.urgency, 'high');
 397    });
 398  });
 399  
 400  // ─── sendAnswer() ──────────────────────────────────────────────────────────────
 401  
 402  describe('BaseAgent - sendAnswer()', () => {
 403    test('creates an answer message', async () => {
 404      const taskId = db
 405        .prepare(
 406          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 407        )
 408        .run().lastInsertRowid;
 409  
 410      await agent.sendAnswer(taskId, 'qa', 'The fix is in score.js line 42');
 411  
 412      const msgs = db
 413        .prepare(
 414          `SELECT * FROM agent_messages
 415         WHERE from_agent = 'developer' AND to_agent = 'qa' AND message_type = 'answer'`
 416        )
 417        .all();
 418      assert.strictEqual(msgs.length, 1);
 419      assert.match(msgs[0].content, /score\.js/);
 420    });
 421  });
 422  
 423  // ─── handoff() ─────────────────────────────────────────────────────────────────
 424  
 425  describe('BaseAgent - handoff()', () => {
 426    test('creates a handoff message with metadata', async () => {
 427      const taskId = db
 428        .prepare(
 429          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 430        )
 431        .run().lastInsertRowid;
 432  
 433      await agent.handoff(taskId, 'qa', 'Bug fixed - ready for review', {
 434        files_changed: ['src/score.js'],
 435      });
 436  
 437      const msgs = db
 438        .prepare(
 439          `SELECT * FROM agent_messages
 440         WHERE from_agent = 'developer' AND to_agent = 'qa' AND message_type = 'handoff'`
 441        )
 442        .all();
 443      assert.strictEqual(msgs.length, 1);
 444      assert.match(msgs[0].content, /ready for review/);
 445      const meta = JSON.parse(msgs[0].metadata_json);
 446      assert.deepStrictEqual(meta.files_changed, ['src/score.js']);
 447    });
 448  });
 449  
 450  // ─── notify() ──────────────────────────────────────────────────────────────────
 451  
 452  describe('BaseAgent - notify()', () => {
 453    test('sends a notification message to target agent', async () => {
 454      await agent.notify('qa', 'Pipeline complete', null, { pipeline_id: 7 });
 455  
 456      const msgs = db
 457        .prepare(
 458          `SELECT * FROM agent_messages
 459         WHERE from_agent = 'developer' AND to_agent = 'qa' AND message_type = 'notification'`
 460        )
 461        .all();
 462      assert.ok(msgs.length > 0);
 463      assert.match(msgs[0].content, /Pipeline complete/);
 464    });
 465  });
 466  
 467  // ─── log() ─────────────────────────────────────────────────────────────────────
 468  
 469  describe('BaseAgent - log()', () => {
 470    test('writes info log to agent_logs', async () => {
 471      await agent.log('info', 'Processing started', { step: 1 });
 472  
 473      const logs = db
 474        .prepare(
 475          `SELECT * FROM agent_logs WHERE agent_name = 'developer' AND message = 'Processing started'`
 476        )
 477        .all();
 478      assert.ok(logs.length > 0);
 479      assert.strictEqual(logs[0].log_level, 'info');
 480    });
 481  
 482    test('writes error log to agent_logs', async () => {
 483      await agent.log('error', 'Critical failure', { code: 500 });
 484  
 485      const logs = db
 486        .prepare(
 487          `SELECT * FROM agent_logs WHERE agent_name = 'developer' AND log_level = 'error' AND message = 'Critical failure'`
 488        )
 489        .all();
 490      assert.ok(logs.length > 0);
 491    });
 492  
 493    test('handles null data without error', async () => {
 494      await agent.log('warn', 'No context', null);
 495  
 496      const logs = db.prepare(`SELECT * FROM agent_logs WHERE message = 'No context'`).all();
 497      assert.ok(logs.length > 0);
 498    });
 499  
 500    test('serializes data_json when data is provided', async () => {
 501      await agent.log('info', 'With data', { key: 'value', num: 42 });
 502  
 503      const log = db.prepare(`SELECT data_json FROM agent_logs WHERE message = 'With data'`).get();
 504      assert.ok(log.data_json);
 505      const data = JSON.parse(log.data_json);
 506      assert.strictEqual(data.key, 'value');
 507      assert.strictEqual(data.num, 42);
 508    });
 509  });
 510  
 511  // ─── createTask() ──────────────────────────────────────────────────────────────
 512  
 513  describe('BaseAgent - createTask()', () => {
 514    test('creates a task and returns a numeric ID', async () => {
 515      const taskId = await agent.createTask({
 516        task_type: 'fix_bug',
 517        assigned_to: 'developer',
 518        priority: 7,
 519        context: { error_message: 'Test error' },
 520      });
 521  
 522      assert.ok(typeof taskId === 'number');
 523      assert.ok(taskId > 0);
 524  
 525      const row = db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId);
 526      assert.strictEqual(row.task_type, 'fix_bug');
 527      assert.strictEqual(row.assigned_to, 'developer');
 528      assert.strictEqual(row.priority, 7);
 529    });
 530  
 531    test('serializes context as JSON in context_json', async () => {
 532      const taskId = await agent.createTask({
 533        task_type: 'classify_error',
 534        assigned_to: 'triage',
 535        context: { error_message: 'ENOTFOUND', stage: 'serps' },
 536      });
 537  
 538      const row = db.prepare('SELECT context_json FROM agent_tasks WHERE id = ?').get(taskId);
 539      const ctx = JSON.parse(row.context_json);
 540      assert.strictEqual(ctx.error_message, 'ENOTFOUND');
 541      assert.strictEqual(ctx.stage, 'serps');
 542    });
 543  
 544    test('sets parent_task_id when provided', async () => {
 545      const parentId = db
 546        .prepare(
 547          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('classify_error', 'triage', 'running')`
 548        )
 549        .run().lastInsertRowid;
 550  
 551      const childId = await agent.createTask({
 552        task_type: 'fix_bug',
 553        assigned_to: 'developer',
 554        parent_task_id: parentId,
 555        context: { error_message: 'oops' },
 556      });
 557  
 558      const row = db.prepare('SELECT parent_task_id FROM agent_tasks WHERE id = ?').get(childId);
 559      assert.strictEqual(row.parent_task_id, parentId);
 560    });
 561  
 562    test('defaults priority to 5 when not specified', async () => {
 563      const taskId = await agent.createTask({
 564        task_type: 'fix_bug',
 565        assigned_to: 'developer',
 566        context: {},
 567      });
 568  
 569      const row = db.prepare('SELECT priority FROM agent_tasks WHERE id = ?').get(taskId);
 570      assert.strictEqual(row.priority, 5);
 571    });
 572  });
 573  
 574  // ─── Locking ───────────────────────────────────────────────────────────────────
 575  
 576  describe('BaseAgent - acquireLock() / releaseLock()', () => {
 577    test('acquireLock returns true when agent is idle', () => {
 578      db.prepare(
 579        `INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('developer', 'idle')`
 580      ).run();
 581  
 582      const acquired = agent.acquireLock();
 583      assert.strictEqual(acquired, true);
 584    });
 585  
 586    test('acquireLock returns false when agent is working (non-stale)', () => {
 587      db.prepare(
 588        `INSERT OR REPLACE INTO agent_state (agent_name, status, last_active)
 589         VALUES ('developer', 'working', datetime('now'))`
 590      ).run();
 591  
 592      const acquired = agent.acquireLock();
 593      assert.strictEqual(acquired, false);
 594    });
 595  
 596    test('releaseLock sets status back to idle', () => {
 597      db.prepare(
 598        `INSERT OR REPLACE INTO agent_state (agent_name, status) VALUES ('developer', 'working')`
 599      ).run();
 600  
 601      agent.releaseLock();
 602  
 603      const row = db.prepare(`SELECT status FROM agent_state WHERE agent_name = 'developer'`).get();
 604      assert.strictEqual(row.status, 'idle');
 605    });
 606  
 607    test('stale working lock can be re-acquired after timeout', () => {
 608      db.prepare(
 609        `INSERT OR REPLACE INTO agent_state (agent_name, status, last_active)
 610         VALUES ('developer', 'working', datetime('now', '-10 minutes'))`
 611      ).run();
 612  
 613      process.env.AGENT_LOCK_STALE_MINUTES = '2';
 614      const acquired = agent.acquireLock();
 615      assert.strictEqual(acquired, true);
 616      delete process.env.AGENT_LOCK_STALE_MINUTES;
 617    });
 618  });
 619  
 620  // ─── validateWorkflowDependencies() ───────────────────────────────────────────
 621  
 622  describe('BaseAgent - validateWorkflowDependencies()', () => {
 623    test('returns invalid for implement_feature without parent_task_id', async () => {
 624      const result = await agent.validateWorkflowDependencies({
 625        task_type: 'implement_feature',
 626        assigned_to: 'developer',
 627        parent_task_id: null,
 628      });
 629  
 630      assert.strictEqual(result.valid, false);
 631      assert.ok(result.requiredPrerequisite);
 632      assert.strictEqual(result.requiredPrerequisite.task_type, 'design_proposal');
 633    });
 634  
 635    test('returns invalid when parent task does not exist', async () => {
 636      const result = await agent.validateWorkflowDependencies({
 637        task_type: 'implement_feature',
 638        assigned_to: 'developer',
 639        parent_task_id: 99999,
 640      });
 641  
 642      assert.strictEqual(result.valid, false);
 643      assert.match(result.reason, /not found/);
 644    });
 645  
 646    test('returns invalid when parent design_proposal is pending', async () => {
 647      const parentId = db
 648        .prepare(
 649          `INSERT INTO agent_tasks (task_type, assigned_to, status)
 650         VALUES ('design_proposal', 'architect', 'pending')`
 651        )
 652        .run().lastInsertRowid;
 653  
 654      const result = await agent.validateWorkflowDependencies({
 655        task_type: 'implement_feature',
 656        assigned_to: 'developer',
 657        parent_task_id: parentId,
 658      });
 659  
 660      assert.strictEqual(result.valid, false);
 661    });
 662  
 663    test('returns invalid when parent completed but not PO-approved', async () => {
 664      const parentId = db
 665        .prepare(
 666          `INSERT INTO agent_tasks (task_type, assigned_to, status)
 667         VALUES ('design_proposal', 'architect', 'completed')`
 668        )
 669        .run().lastInsertRowid;
 670  
 671      const result = await agent.validateWorkflowDependencies({
 672        task_type: 'implement_feature',
 673        assigned_to: 'developer',
 674        parent_task_id: parentId,
 675      });
 676  
 677      assert.strictEqual(result.valid, false);
 678      assert.match(result.reason, /approval/i);
 679    });
 680  
 681    test('returns valid when parent is completed and PO-approved', async () => {
 682      const parentId = db
 683        .prepare(
 684          `INSERT INTO agent_tasks (task_type, assigned_to, status, approval_json)
 685         VALUES ('design_proposal', 'architect', 'completed', ?)`
 686        )
 687        .run(JSON.stringify({ decision: 'approved', reviewer: 'PO' })).lastInsertRowid;
 688  
 689      const result = await agent.validateWorkflowDependencies({
 690        task_type: 'implement_feature',
 691        assigned_to: 'developer',
 692        parent_task_id: parentId,
 693      });
 694  
 695      assert.strictEqual(result.valid, true);
 696    });
 697  
 698    test('returns valid for fix_bug (no parent required)', async () => {
 699      const result = await agent.validateWorkflowDependencies({
 700        task_type: 'fix_bug',
 701        assigned_to: 'developer',
 702        parent_task_id: null,
 703      });
 704      assert.strictEqual(result.valid, true);
 705    });
 706  
 707    test('returns valid for verify_fix (no parent required)', async () => {
 708      const result = await agent.validateWorkflowDependencies({
 709        task_type: 'verify_fix',
 710        assigned_to: 'qa',
 711        parent_task_id: null,
 712      });
 713      assert.strictEqual(result.valid, true);
 714    });
 715  });
 716  
 717  // ─── getContext / getContextMetadata ───────────────────────────────────────────
 718  
 719  describe('BaseAgent - getContext() / getContextMetadata()', () => {
 720    test('getContext returns a string or null after initialize', () => {
 721      const ctx = agent.getContext();
 722      assert.ok(ctx === null || typeof ctx === 'string');
 723    });
 724  
 725    test('getContextMetadata returns object with files and sizeKB', () => {
 726      const meta = agent.getContextMetadata();
 727      assert.ok(meta !== null && typeof meta === 'object');
 728      assert.ok('files' in meta);
 729      assert.ok('sizeKB' in meta);
 730    });
 731  });
 732  
 733  // ─── pollTasks() ───────────────────────────────────────────────────────────────
 734  
 735  describe('BaseAgent - pollTasks()', () => {
 736    test('returns 0 when no pending tasks available', async () => {
 737      db.prepare(
 738        `INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('developer', 'idle')`
 739      ).run();
 740  
 741      process.env.AGENT_ALLOW_CONCURRENT_INSTANCES = 'true';
 742      const count = await agent.pollTasks(5);
 743      assert.strictEqual(count, 0);
 744      delete process.env.AGENT_ALLOW_CONCURRENT_INSTANCES;
 745    });
 746  
 747    test('processes pending tasks and returns count', async () => {
 748      db.prepare(
 749        `INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('developer', 'idle')`
 750      ).run();
 751      db.prepare(
 752        `INSERT INTO agent_tasks (task_type, assigned_to, status, priority)
 753         VALUES ('fix_bug', 'developer', 'pending', 5)`
 754      ).run();
 755  
 756      process.env.AGENT_ALLOW_CONCURRENT_INSTANCES = 'true';
 757      const count = await agent.pollTasks(1);
 758      assert.ok(count >= 0);
 759      delete process.env.AGENT_ALLOW_CONCURRENT_INSTANCES;
 760    });
 761  });
 762  
 763  // ─── recordOutcome() ──────────────────────────────────────────────────────────
 764  
 765  describe('BaseAgent - recordOutcome()', () => {
 766    test('records a success outcome for a task', async () => {
 767      const taskId = db
 768        .prepare(
 769          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'completed')`
 770        )
 771        .run().lastInsertRowid;
 772  
 773      await agent.recordOutcome(taskId, 'success', { error_type: 'null_pointer', duration_ms: 500 });
 774  
 775      const outcome = db.prepare('SELECT * FROM agent_outcomes WHERE task_id = ?').get(taskId);
 776      assert.ok(outcome);
 777      assert.strictEqual(outcome.outcome, 'success');
 778      assert.strictEqual(outcome.agent_name, 'developer');
 779      assert.strictEqual(outcome.task_type, 'fix_bug');
 780    });
 781  
 782    test('records a failure outcome for a task', async () => {
 783      const taskId = db
 784        .prepare(
 785          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'failed')`
 786        )
 787        .run().lastInsertRowid;
 788  
 789      await agent.recordOutcome(taskId, 'failure', { error: 'Something went wrong' });
 790  
 791      const outcome = db.prepare('SELECT * FROM agent_outcomes WHERE task_id = ?').get(taskId);
 792      assert.ok(outcome);
 793      assert.strictEqual(outcome.outcome, 'failure');
 794    });
 795  
 796    test('throws for invalid outcome value', async () => {
 797      const taskId = db
 798        .prepare(
 799          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'completed')`
 800        )
 801        .run().lastInsertRowid;
 802  
 803      await assert.rejects(() => agent.recordOutcome(taskId, 'invalid_outcome'), /Invalid outcome/);
 804    });
 805  
 806    test('throws when task does not exist', async () => {
 807      await assert.rejects(() => agent.recordOutcome(99999, 'success'), /not found/);
 808    });
 809  
 810    test('records outcome with null context and result', async () => {
 811      const taskId = db
 812        .prepare(
 813          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'completed')`
 814        )
 815        .run().lastInsertRowid;
 816  
 817      // Empty context and result objects
 818      await agent.recordOutcome(taskId, 'success', {}, {});
 819      const outcome = db.prepare('SELECT * FROM agent_outcomes WHERE task_id = ?').get(taskId);
 820      assert.ok(outcome);
 821      assert.strictEqual(outcome.outcome, 'success');
 822    });
 823  });
 824  
 825  // ─── learnFromPastOutcomes() ───────────────────────────────────────────────────
 826  
 827  describe('BaseAgent - learnFromPastOutcomes()', () => {
 828    test('returns empty insights when no outcomes exist', async () => {
 829      const insights = await agent.learnFromPastOutcomes('fix_bug');
 830      assert.strictEqual(insights.task_type, 'fix_bug');
 831      assert.strictEqual(insights.total_outcomes, 0);
 832      assert.strictEqual(insights.success_rate, 0);
 833    });
 834  
 835    test('returns correct success rate with mixed outcomes', async () => {
 836      // Create tasks and outcomes
 837      for (let i = 0; i < 3; i++) {
 838        const taskId = db
 839          .prepare(
 840            `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'completed')`
 841          )
 842          .run().lastInsertRowid;
 843        db.prepare(
 844          `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, duration_ms, created_at)
 845           VALUES (?, 'developer', 'fix_bug', 'success', 1000, CURRENT_TIMESTAMP)`
 846        ).run(taskId);
 847      }
 848      for (let i = 0; i < 1; i++) {
 849        const taskId = db
 850          .prepare(
 851            `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'failed')`
 852          )
 853          .run().lastInsertRowid;
 854        db.prepare(
 855          `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, duration_ms, created_at)
 856           VALUES (?, 'developer', 'fix_bug', 'failure', 500, CURRENT_TIMESTAMP)`
 857        ).run(taskId);
 858      }
 859  
 860      const insights = await agent.learnFromPastOutcomes('fix_bug');
 861      assert.strictEqual(insights.total_outcomes, 4);
 862      assert.strictEqual(insights.success_count, 3);
 863      assert.strictEqual(insights.failure_count, 1);
 864      assert.ok(insights.success_rate === 75);
 865    });
 866  
 867    test('returns avg duration from outcomes', async () => {
 868      const taskId = db
 869        .prepare(
 870          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('verify_fix', 'developer', 'completed')`
 871        )
 872        .run().lastInsertRowid;
 873      db.prepare(
 874        `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, duration_ms, created_at)
 875         VALUES (?, 'developer', 'verify_fix', 'success', 2000, CURRENT_TIMESTAMP)`
 876      ).run(taskId);
 877  
 878      const insights = await agent.learnFromPastOutcomes('verify_fix');
 879      assert.ok(insights.avg_duration_ms > 0);
 880    });
 881  
 882    test('extracts context patterns from outcomes', async () => {
 883      const taskId = db
 884        .prepare(
 885          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'completed')`
 886        )
 887        .run().lastInsertRowid;
 888      db.prepare(
 889        `INSERT INTO agent_outcomes (task_id, agent_name, task_type, outcome, context_json, created_at)
 890         VALUES (?, 'developer', 'fix_bug', 'success', ?, CURRENT_TIMESTAMP)`
 891      ).run(taskId, JSON.stringify({ error_type: 'null_pointer' }));
 892  
 893      const insights = await agent.learnFromPastOutcomes('fix_bug');
 894      assert.ok(insights.context_patterns);
 895    });
 896  
 897    test('includes recommendations in insights', async () => {
 898      const insights = await agent.learnFromPastOutcomes('nonexistent_type');
 899      assert.ok(Array.isArray(insights.recommendations) || insights.insights);
 900    });
 901  });
 902  
 903  // ─── requestPoApproval() ───────────────────────────────────────────────────────
 904  
 905  describe('BaseAgent - requestPoApproval()', () => {
 906    test('blocks task and adds to human review queue', async () => {
 907      const taskId = db
 908        .prepare(
 909          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('design_proposal', 'architect', 'running')`
 910        )
 911        .run().lastInsertRowid;
 912  
 913      await agent.requestPoApproval(taskId, {
 914        title: 'New Feature Design',
 915        summary: 'Redesign the login flow',
 916        priority: 'high',
 917      });
 918  
 919      const task = db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId);
 920      assert.strictEqual(task.status, 'blocked');
 921      assert.match(task.error_message, /Product Owner/i);
 922    });
 923  
 924    test('adds item to human review queue', async () => {
 925      const taskId = db
 926        .prepare(
 927          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('design_proposal', 'architect', 'running')`
 928        )
 929        .run().lastInsertRowid;
 930  
 931      await agent.requestPoApproval(taskId, {
 932        title: 'Test Proposal',
 933        summary: 'Test summary',
 934        priority: 'medium',
 935      });
 936  
 937      // human_review_queue is populated by addReviewItem - check by type 'architecture'
 938      const reviewItems = db
 939        .prepare(`SELECT * FROM human_review_queue WHERE type = 'architecture'`)
 940        .all();
 941      assert.ok(reviewItems.length > 0, 'Should add item to human review queue');
 942    });
 943  });
 944  
 945  // ─── requestArchitectApproval() ────────────────────────────────────────────────
 946  
 947  describe('BaseAgent - requestArchitectApproval()', () => {
 948    test('blocks task and creates architect review task', async () => {
 949      const taskId = db
 950        .prepare(
 951          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
 952        )
 953        .run().lastInsertRowid;
 954  
 955      await agent.requestArchitectApproval(taskId, {
 956        title: 'Implementation Plan',
 957        summary: 'Refactor database layer',
 958      });
 959  
 960      const task = db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId);
 961      assert.strictEqual(task.status, 'blocked');
 962      assert.match(task.error_message, /Architect/i);
 963  
 964      // Check architect review task was created
 965      const architectTask = db
 966        .prepare(
 967          `SELECT * FROM agent_tasks WHERE task_type = 'technical_review' AND assigned_to = 'architect' AND parent_task_id = ?`
 968        )
 969        .get(taskId);
 970      assert.ok(architectTask, 'Should create architect technical_review task');
 971    });
 972  });
 973  
 974  // ─── approveTask() ─────────────────────────────────────────────────────────────
 975  
 976  describe('BaseAgent - approveTask()', () => {
 977    test('records approval and marks task as completed', async () => {
 978      const taskId = db
 979        .prepare(
 980          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('design_proposal', 'architect', 'blocked')`
 981        )
 982        .run().lastInsertRowid;
 983  
 984      await agent.approveTask(taskId, 'ProductOwner', {
 985        decision: 'approved',
 986        notes: 'Looks good',
 987        conditions: [],
 988      });
 989  
 990      const task = db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId);
 991      assert.strictEqual(task.status, 'completed');
 992      assert.ok(task.approval_json);
 993      const approval = JSON.parse(task.approval_json);
 994      assert.strictEqual(approval.decision, 'approved');
 995      assert.strictEqual(approval.reviewer, 'ProductOwner');
 996    });
 997  
 998    test('records rejection with notes', async () => {
 999      const taskId = db
1000        .prepare(
1001          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('design_proposal', 'architect', 'blocked')`
1002        )
1003        .run().lastInsertRowid;
1004  
1005      await agent.approveTask(taskId, 'Reviewer', {
1006        decision: 'rejected',
1007        notes: 'Needs more work',
1008        conditions: [],
1009      });
1010  
1011      const task = db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId);
1012      const approval = JSON.parse(task.approval_json);
1013      assert.strictEqual(approval.decision, 'rejected');
1014    });
1015  });
1016  
1017  // ─── acquireNextTask() with row locking ────────────────────────────────────────
1018  
1019  describe('BaseAgent - acquireNextTask() row locking', () => {
1020    test('acquires task with row-level locking enabled', () => {
1021      process.env.AGENT_ENABLE_ROW_LOCKING = 'true';
1022      db.prepare(
1023        `INSERT OR IGNORE INTO agent_state (agent_name, status) VALUES ('developer', 'idle')`
1024      ).run();
1025      db.prepare(
1026        `INSERT INTO agent_tasks (task_type, assigned_to, status, priority)
1027         VALUES ('fix_bug', 'developer', 'pending', 8)`
1028      ).run();
1029  
1030      const task = agent.acquireNextTask();
1031      if (task) {
1032        assert.strictEqual(task.task_type, 'fix_bug');
1033        const dbTask = db.prepare('SELECT status FROM agent_tasks WHERE id = ?').get(task.id);
1034        assert.strictEqual(dbTask.status, 'running');
1035      }
1036      delete process.env.AGENT_ENABLE_ROW_LOCKING;
1037    });
1038  
1039    test('returns null when no tasks pending (row locking)', () => {
1040      process.env.AGENT_ENABLE_ROW_LOCKING = 'true';
1041      const task = agent.acquireNextTask();
1042      assert.strictEqual(task, null);
1043      delete process.env.AGENT_ENABLE_ROW_LOCKING;
1044    });
1045  
1046    test('acquires task with row-level locking disabled (fallback)', () => {
1047      process.env.AGENT_ENABLE_ROW_LOCKING = 'false';
1048      db.prepare(
1049        `INSERT INTO agent_tasks (task_type, assigned_to, status, priority)
1050         VALUES ('fix_bug', 'developer', 'pending', 8)`
1051      ).run();
1052  
1053      const task = agent.acquireNextTask();
1054      // Should get task via fallback path
1055      assert.ok(task === null || task.task_type === 'fix_bug');
1056      delete process.env.AGENT_ENABLE_ROW_LOCKING;
1057    });
1058  });
1059  
1060  // ─── getUnreadMessages() / markMessageRead() ──────────────────────────────────
1061  
1062  describe('BaseAgent - getUnreadMessages() / markMessageRead()', () => {
1063    test('returns unread messages for agent', () => {
1064      db.prepare(
1065        `INSERT INTO agent_messages (task_id, from_agent, to_agent, message_type, content, created_at)
1066         VALUES (NULL, 'triage', 'developer', 'notification', 'Test message', CURRENT_TIMESTAMP)`
1067      ).run();
1068  
1069      const messages = agent.getUnreadMessages();
1070      assert.ok(Array.isArray(messages));
1071    });
1072  
1073    test('markMessageRead marks message as read', () => {
1074      const msgId = db
1075        .prepare(
1076          `INSERT INTO agent_messages (task_id, from_agent, to_agent, message_type, content, created_at)
1077         VALUES (NULL, 'triage', 'developer', 'notification', 'Read test', CURRENT_TIMESTAMP)`
1078        )
1079        .run().lastInsertRowid;
1080  
1081      agent.markMessageRead(msgId);
1082  
1083      const msg = db.prepare('SELECT * FROM agent_messages WHERE id = ?').get(msgId);
1084      assert.ok(msg.read_at !== null, 'Message should be marked as read');
1085    });
1086  });
1087  
1088  // ─── hasPendingQuestions() ─────────────────────────────────────────────────────
1089  
1090  describe('BaseAgent - hasPendingQuestions()', () => {
1091    test('returns boolean for pending questions', () => {
1092      const result = agent.hasPendingQuestions();
1093      assert.ok(typeof result === 'boolean');
1094    });
1095  
1096    test('returns true when question message exists', () => {
1097      const taskId = db
1098        .prepare(
1099          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
1100        )
1101        .run().lastInsertRowid;
1102  
1103      db.prepare(
1104        `INSERT INTO agent_messages (task_id, from_agent, to_agent, message_type, content, created_at)
1105         VALUES (?, 'triage', 'developer', 'question', 'What is the root cause?', CURRENT_TIMESTAMP)`
1106      ).run(taskId);
1107  
1108      const hasPending = agent.hasPendingQuestions();
1109      assert.strictEqual(hasPending, true);
1110    });
1111  });
1112  
1113  // ─── updateTask() ─────────────────────────────────────────────────────────────
1114  
1115  describe('BaseAgent - updateTask()', () => {
1116    test('updates task error_message', () => {
1117      const taskId = db
1118        .prepare(
1119          `INSERT INTO agent_tasks (task_type, assigned_to, status) VALUES ('fix_bug', 'developer', 'running')`
1120        )
1121        .run().lastInsertRowid;
1122  
1123      agent.updateTask(taskId, { error_message: 'Updated error' });
1124  
1125      const task = db.prepare('SELECT * FROM agent_tasks WHERE id = ?').get(taskId);
1126      assert.match(task.error_message, /Updated error/);
1127    });
1128  
1129    test('throws when task not found', () => {
1130      assert.throws(() => agent.updateTask(99999, { error_message: 'test' }), /not found/);
1131    });
1132  });
1133  
1134  // ─── findDuplicateTask() ───────────────────────────────────────────────────────
1135  
1136  describe('BaseAgent - findDuplicateTask()', () => {
1137    test('returns null for task types that allow duplicates', () => {
1138      const result = agent.findDuplicateTask({
1139        task_type: 'implement_feature',
1140        assigned_to: 'developer',
1141        context: { description: 'New feature' },
1142      });
1143      assert.strictEqual(result, null);
1144    });
1145  
1146    test('does not throw when checking for duplicate classify_error task', () => {
1147      // findDuplicateTask catches errors internally and returns null on failure
1148      // This tests that the deduplication path for fix_bug/classify_error is exercised
1149      const result = agent.findDuplicateTask({
1150        task_type: 'classify_error',
1151        assigned_to: 'triage',
1152        context: { error_message: 'UNIQUE constraint failed: messages.site_id' },
1153      });
1154      // Either returns null (not found or DB access failed gracefully) or an object
1155      assert.ok(result === null || typeof result === 'object');
1156    });
1157  
1158    test('returns null when no duplicate exists', () => {
1159      const result = agent.findDuplicateTask({
1160        task_type: 'fix_bug',
1161        assigned_to: 'developer',
1162        context: { error_message: 'Unique error message XYZ123' },
1163      });
1164      assert.strictEqual(result, null);
1165    });
1166  
1167    test('monitoring task types use null dedupeField (type-only dedup)', () => {
1168      // Monitoring task dedup path: dedupeKey = task_type, dedupeField = null
1169      // This exercises the monitoring task branch in findDuplicateTask
1170      const result = agent.findDuplicateTask({
1171        task_type: 'scan_logs',
1172        assigned_to: 'monitor',
1173        context: {},
1174      });
1175      // Returns null (not found) or object (found) - either is valid, no throw
1176      assert.ok(result === null || typeof result === 'object');
1177    });
1178  });
1179  
1180  // ─── Tool methods ─────────────────────────────────────────────────────────────
1181  
1182  describe('BaseAgent - direct tool methods', () => {
1183    test('fileExistsTool returns boolean for existing file', async () => {
1184      const exists = await agent.fileExistsTool('package.json');
1185      assert.strictEqual(exists, true);
1186    });
1187  
1188    test('fileExistsTool returns false for non-existent file', async () => {
1189      const exists = await agent.fileExistsTool('nonexistent-file-xyz.js');
1190      assert.strictEqual(exists, false);
1191    });
1192  
1193    test('readFileTool reads file content', async () => {
1194      const content = await agent.readFileTool('package.json');
1195      assert.ok(typeof content === 'string');
1196      assert.match(content, /name/);
1197    });
1198  
1199    test('searchFilesTool searches for pattern', async () => {
1200      const results = await agent.searchFilesTool('BaseAgent', 'src/agents/');
1201      assert.ok(typeof results === 'string');
1202    });
1203  
1204    test('globFilesTool finds files matching pattern', async () => {
1205      const files = await agent.globFilesTool('*.js', 'src/agents/');
1206      assert.ok(Array.isArray(files) || typeof files === 'object');
1207    });
1208  
1209    test('listFilesTool lists files in directory', async () => {
1210      const files = await agent.listFilesTool('src/agents/');
1211      assert.ok(Array.isArray(files) || typeof files === 'object');
1212    });
1213  
1214    test('runCommandTool executes simple command', async () => {
1215      const result = await agent.runCommandTool('echo hello');
1216      assert.ok(result.stdout.trim() === 'hello' || typeof result.stdout === 'string');
1217    });
1218  });