graph-agent-p1-hire.test.js
9.38 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
/**
* 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();
});
});