graph-agent-p1-acceptance.test.js 12.5 KB
/**
 * End-to-end acceptance test — 本体共享 + 公司态下沉的完整链路
 *
 * 走**真的** hire() → getAgentConfig() → promptBuilder.build()(不是 mock),
 * 覆盖 28 号设计 §八 的验收 4 / 5 与批次 4 的 hire 形态。
 *
 * 这个文件的前身锁的是 P1 的「私有目录里有没有 agent.md、config.json 里有没有
 * systemAgentRef」。28 批次 4 把公司私有目录整体删了(G3),那批断言全部失效 ——
 * 但它们**要证明的东西**一条没丢,只是换了落点:
 *
 *   ① 引用生效        prompt 指向唯一那份 `system/agents/{id}/agent.md`,
 *                     两家公司拿到的是同一个路径、同一份内容(验收 5:改一次全公司生效)。
 *   ② 公司上下文注入  company-context.md / user.md / memory 进提示词,
 *                     且只进自己那家公司的(验收 4:两边不串)。
 *   ③ 命名冲突不误伤  `evolver-youtube-crawler` 是 §2.2 那 7 个同名 id 之一
 *                     (批次 1 前 system/agents/ 与 system/templates/ 各有一份,
 *                     lookupSharedAgentMd 恒 null → 每次雇佣都全量拷贝)。
 *                     合并后它必须和普通本体走**完全一样**的路径,不再有特例。
 *
 * Isolation:
 *   - GRAPH_DB_PATH 指向一次性 SQLite,真实 data/graph.sqlite 全程不碰;
 *   - 公司建在 data/companies/zz-acceptance-* 下,afterAll 删掉;
 *   - 公司态目录落在**真实**的 system/agents/{aid}/{cid}/ 下,afterAll 逐个删掉;
 *   - 兜底分支毕业出来的那份本体**留着**(28 号裁决:测试建的 agent 是正式注册表条目),
 *     所以 id 全部固定 —— 随机后缀 + 不删 = 每跑一次多一个僵尸本体;
 *   - hire() 会 bump system/agents/_registry.json 的 usedCount,快照后恢复。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

describe('本体共享 + 公司态下沉 — 端到端验收(28 §八 4/5)', () => {
  const REPO_ROOT = path.resolve(__dirname, '../../..');
  const TEST_DB_PATH = '/tmp/test-graph-agent-p1-acceptance.sqlite';
  const DATA_DIR = path.join(REPO_ROOT, 'data/companies');
  const AGENTS_DIR = path.join(REPO_ROOT, 'system/agents'); // 28 批次 1:本体只剩这一个目录

  const CO_A = 'zz-acceptance-a';
  const CO_B = 'zz-acceptance-b';
  const CO_UNKNOWN = 'zz-acceptance-unknown';
  const UNKNOWN_ROLE = 'zz-acceptance-graduated'; // 第一次跑由 _base 毕业出来,此后常驻
  const COLLISION_ROLE = 'evolver-youtube-crawler'; // 27§4.6/§7 的历史冲突 id,批次 1 后只剩一份本体
  const REGISTRY_PATH = path.join(AGENTS_DIR, '_registry.json');

  const COMPANIES = [CO_A, CO_B, CO_UNKNOWN];

  let graphDb;
  let CompanyService;
  let companyService;
  let AgentManager;
  let registrySnapshot;

  beforeAll(() => {
    registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8');
    // 依赖的物理文件必须真的在仓库里,否则测试是空过而不是通过
    expect(fs.existsSync(path.join(AGENTS_DIR, 'qa-judge', 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(AGENTS_DIR, COLLISION_ROLE, 'agent.md'))).toBe(true);
    // 冲突真的消失了:批次 1 之前 system/templates/ 下还有第二份同名本体
    expect(fs.existsSync(path.join(REPO_ROOT, 'system/templates', COLLISION_ROLE))).toBe(false);
    // 公司私有 agent 目录真的清空了(批次 4 的 M2)—— 验收 1
    for (const c of fs.readdirSync(DATA_DIR, { withFileTypes: true })) {
      if (!c.isDirectory()) continue;
      expect(fs.existsSync(path.join(DATA_DIR, c.name, 'agents'))).toBe(false);
    }

    // --- 切到隔离库,之后所有写操作都落在它上面
    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of ['../../services/graph-db', '../../services/graph-service',
      '../../services/company-service', '../../src/core/agent-manager', '../../src/core/prompt-builder']) {
      delete require.cache[require.resolve(m)];
    }
    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();
    // 镜像生产种子(seedAgents:两个 id 都是 origin='builtin' 的本体行)
    for (const id of ['qa-judge', COLLISION_ROLE]) {
      db.prepare(`
        INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at)
        VALUES (?, ?, 'public', NULL, 'builtin', ?, 'active', NULL, ?, ?)
      `).run(id, id, `system/agents/${id}`, now, now);
    }

    // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
    // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
    for (const id of COMPANIES) fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true });
    companyService.create({ id: CO_A, name: 'Acceptance A 公司', description: 'A 家的使命' });
    companyService.create({ id: CO_B, name: 'Acceptance B 公司', description: 'B 家的使命' });
    companyService.create({ id: CO_UNKNOWN, name: 'Acceptance 未知角色公司', description: '兜底分支' });
  });

  afterAll(() => {
    for (const id of COMPANIES) {
      try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {}
      for (const body of fs.readdirSync(AGENTS_DIR, { withFileTypes: true })) {
        if (!body.isDirectory()) continue;
        try { fs.rmSync(path.join(AGENTS_DIR, body.name, 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');
  });

  // ─────────────────────────────────────────────────────────────────
  // ① 引用生效 —— 两家公司雇同一个本体,prompt 是同一份
  // ─────────────────────────────────────────────────────────────────
  it('两家公司雇 qa-judge:prompt 都指向唯一那份本体 agent.md,内容逐字一致', () => {
    expect(companyService.hire(CO_A, 'qa-judge', {}).status).toBe('hired_from_template');
    expect(companyService.hire(CO_B, 'qa-judge', {}).status).toBe('hired_from_template');

    const manager = new AgentManager({}, REPO_ROOT);
    const sharedPath = path.join(AGENTS_DIR, 'qa-judge', 'agent.md');
    const shared = fs.readFileSync(sharedPath, 'utf-8');

    for (const cid of [CO_A, CO_B]) {
      const { agent } = manager.getAgentConfig(`${cid}:qa-judge`);
      expect(agent.prompt).toBe(sharedPath);
      // 验收 5:改一次本体两家下次调用都读到新版 —— 前提是谁都没有 baked 一份副本
      expect(manager.buildFullPrompt(`${cid}:qa-judge`, 'test task')).toContain(shared);
    }
  });

  // ─────────────────────────────────────────────────────────────────
  // ② 公司上下文注入 —— 两家的语境进各自的提示词,且不互相串
  // ─────────────────────────────────────────────────────────────────
  it('公司语境(company-context.md / user.md)进提示词,且只进自己那家公司的', () => {
    const manager = new AgentManager({}, REPO_ROOT);
    // buildFullPrompt 读的是 getAgentConfig 缓存进 config.agents 的那份,先寻址再拼
    const { agent } = manager.getAgentConfig(`${CO_A}:qa-judge`);
    manager.getAgentConfig(`${CO_B}:qa-judge`);

    // 「不串」只能用**公司使命**来验:prompt 开头的 <env_status> 是上帝视角,
    // 本来就列着所有公司的 id 与 name,拿公司名做反向断言会恒红。
    // description 只出现在各自的 company-context.md 里。
    const promptA = manager.buildFullPrompt(`${CO_A}:qa-judge`, 'go');
    expect(promptA).toContain('## 所属公司');
    expect(promptA).toContain(CO_A);
    expect(promptA).toContain('A 家的使命');
    expect(promptA).toContain('# 与上级的关系');
    expect(promptA).not.toContain('B 家的使命');

    const promptB = manager.buildFullPrompt(`${CO_B}:qa-judge`, 'go');
    expect(promptB).toContain('B 家的使命');
    expect(promptB).not.toContain('A 家的使命');

    // 落点是公司态目录,不是任何一份 agent 副本
    expect(agent.companyContextPath).toBe(path.join(AGENTS_DIR, 'qa-judge', CO_A, 'company-context.md'));
    expect(agent.companyDir).toBe(path.join(AGENTS_DIR, 'qa-judge', CO_A));
  });

  // ─────────────────────────────────────────────────────────────────
  // ③ 命名冲突不误伤 —— 曾经的同名 id 走的是同一条普通路径
  // ─────────────────────────────────────────────────────────────────
  it('[KEY REGRESSION] 曾经的同名冲突 id 没有特例:照样引用唯一那份本体,不产生副本', () => {
    const result = companyService.hire(CO_A, COLLISION_ROLE, {});
    expect(result.status).toBe('hired_from_template');
    expect(result.agentDir).toBe(path.join(AGENTS_DIR, COLLISION_ROLE, CO_A));

    // 批次 1 之前这里会有一份全量拷贝(含 agent.md)
    expect(fs.existsSync(path.join(DATA_DIR, CO_A, 'agents'))).toBe(false);
    expect(fs.existsSync(path.join(result.agentDir, 'agent.md'))).toBe(false);
    expect(fs.existsSync(path.join(result.agentDir, 'company-context.md'))).toBe(true);

    const graphService = require('../../services/graph-service');
    const shared = graphService.lookupSharedAgentMd(COLLISION_ROLE);
    expect(shared).toBe(path.join(AGENTS_DIR, COLLISION_ROLE, 'agent.md'));

    const manager = new AgentManager({}, REPO_ROOT);
    expect(manager.getAgentConfig(`${CO_A}:${COLLISION_ROLE}`).agent.prompt).toBe(shared);

    // 引用模式下照样有 org_graph_nodes 审计行
    expect(graphDb.getDb()
      .prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?')
      .get(CO_A, COLLISION_ROLE)).toBeTruthy();
  });

  // ─────────────────────────────────────────────────────────────────
  // 兜底:本体不存在的角色不抛异常,从 _base 毕业出一份新本体
  // ─────────────────────────────────────────────────────────────────
  it('从 _base 毕业出一份本体:不抛异常,落在 system/agents/{id}/ 但停在 draft', () => {
    // 直接调毕业分支:本体常驻之后 hire() 第二次跑会(正确地)走复用分支,
    // 而这条用例锁的是「毕业产物长什么样」。毕业本身幂等(_copySkeleton 覆写)。
    let result;
    expect(() => { result = companyService.hireFromBaseMetaAgent(CO_UNKNOWN, UNKNOWN_ROLE, {}); }).not.toThrow();
    expect(result.status).toBe('hired_from_base');
    // 28 二轮修复:`_base` 三份定义都埋着哨兵,拷出来的骨架**不算**填过 —— 新本体停在
    // draft,直到 agent-creator 按角色重写。否则就是「复制完通用模板就上岗」(§2.4 org-coder)。
    expect(result.needsTraining).toBe(true);
    expect(result.missing).toEqual(['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md']);
    for (const f of ['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md']) {
      expect(fs.existsSync(path.join(AGENTS_DIR, UNKNOWN_ROLE, f)), f).toBe(true);
    }
  });
});