graph-agent-p1-acceptance.test.js
12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
* 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 开头的 <env_status> 是上帝视角,
// 本来就列着所有公司的 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);
}
});
});