/** * 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, source_kind, origin, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (?, ?, 'public', NULL, 'global_agent', '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 开头的 是上帝视角, // 本来就列着所有公司的 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); } }); });