graph-agent-p1-hire.test.js 6.92 KB
/**
 * Integration Test — company-service.hire() P1 引用模式
 *
 * 覆盖 docs/todo/system-design/27-p1-virtual-reference.md §6 验收标准 1/3/5:
 *   1. system_agents 里有 source_kind='template' 行的角色(qa-judge) → 引用模式:
 *      私有目录没有 agent.md,有 company-context.md(含公司 ID),config.json 有
 *      systemAgentRef,org_graph_nodes 新增一行。
 *   2. 不在 system_agents 里的角色 → 完全 legacy 行为(含 agent.md 被拷贝)。
 *   3. 幂等性:同一 (companyId, role) 调用 hire() 两次,第二次 already_exists,
 *      org_graph_nodes 不重复。
 *
 * 用 GRAPH_DB_PATH 覆盖到临时 SQLite 文件(同 graph-service.test.js / graph-db.test.js
 * 的模式),永不触碰真实 data/graph.sqlite;用临时公司 id,测试完清理 data/companies/
 * 下对应目录,不污染真实数据。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

describe('company-service.hire() — P1 virtual reference mode', () => {
  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 TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates');

  const TEST_COMPANY_ID = `tmp-p1-hire-test-co-${Date.now()}`;
  const LEGACY_TEMPLATE_ROLE = `tmp-p1-legacy-role-${Date.now()}`;
  const legacyTemplateDir = path.join(TEMPLATES_DIR, LEGACY_TEMPLATE_ROLE);

  let graphDb;
  let CompanyService;
  let companyService;

  beforeAll(() => {
    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 'template' row for qa-judge pointing at the real template dir (already exists
    // in the repo with a real agent.md) — this is exactly what npm run graph:seed produced
    // in commit 8421f55, just re-created here against the isolated test DB.
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    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);

    // Fixture template dir for the "role not in system_agents" legacy-fallback case.
    // Deliberately NOT inserted into system_agents, so lookupSharedAgentMd() returns null
    // and hire() must fall back to the full legacy copy (including agent.md).
    fs.mkdirSync(legacyTemplateDir, { recursive: true });
    fs.writeFileSync(path.join(legacyTemplateDir, 'agent.md'), '# legacy fixture agent\n', 'utf-8');
    fs.writeFileSync(path.join(legacyTemplateDir, 'config.json'), JSON.stringify({ type: 'skill', name: LEGACY_TEMPLATE_ROLE }), 'utf-8');

    // Company fixture (CompanyService.hire() requires an existing company).
    companyService.create({ id: TEST_COMPANY_ID, name: 'P1 Hire Test Co', description: 'test fixture' });
  });

  afterAll(() => {
    try { fs.rmSync(path.join(DATA_DIR, TEST_COMPANY_ID), { recursive: true, force: true }); } catch {}
    try { fs.rmSync(legacyTemplateDir, { 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('reference mode: hiring a system_agents template role does not copy agent.md, writes company-context.md, sets systemAgentRef, records org_graph_nodes', () => {
    const result = companyService.hire(TEST_COMPANY_ID, '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 contextContent = fs.readFileSync(contextPath, 'utf-8');
    expect(contextContent).toContain(TEST_COMPANY_ID);
    expect(contextContent).toContain('## 所属公司');

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

    // Non-shared files must still be provisioned privately (workDir constraint, §3.3 of the design doc).
    expect(fs.existsSync(path.join(agentDir, 'soul.md'))).toBe(true);
    expect(fs.existsSync(path.join(agentDir, 'identity.md'))).toBe(true);
    expect(fs.existsSync(path.join(agentDir, 'memory/memory.md'))).toBe(true);

    const db = graphDb.getDb();
    const node = db.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('legacy fallback: hiring a role absent from system_agents copies agent.md and writes no company-context.md / systemAgentRef', () => {
    const result = companyService.hire(TEST_COMPANY_ID, LEGACY_TEMPLATE_ROLE, {});

    expect(result.status).toBe('hired_from_template');
    const agentDir = result.agentDir;

    expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(true);
    const agentMdContent = fs.readFileSync(path.join(agentDir, 'agent.md'), 'utf-8');
    expect(agentMdContent).toContain('legacy fixture agent');
    expect(agentMdContent).toContain('## 所属公司'); // still appended in-place, legacy behavior

    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();

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

  it('idempotency: hiring the same (companyId, role) twice returns already_exists the second time and does not duplicate org_graph_nodes', () => {
    const role = 'qa-judge'; // already hired by the first test above

    const second = companyService.hire(TEST_COMPANY_ID, role, {});
    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(TEST_COMPANY_ID, role).c;
    expect(count).toBe(1);
  });
});