/** * End-to-end acceptance test — P1 虚拟引用寻址 * * Verifies all 6 acceptance criteria from * docs/todo/system-design/27-p1-virtual-reference.md §6, against the real * hire() -> getAgentConfig() -> promptBuilder.build() pipeline (not mocks), * including the §4.6 / §7 naming-collision regression (criterion 6) using * the real `evolver-youtube-crawler` id that genuinely collides between * system/agents/ and system/templates/ in this repo. * * Isolation: * - GRAPH_DB_PATH points at a throwaway SQLite file for the whole file * (same pattern as server/tests/unit/graph-db.test.js) — the real * data/graph.sqlite is opened once, read-only, only to assert that the * production seed data for the collision id actually has the shape the * design doc claims (no `source_kind='template'` row) before mirroring * that exact shape into the isolated test DB. No writes ever hit the * real data/graph.sqlite. * - All hired agents live under temporary company ids * (`tmp-p1-acceptance-*`) removed in afterAll. * - The one legacy (pre-P1) company used for criterion 4 is the real * `idearun` company, accessed read-only only (getAgentConfig / build), * never hired into or written to. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; import Database from 'better-sqlite3'; describe('P1 virtual reference addressing — full acceptance suite (27-p1-virtual-reference.md §6)', () => { const REPO_ROOT = path.resolve(__dirname, '../../..'); const TEST_DB_PATH = '/tmp/test-graph-agent-p1-acceptance.sqlite'; const REAL_GRAPH_DB_PATH = path.join(REPO_ROOT, 'data/graph.sqlite'); const DATA_DIR = path.join(REPO_ROOT, 'data/companies'); const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates'); const RUN_ID = Date.now(); const CO_QA = `tmp-p1-acceptance-qa-${RUN_ID}`; const CO_UNKNOWN = `tmp-p1-acceptance-unknown-${RUN_ID}`; const CO_COLLISION = `tmp-p1-acceptance-collision-${RUN_ID}`; const UNKNOWN_ROLE = `tmp-p1-nonexistent-role-${RUN_ID}-${Math.random().toString(36).slice(2)}`; const COLLISION_ROLE = 'evolver-youtube-crawler'; // real 27§4.6/§7 collision id const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); let graphDb; let CompanyService; let companyService; let AgentManager; let registrySnapshot; // hire() bumps _registry.json usedCount for real templates as a side // effect (company-service.js _updateTemplateUsedCount) — snapshot/restore so hiring the // real qa-judge/evolver-youtube-crawler templates in this test never leaves a permanent // diff on a tracked file. beforeAll(() => { registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8'); // --- Sanity check against the REAL (already-seeded, committed 8421f55) graph.sqlite, // read-only: confirm the production seed data genuinely has the shape the design doc // claims for the collision id (no source_kind='template' row). This is what makes // criterion 6 a real regression test instead of a test of hand-picked fixture data. expect(fs.existsSync(REAL_GRAPH_DB_PATH)).toBe(true); const realDb = new Database(REAL_GRAPH_DB_PATH, { readonly: true }); try { const rows = realDb.prepare('SELECT source_kind, legacy_path FROM system_agents WHERE id = ?').all(COLLISION_ROLE); expect(rows.length).toBeGreaterThan(0); expect(rows.some(r => r.source_kind === 'template')).toBe(false); expect(rows.some(r => r.source_kind === 'global_agent')).toBe(true); } finally { realDb.close(); } // Also confirm the physical files this test depends on actually exist in the repo, // so a passing test means something (not vacuously true because fixtures were missing). expect(fs.existsSync(path.join(TEMPLATES_DIR, 'qa-judge', 'agent.md'))).toBe(true); expect(fs.existsSync(path.join(TEMPLATES_DIR, COLLISION_ROLE, 'agent.md'))).toBe(true); expect(fs.existsSync(path.join(REPO_ROOT, 'system/agents', COLLISION_ROLE, 'agent.md'))).toBe(true); expect(fs.existsSync(path.join(DATA_DIR, 'idearun', 'agents', COLLISION_ROLE, 'agent.md'))).toBe(true); // --- Now switch to the isolated test DB for everything the test actually mutates. 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')]; delete require.cache[require.resolve('../../src/core/agent-manager')]; delete require.cache[require.resolve('../../src/core/prompt-builder')]; 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(); // Mirror the real production seed for qa-judge (source_kind='template'). 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); // Mirror the real production seed for the collision id: only the global_agent row // exists (seedTemplates() skipped the template row because seedGlobalAgents() claimed // the id first) — verified against the real DB above. 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, 'global_agent', ?, 'active', NULL, ?, ?) `).run(COLLISION_ROLE, COLLISION_ROLE, `system/agents/${COLLISION_ROLE}`, now, now); companyService.create({ id: CO_QA, name: 'P1 Acceptance QA Co', description: 'Verifies QA-judge lives here.' }); companyService.create({ id: CO_UNKNOWN, name: 'P1 Acceptance Unknown-role Co', description: 'Untrained role fallback.' }); companyService.create({ id: CO_COLLISION, name: 'P1 Acceptance Collision Co', description: 'Naming collision regression.' }); }); afterAll(() => { for (const id of [CO_QA, CO_UNKNOWN, CO_COLLISION]) { try { fs.rmSync(path.join(DATA_DIR, 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'); }); // ───────────────────────────────────────────────────────────────── // Criterion 1: hire() of a system_agents template role -> reference mode // ───────────────────────────────────────────────────────────────── it('criterion 1: hire(companyId, "qa-judge") uses reference mode (no agent.md, company-context.md, systemAgentRef, org_graph_nodes row)', () => { const result = companyService.hire(CO_QA, '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 context = fs.readFileSync(contextPath, 'utf-8'); expect(context).toContain(CO_QA); expect(context).toContain('P1 Acceptance QA Co'); expect(context).toContain('Verifies QA-judge lives here.'); const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); expect(config.systemAgentRef).toBe('qa-judge'); const db = graphDb.getDb(); const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_QA, 'qa-judge'); expect(node).toBeTruthy(); expect(node.system_agent_id).toBe('qa-judge'); }); // ───────────────────────────────────────────────────────────────── // Criterion 2: final prompt = real shared agent.md content + company-context.md // ───────────────────────────────────────────────────────────────── it('criterion 2: buildFullPrompt() assembles the real shared system/templates/qa-judge/agent.md content plus the company-context.md section', () => { const manager = new AgentManager({}, REPO_ROOT); const agentName = `${CO_QA}:qa-judge`; const { agent } = manager.getAgentConfig(agentName); const expectedSharedPath = path.resolve(REPO_ROOT, 'system/templates/qa-judge', 'agent.md'); expect(agent.prompt).toBe(expectedSharedPath); const realSharedContent = fs.readFileSync(expectedSharedPath, 'utf-8'); const fullPrompt = manager.buildFullPrompt(agentName, 'test task'); // The full assembled prompt carries the exact real content of the shared template file. expect(fullPrompt).toContain(realSharedContent); // ... and the private company-context.md section (proves the two-part assembly from §4.3). expect(fullPrompt).toContain('## 所属公司'); expect(fullPrompt).toContain(CO_QA); expect(fullPrompt).toContain('P1 Acceptance QA Co'); // Regression guard for the "改共享文件后无需重新 hire 即可生效" claim in criterion 2: // prompt-builder must not have baked a stale copy into the private dir — there is no // agent.md in the private dir to have gone stale in the first place. const agentDir = path.join(DATA_DIR, CO_QA, 'agents/qa-judge'); expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false); }); // ───────────────────────────────────────────────────────────────── // Criterion 3: role absent from system_agents -> legacy full copy, no error // ───────────────────────────────────────────────────────────────── it('criterion 3: hiring a role not in system_agents falls back to legacy full copy (including agent.md) without throwing', () => { // UNKNOWN_ROLE has no system_agents row (never seeded) AND no template directory at // all, so hire() must return needs_training rather than throw. To exercise the // "template exists on disk but has no system_agents row" branch specifically (the one // §4.1/§4.2 describe as the legacy-fallback trigger), also create a throwaway template // dir that deliberately is NOT registered in system_agents. const looseTemplateRole = `tmp-p1-loose-template-${RUN_ID}`; const looseTemplateDir = path.join(TEMPLATES_DIR, looseTemplateRole); fs.mkdirSync(looseTemplateDir, { recursive: true }); fs.writeFileSync(path.join(looseTemplateDir, 'agent.md'), '# unregistered fixture agent\n', 'utf-8'); fs.writeFileSync(path.join(looseTemplateDir, 'config.json'), JSON.stringify({ type: 'skill', name: looseTemplateRole }), 'utf-8'); try { let result; expect(() => { result = companyService.hire(CO_UNKNOWN, looseTemplateRole, {}); }).not.toThrow(); expect(result.status).toBe('hired_from_template'); const agentDir = result.agentDir; expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(true); const content = fs.readFileSync(path.join(agentDir, 'agent.md'), 'utf-8'); expect(content).toContain('unregistered fixture agent'); 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(); } finally { try { fs.rmSync(looseTemplateDir, { recursive: true, force: true }); } catch {} } // And confirm hire() also degrades gracefully (no throw) for a role that has neither a // system_agents row nor a template directory at all. // P2 起(27-p2-org-graph.md §4.1 第 7 步)这条分支从 'needs_training' 改为 // 'hired_from_base' —— 先从 system/templates/base/ 元 agent 继承出带四段式生命周期的骨架, // 再交给 agent-creator 填充。needsTraining 仍为 true,路由层的训练触发条件不变。 let unknownResult; expect(() => { unknownResult = companyService.hire(CO_UNKNOWN, UNKNOWN_ROLE, {}); }).not.toThrow(); expect(unknownResult.status).toBe('hired_from_base'); expect(unknownResult.needsTraining).toBe(true); }); // ───────────────────────────────────────────────────────────────── // Criterion 6 (design doc numbering) — naming collision regression // ───────────────────────────────────────────────────────────────── it('criterion 6 [KEY REGRESSION]: hire("evolver-youtube-crawler") on a brand-new company falls back to legacy full copy, never redirecting to system/agents/', () => { const result = companyService.hire(CO_COLLISION, COLLISION_ROLE, {}); expect(result.status).toBe('hired_from_template'); const agentDir = result.agentDir; const agentMdPath = path.join(agentDir, 'agent.md'); expect(fs.existsSync(agentMdPath)).toBe(true); // legacy path -> agent.md IS physically copied const copiedContent = fs.readFileSync(agentMdPath, 'utf-8'); const templateContent = fs.readFileSync(path.join(TEMPLATES_DIR, COLLISION_ROLE, 'agent.md'), 'utf-8'); const globalAgentContent = fs.readFileSync(path.join(REPO_ROOT, 'system/agents', COLLISION_ROLE, 'agent.md'), 'utf-8'); // These two source files are confirmed to genuinely differ in this repo (27§7). expect(templateContent).not.toBe(globalAgentContent); // The copied file must match system/templates/ (what _copyTemplate() actually reads), // and must NOT be — or even coincidentally equal — the system/agents/ (global_agent) version. // (companyContext gets appended in legacy mode, so compare via startsWith/contains rather // than strict equality.) expect(copiedContent.startsWith(templateContent)).toBe(true); expect(copiedContent).not.toContain(globalAgentContent.split('\n')[0]); // global_agent's distinct H1 heading // Reference-mode artifacts must be absent — this id must NOT have taken the reference branch. 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(); // graph-service.lookupSharedAgentMd itself must report "no reference" for this id, // proving the source_kind='template' filter — not just hire()'s outer branching — is correct. const graphService = require('../../services/graph-service'); expect(graphService.lookupSharedAgentMd(COLLISION_ROLE)).toBeNull(); // Still gets an audit org_graph_nodes row even on the legacy path. const db = graphDb.getDb(); const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(CO_COLLISION, COLLISION_ROLE); expect(node).toBeTruthy(); }); // ───────────────────────────────────────────────────────────────── // Criterion 5 (design doc numbering) — idempotency // ───────────────────────────────────────────────────────────────── it('criterion 5: hiring the same (companyId, role) twice short-circuits to already_exists and does not duplicate org_graph_nodes', () => { const first = companyService.hire(CO_QA, 'qa-judge', {}); // already hired in criterion-1 test above expect(first.status).toBe('already_exists'); const second = companyService.hire(CO_QA, 'qa-judge', {}); 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(CO_QA, 'qa-judge').c; expect(count).toBe(1); }); // ───────────────────────────────────────────────────────────────── // Criterion 4 (design doc numbering) — pre-P1 legacy company unaffected // ───────────────────────────────────────────────────────────────── it('criterion 4: a pre-P1 legacy company (real "idearun", never touched by this test) still resolves through the old private-agent.md path, not the new reference branch', () => { // Read-only against real, already-hired production data — proves old instances are // untouched by the new code path rather than merely "not tested". const legacyAgentDir = path.join(DATA_DIR, 'idearun', 'agents', COLLISION_ROLE); expect(fs.existsSync(path.join(legacyAgentDir, 'agent.md'))).toBe(true); const legacyConfig = JSON.parse(fs.readFileSync(path.join(legacyAgentDir, 'config.json'), 'utf-8')); expect(legacyConfig.systemAgentRef).toBeUndefined(); const manager = new AgentManager({}, REPO_ROOT); const agentName = `idearun:${COLLISION_ROLE}`; const { agent } = manager.getAgentConfig(agentName); // Must resolve to the agent's own private agent.md, not any shared/legacy_path location. expect(agent.prompt).toBe(path.join(legacyAgentDir, 'agent.md')); expect(agent.companyContextPath).toBeFalsy(); expect(agent.workDir).toBe(legacyAgentDir); }); });