graph-agent-p1-hire.test.js 9.38 KB
/**
 * Integration Test — company-service.hire()(28 批次 4:公司不再持有 Agent 实体)
 *
 * 这三条不变量从 P1 一路传下来,只是形态换了 —— P1 时它们表现为「私有目录里有什么、
 * 没有什么」,现在公司私有目录整体消失(G3),同一批不变量落在别的地方:
 *
 *   1. 引用生效        —— 雇佣**不产生任何本体副本**:`data/companies/{cid}/agents/` 不复现,
 *                        雇佣关系是 `company_agents` 的一行,agent.md 只有本体那一份。
 *   2. 公司上下文注入  —— company-context.md / user.md / 空 memory 落在
 *                        `system/agents/{aid}/{cid}/`,且内容带这家公司的 id 与名字。
 *   3. 幂等            —— 同一 (companyId, role) 雇两次,第二次 already_exists,
 *                        org_graph_nodes 不重复。
 *
 * 外加 P2 的兜底分支:本体不存在的角色从 `_base` 元 agent 毕业出一份**新本体**
 * (批次 4 起落点是 `system/agents/{id}/`,不再是公司私有目录 —— §2.4 的 org-coder
 * 死角正是那么产生的)。
 *
 * 隔离:GRAPH_DB_PATH 指向一次性 sqlite,真实 data/graph.sqlite 全程不碰;
 * 公司建在 data/companies/zz-* 下,公司态目录散在真实 system/agents/{aid}/ 下面,
 * afterAll 逐个删掉(不删的话每跑一次单测就往源码树里留一堆运行态目录)。
 * **本体不删**(28 号裁决:测试建出来的 agent 是正式注册表条目,留着才谈得上复用),
 * id 因此一律固定 —— 随机后缀 + 不删 = 每跑一次多一个僵尸本体。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

describe('company-service.hire() — 引用本体 + 公司态下沉', () => {
  const TEST_DB_PATH = '/tmp/test-graph-agent-p1-hire.sqlite';
  const REPO_ROOT = path.resolve(__dirname, '../../..');
  const DATA_DIR = path.join(REPO_ROOT, 'data/companies');
  const AGENTS_DIR = path.join(REPO_ROOT, 'system/agents'); // 28 批次 1:本体只剩这一个目录

  const TEST_COMPANY_ID = 'zz-hire-test-co';
  const BODYLESS_ROLE = 'zz-hire-bodyless'; // 第一次跑时仓库里没有本体,由它毕业出来并常驻

  let graphDb;
  let CompanyService;
  let companyService;

  const companyAgentRow = (companyId, roleName) =>
    graphDb.getDb().prepare(
      `SELECT * FROM company_agents WHERE company_id = ? AND role_name = ? AND status = 'active'`
    ).get(companyId, roleName);

  beforeAll(() => {
    // 夹具必须真的在仓库里,否则「没拷贝 agent.md」会因为压根没有本体而空过
    expect(fs.existsSync(path.join(AGENTS_DIR, 'qa-judge', 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(AGENTS_DIR, '_base'))).toBe(true);

    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')];
    graphDb = require('../../services/graph-db');
    ({ CompanyService } = require('../../services/company-service'));
    companyService = new CompanyService();

    // Seed a builtin row for qa-judge —— 本体目录在仓库里真实存在且有 agent.md,
    // 这正是 `node scripts/graph-seed.js` 在生产库里种出来的形状,只是换到隔离库里重建。
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    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('qa-judge', 'qa-judge', 'system/agents/qa-judge', now, now);

    // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的
    // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。
    fs.rmSync(path.join(DATA_DIR, TEST_COMPANY_ID), { recursive: true, force: true });
    companyService.create({ id: TEST_COMPANY_ID, name: 'Hire Test Co', description: 'test fixture' });
  });

  afterAll(() => {
    try { fs.rmSync(path.join(DATA_DIR, TEST_COMPANY_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, TEST_COMPANY_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;
  });

  it('引用生效:雇一个在库本体不产生任何副本,只多一行 company_agents', () => {
    const result = companyService.hire(TEST_COMPANY_ID, 'qa-judge', {});

    expect(result.status).toBe('hired_from_template');
    // 公司私有 agent 目录是 G3 删掉的东西,雇佣不许把它建回来
    expect(fs.existsSync(path.join(DATA_DIR, TEST_COMPANY_ID, 'agents'))).toBe(false);

    // agentDir 现在指公司态目录,而不是一份 agent 副本
    expect(result.agentDir).toBe(path.join(AGENTS_DIR, 'qa-judge', TEST_COMPANY_ID));
    expect(fs.existsSync(path.join(result.agentDir, 'agent.md'))).toBe(false);
    expect(fs.existsSync(path.join(result.agentDir, 'config.json'))).toBe(false);

    const row = companyAgentRow(TEST_COMPANY_ID, 'qa-judge');
    expect(row).toBeTruthy();
    expect(row.system_agent_id).toBe('qa-judge');

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

  it('公司上下文注入:company-context.md / user.md / 空 memory 落在公司态目录里', () => {
    const stateDir = path.join(AGENTS_DIR, 'qa-judge', TEST_COMPANY_ID);

    const context = fs.readFileSync(path.join(stateDir, 'company-context.md'), 'utf-8');
    expect(context).toContain('## 所属公司');
    expect(context).toContain(TEST_COMPANY_ID);
    expect(context).toContain('Hire Test Co');

    const userMd = fs.readFileSync(path.join(stateDir, 'user.md'), 'utf-8');
    expect(userMd).toContain('# 与上级的关系');
    expect(userMd).toContain(TEST_COMPANY_ID);

    expect(fs.existsSync(path.join(stateDir, 'memory', 'memory.md'))).toBe(true);

    // 共享本体一个字都没被改:公司信息不再追加进 agent.md(那会写进所有公司看到的本体)
    const bodyAgentMd = fs.readFileSync(path.join(AGENTS_DIR, 'qa-judge', 'agent.md'), 'utf-8');
    expect(bodyAgentMd).not.toContain(TEST_COMPANY_ID);
  });

  it('幂等:同一 (companyId, role) 雇两次,第二次 already_exists 且 org_graph_nodes 不重复', () => {
    const second = companyService.hire(TEST_COMPANY_ID, 'qa-judge', {});
    expect(second.status).toBe('already_exists');

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

  it('从 _base 毕业出一份**新本体**,而不是公司私有副本', () => {
    // 直接调毕业分支本身,而不是靠 hire() 路由过去:本体常驻之后第二次跑起
    // `system/agents/{BODYLESS_ROLE}` 已经在盘上,hire() 会(正确地)走复用分支,
    // 这条用例要锁的「毕业出来的东西长什么样」就再也测不到了。
    // 毕业本身是幂等的(_copySkeleton 用 copyFileSync 覆写),跑几次形态都一样。
    // hire() 那条路由本身由 agent-body-draft-gate.test.js 的第一条用例覆盖。
    const result = companyService.hireFromBaseMetaAgent(TEST_COMPANY_ID, BODYLESS_ROLE, {});

    expect(result.status).toBe('hired_from_base');
    // 骨架文件齐全 ≠ 定义填好了:三份都还带着 `_base` 的哨兵(28 二轮修复),闸门认作
    // 「没有角色定义」,本体停在 draft 等 agent-creator。放行的话新角色就是拿通用模板上岗。
    expect(result.needsTraining).toBe(true);

    // 产物立刻是全系统可复用的本体(G4)——§2.4 的 org-coder 死角不再产生
    const newBody = path.join(AGENTS_DIR, BODYLESS_ROLE);
    expect(fs.existsSync(path.join(newBody, 'agent.md'))).toBe(true);
    expect(fs.existsSync(path.join(newBody, 'soul.md'))).toBe(true);
    expect(fs.existsSync(path.join(newBody, '.claude/skills/main_skill/SKILL.md'))).toBe(true);
    expect(JSON.parse(fs.readFileSync(path.join(newBody, 'config.json'), 'utf-8')).inheritedFrom).toBe('_base');
    expect(fs.existsSync(path.join(DATA_DIR, TEST_COMPANY_ID, 'agents'))).toBe(false);

    // 公司态与雇佣关系照样落到位
    expect(fs.existsSync(path.join(newBody, TEST_COMPANY_ID, 'company-context.md'))).toBe(true);
    expect(companyAgentRow(TEST_COMPANY_ID, BODYLESS_ROLE)).toBeTruthy();
  });
});