graph-agent-p1-acceptance.test.js
18.4 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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/**
* 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);
});
});