/** * 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(); }); });