graph-agent-p1-acceptance.test.js 18.4 KB
/**
 * End-to-end acceptance test — P1 虚拟引用寻址
 *
 * Verifies all 6 acceptance criteria from
 * docs/todo/system-design/27-p1-virtual-reference.md §6, against the real
 * hire() -> getAgentConfig() -> promptBuilder.build() pipeline (not mocks),
 * including the §4.6 / §7 naming-collision regression (criterion 6) using
 * the real `evolver-youtube-crawler` id that genuinely collides between
 * system/agents/ and system/templates/ in this repo.
 *
 * Isolation:
 *   - GRAPH_DB_PATH points at a throwaway SQLite file for the whole file
 *     (same pattern as server/tests/unit/graph-db.test.js) — the real
 *     data/graph.sqlite is opened once, read-only, only to assert that the
 *     production seed data for the collision id actually has the shape the
 *     design doc claims (no `source_kind='template'` row) before mirroring
 *     that exact shape into the isolated test DB. No writes ever hit the
 *     real data/graph.sqlite.
 *   - All hired agents live under temporary company ids
 *     (`tmp-p1-acceptance-*`) removed in afterAll.
 *   - The one legacy (pre-P1) company used for criterion 4 is the real
 *     `idearun` company, accessed read-only only (getAgentConfig / build),
 *     never hired into or written to.
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import Database from 'better-sqlite3';

describe('P1 virtual reference addressing — full acceptance suite (27-p1-virtual-reference.md §6)', () => {
  const REPO_ROOT = path.resolve(__dirname, '../../..');
  const TEST_DB_PATH = '/tmp/test-graph-agent-p1-acceptance.sqlite';
  const REAL_GRAPH_DB_PATH = path.join(REPO_ROOT, 'data/graph.sqlite');
  const DATA_DIR = path.join(REPO_ROOT, 'data/companies');
  const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates');

  const RUN_ID = Date.now();
  const CO_QA = `tmp-p1-acceptance-qa-${RUN_ID}`;
  const CO_UNKNOWN = `tmp-p1-acceptance-unknown-${RUN_ID}`;
  const CO_COLLISION = `tmp-p1-acceptance-collision-${RUN_ID}`;
  const UNKNOWN_ROLE = `tmp-p1-nonexistent-role-${RUN_ID}-${Math.random().toString(36).slice(2)}`;
  const COLLISION_ROLE = 'evolver-youtube-crawler'; // real 27§4.6/§7 collision id
  const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');

  let graphDb;
  let CompanyService;
  let companyService;
  let AgentManager;
  let registrySnapshot; // hire() bumps _registry.json usedCount for real templates as a side
  // effect (company-service.js _updateTemplateUsedCount) — snapshot/restore so hiring the
  // real qa-judge/evolver-youtube-crawler templates in this test never leaves a permanent
  // diff on a tracked file.

  beforeAll(() => {
    registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8');
    // --- Sanity check against the REAL (already-seeded, committed 8421f55) graph.sqlite,
    // read-only: confirm the production seed data genuinely has the shape the design doc
    // claims for the collision id (no source_kind='template' row). This is what makes
    // criterion 6 a real regression test instead of a test of hand-picked fixture data.
    expect(fs.existsSync(REAL_GRAPH_DB_PATH)).toBe(true);
    const realDb = new Database(REAL_GRAPH_DB_PATH, { readonly: true });
    try {
      const rows = realDb.prepare('SELECT source_kind, legacy_path FROM system_agents WHERE id = ?').all(COLLISION_ROLE);
      expect(rows.length).toBeGreaterThan(0);
      expect(rows.some(r => r.source_kind === 'template')).toBe(false);
      expect(rows.some(r => r.source_kind === 'global_agent')).toBe(true);
    } finally {
      realDb.close();
    }
    // Also confirm the physical files this test depends on actually exist in the repo,
    // so a passing test means something (not vacuously true because fixtures were missing).
    expect(fs.existsSync(path.join(TEMPLATES_DIR, 'qa-judge', 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(TEMPLATES_DIR, COLLISION_ROLE, 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(REPO_ROOT, 'system/agents', COLLISION_ROLE, 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(DATA_DIR, 'idearun', 'agents', COLLISION_ROLE, 'agent.md'))).toBe(true);

    // --- Now switch to the isolated test DB for everything the test actually mutates.
    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    delete require.cache[require.resolve('../../services/graph-db')];
    delete require.cache[require.resolve('../../services/graph-service')];
    delete require.cache[require.resolve('../../services/company-service')];
    delete require.cache[require.resolve('../../src/core/agent-manager')];
    delete require.cache[require.resolve('../../src/core/prompt-builder')];
    graphDb = require('../../services/graph-db');
    ({ CompanyService } = require('../../services/company-service'));
    AgentManager = require('../../src/core/agent-manager');
    companyService = new CompanyService();

    const db = graphDb.getDb();
    const now = new Date().toISOString();

    // Mirror the real production seed for qa-judge (source_kind='template').
    db.prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?)
    `).run('qa-judge', 'qa-judge', 'system/templates/qa-judge', now, now);

    // Mirror the real production seed for the collision id: only the global_agent row
    // exists (seedTemplates() skipped the template row because seedGlobalAgents() claimed
    // the id first) — verified against the real DB above.
    db.prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', NULL, ?, ?)
    `).run(COLLISION_ROLE, COLLISION_ROLE, `system/agents/${COLLISION_ROLE}`, now, now);

    companyService.create({ id: CO_QA, name: 'P1 Acceptance QA Co', description: 'Verifies QA-judge lives here.' });
    companyService.create({ id: CO_UNKNOWN, name: 'P1 Acceptance Unknown-role Co', description: 'Untrained role fallback.' });
    companyService.create({ id: CO_COLLISION, name: 'P1 Acceptance Collision Co', description: 'Naming collision regression.' });
  });

  afterAll(() => {
    for (const id of [CO_QA, CO_UNKNOWN, CO_COLLISION]) {
      try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {}
    }
    try { fs.unlinkSync(TEST_DB_PATH); } catch {}
    try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
    try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
    delete process.env.GRAPH_DB_PATH;
    if (registrySnapshot !== undefined) fs.writeFileSync(REGISTRY_PATH, registrySnapshot, 'utf-8');
  });

  // ─────────────────────────────────────────────────────────────────
  // Criterion 1: hire() of a system_agents template role -> reference mode
  // ─────────────────────────────────────────────────────────────────
  it('criterion 1: hire(companyId, "qa-judge") uses reference mode (no agent.md, company-context.md, systemAgentRef, org_graph_nodes row)', () => {
    const result = companyService.hire(CO_QA, 'qa-judge', {});
    expect(result.status).toBe('hired_from_template');

    const agentDir = result.agentDir;
    expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false);

    const contextPath = path.join(agentDir, 'company-context.md');
    expect(fs.existsSync(contextPath)).toBe(true);
    const context = fs.readFileSync(contextPath, 'utf-8');
    expect(context).toContain(CO_QA);
    expect(context).toContain('P1 Acceptance QA Co');
    expect(context).toContain('Verifies QA-judge lives here.');

    const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8'));
    expect(config.systemAgentRef).toBe('qa-judge');

    const db = graphDb.getDb();
    const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_QA, 'qa-judge');
    expect(node).toBeTruthy();
    expect(node.system_agent_id).toBe('qa-judge');
  });

  // ─────────────────────────────────────────────────────────────────
  // Criterion 2: final prompt = real shared agent.md content + company-context.md
  // ─────────────────────────────────────────────────────────────────
  it('criterion 2: buildFullPrompt() assembles the real shared system/templates/qa-judge/agent.md content plus the company-context.md section', () => {
    const manager = new AgentManager({}, REPO_ROOT);
    const agentName = `${CO_QA}:qa-judge`;
    const { agent } = manager.getAgentConfig(agentName);

    const expectedSharedPath = path.resolve(REPO_ROOT, 'system/templates/qa-judge', 'agent.md');
    expect(agent.prompt).toBe(expectedSharedPath);

    const realSharedContent = fs.readFileSync(expectedSharedPath, 'utf-8');
    const fullPrompt = manager.buildFullPrompt(agentName, 'test task');

    // The full assembled prompt carries the exact real content of the shared template file.
    expect(fullPrompt).toContain(realSharedContent);
    // ... and the private company-context.md section (proves the two-part assembly from §4.3).
    expect(fullPrompt).toContain('## 所属公司');
    expect(fullPrompt).toContain(CO_QA);
    expect(fullPrompt).toContain('P1 Acceptance QA Co');

    // Regression guard for the "改共享文件后无需重新 hire 即可生效" claim in criterion 2:
    // prompt-builder must not have baked a stale copy into the private dir — there is no
    // agent.md in the private dir to have gone stale in the first place.
    const agentDir = path.join(DATA_DIR, CO_QA, 'agents/qa-judge');
    expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false);
  });

  // ─────────────────────────────────────────────────────────────────
  // Criterion 3: role absent from system_agents -> legacy full copy, no error
  // ─────────────────────────────────────────────────────────────────
  it('criterion 3: hiring a role not in system_agents falls back to legacy full copy (including agent.md) without throwing', () => {
    // UNKNOWN_ROLE has no system_agents row (never seeded) AND no template directory at
    // all, so hire() must return needs_training rather than throw. To exercise the
    // "template exists on disk but has no system_agents row" branch specifically (the one
    // §4.1/§4.2 describe as the legacy-fallback trigger), also create a throwaway template
    // dir that deliberately is NOT registered in system_agents.
    const looseTemplateRole = `tmp-p1-loose-template-${RUN_ID}`;
    const looseTemplateDir = path.join(TEMPLATES_DIR, looseTemplateRole);
    fs.mkdirSync(looseTemplateDir, { recursive: true });
    fs.writeFileSync(path.join(looseTemplateDir, 'agent.md'), '# unregistered fixture agent\n', 'utf-8');
    fs.writeFileSync(path.join(looseTemplateDir, 'config.json'), JSON.stringify({ type: 'skill', name: looseTemplateRole }), 'utf-8');

    try {
      let result;
      expect(() => { result = companyService.hire(CO_UNKNOWN, looseTemplateRole, {}); }).not.toThrow();
      expect(result.status).toBe('hired_from_template');

      const agentDir = result.agentDir;
      expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(true);
      const content = fs.readFileSync(path.join(agentDir, 'agent.md'), 'utf-8');
      expect(content).toContain('unregistered fixture agent');
      expect(fs.existsSync(path.join(agentDir, 'company-context.md'))).toBe(false);

      const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8'));
      expect(config.systemAgentRef).toBeUndefined();
    } finally {
      try { fs.rmSync(looseTemplateDir, { recursive: true, force: true }); } catch {}
    }

    // And confirm hire() also degrades gracefully (no throw) for a role that has neither a
    // system_agents row nor a template directory at all.
    // P2 起(27-p2-org-graph.md §4.1 第 7 步)这条分支从 'needs_training' 改为
    // 'hired_from_base' —— 先从 system/templates/base/ 元 agent 继承出带四段式生命周期的骨架,
    // 再交给 agent-creator 填充。needsTraining 仍为 true,路由层的训练触发条件不变。
    let unknownResult;
    expect(() => { unknownResult = companyService.hire(CO_UNKNOWN, UNKNOWN_ROLE, {}); }).not.toThrow();
    expect(unknownResult.status).toBe('hired_from_base');
    expect(unknownResult.needsTraining).toBe(true);
  });

  // ─────────────────────────────────────────────────────────────────
  // Criterion 6 (design doc numbering) — naming collision regression
  // ─────────────────────────────────────────────────────────────────
  it('criterion 6 [KEY REGRESSION]: hire("evolver-youtube-crawler") on a brand-new company falls back to legacy full copy, never redirecting to system/agents/', () => {
    const result = companyService.hire(CO_COLLISION, COLLISION_ROLE, {});
    expect(result.status).toBe('hired_from_template');

    const agentDir = result.agentDir;
    const agentMdPath = path.join(agentDir, 'agent.md');
    expect(fs.existsSync(agentMdPath)).toBe(true); // legacy path -> agent.md IS physically copied

    const copiedContent = fs.readFileSync(agentMdPath, 'utf-8');
    const templateContent = fs.readFileSync(path.join(TEMPLATES_DIR, COLLISION_ROLE, 'agent.md'), 'utf-8');
    const globalAgentContent = fs.readFileSync(path.join(REPO_ROOT, 'system/agents', COLLISION_ROLE, 'agent.md'), 'utf-8');

    // These two source files are confirmed to genuinely differ in this repo (27§7).
    expect(templateContent).not.toBe(globalAgentContent);

    // The copied file must match system/templates/ (what _copyTemplate() actually reads),
    // and must NOT be — or even coincidentally equal — the system/agents/ (global_agent) version.
    // (companyContext gets appended in legacy mode, so compare via startsWith/contains rather
    // than strict equality.)
    expect(copiedContent.startsWith(templateContent)).toBe(true);
    expect(copiedContent).not.toContain(globalAgentContent.split('\n')[0]); // global_agent's distinct H1 heading

    // Reference-mode artifacts must be absent — this id must NOT have taken the reference branch.
    expect(fs.existsSync(path.join(agentDir, 'company-context.md'))).toBe(false);
    const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8'));
    expect(config.systemAgentRef).toBeUndefined();

    // graph-service.lookupSharedAgentMd itself must report "no reference" for this id,
    // proving the source_kind='template' filter — not just hire()'s outer branching — is correct.
    const graphService = require('../../services/graph-service');
    expect(graphService.lookupSharedAgentMd(COLLISION_ROLE)).toBeNull();

    // Still gets an audit org_graph_nodes row even on the legacy path.
    const db = graphDb.getDb();
    const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_COLLISION, COLLISION_ROLE);
    expect(node).toBeTruthy();
  });

  // ─────────────────────────────────────────────────────────────────
  // Criterion 5 (design doc numbering) — idempotency
  // ─────────────────────────────────────────────────────────────────
  it('criterion 5: hiring the same (companyId, role) twice short-circuits to already_exists and does not duplicate org_graph_nodes', () => {
    const first = companyService.hire(CO_QA, 'qa-judge', {}); // already hired in criterion-1 test above
    expect(first.status).toBe('already_exists');

    const second = companyService.hire(CO_QA, 'qa-judge', {});
    expect(second.status).toBe('already_exists');

    const db = graphDb.getDb();
    const count = db.prepare('SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_QA, 'qa-judge').c;
    expect(count).toBe(1);
  });

  // ─────────────────────────────────────────────────────────────────
  // Criterion 4 (design doc numbering) — pre-P1 legacy company unaffected
  // ─────────────────────────────────────────────────────────────────
  it('criterion 4: a pre-P1 legacy company (real "idearun", never touched by this test) still resolves through the old private-agent.md path, not the new reference branch', () => {
    // Read-only against real, already-hired production data — proves old instances are
    // untouched by the new code path rather than merely "not tested".
    const legacyAgentDir = path.join(DATA_DIR, 'idearun', 'agents', COLLISION_ROLE);
    expect(fs.existsSync(path.join(legacyAgentDir, 'agent.md'))).toBe(true);
    const legacyConfig = JSON.parse(fs.readFileSync(path.join(legacyAgentDir, 'config.json'), 'utf-8'));
    expect(legacyConfig.systemAgentRef).toBeUndefined();

    const manager = new AgentManager({}, REPO_ROOT);
    const agentName = `idearun:${COLLISION_ROLE}`;
    const { agent } = manager.getAgentConfig(agentName);

    // Must resolve to the agent's own private agent.md, not any shared/legacy_path location.
    expect(agent.prompt).toBe(path.join(legacyAgentDir, 'agent.md'));
    expect(agent.companyContextPath).toBeFalsy();
    expect(agent.workDir).toBe(legacyAgentDir);
  });
});