test-harness.js
17.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
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
#!/usr/bin/env node
/**
* test-harness.js — Harness Engineering 四阶段流转手工测试脚本
*
* 测试覆盖:
* ✅ Test 1: Meta-Skill 文件存在性检查
* ✅ Test 2: Base Agent Template 完整性检查
* ✅ Test 3: Task Transition API — Hot Swap 换脑验证
* ✅ Test 4: Task Transition API — Context Flush 记忆断舍离
* ✅ Test 5: P0 事前阻断阀 (Human-in-the-loop)
* ✅ Test 6: _lessons.md 注入 System Prompt
* ✅ Test 7: billing.log 计费日志
* ✅ Test 8: OKR GoalsService 新增状态兼容
* ✅ Test 9: CLI `task transition` 命令注册
*
* 用法:
* node scripts/test-harness.js # 运行全部离线测试 (不需要服务器)
* node scripts/test-harness.js --online # 运行含 API 调用的在线测试 (需要服务器在 3000 端口)
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const ROOT = path.resolve(__dirname, '..');
const DATA = path.join(ROOT, 'data');
const API_BASE = 'http://localhost:3000/api';
let passed = 0;
let failed = 0;
const results = [];
function assert(condition, testName, detail = '') {
if (condition) {
passed++;
results.push({ status: '✅', testName });
console.log(` ✅ ${testName}`);
} else {
failed++;
results.push({ status: '❌', testName, detail });
console.log(` ❌ ${testName}${detail ? ` — ${detail}` : ''}`);
}
}
function fileExists(relPath) {
return fs.existsSync(path.join(ROOT, relPath));
}
function fileContains(relPath, needle) {
if (!fileExists(relPath)) return false;
return fs.readFileSync(path.join(ROOT, relPath), 'utf-8').includes(needle);
}
function httpPost(urlPath, body) {
return new Promise((resolve, reject) => {
const postData = JSON.stringify(body);
const url = new URL(API_BASE + urlPath);
const req = http.request({
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) },
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
catch { resolve({ status: res.statusCode, body: data }); }
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
// ═══════════════════════════════════════
// Test 1: Meta-Skill 文件存在性
// ═══════════════════════════════════════
function test1_metaSkillFiles() {
console.log('\n📦 Test 1: Meta-Skill 文件存在性');
const phases = ['meta-research', 'meta-plan', 'meta-execute', 'meta-verify'];
for (const phase of phases) {
const skillPath = `system/skills/${phase}/SKILL.md`;
assert(fileExists(skillPath), `${phase}/SKILL.md 存在`, `缺失: ${skillPath}`);
}
// 内容校验
assert(
fileContains('system/skills/meta-research/SKILL.md', '调研'),
'meta-research 包含"调研"关键词'
);
assert(
fileContains('system/skills/meta-execute/SKILL.md', '服从纪律'),
'meta-execute 包含"服从纪律"约束'
);
assert(
fileContains('system/skills/meta-verify/SKILL.md', '_lessons.md'),
'meta-verify 引用 _lessons.md 防坑机制'
);
}
// ═══════════════════════════════════════
// Test 2: Base Agent Template 完整性
// ═══════════════════════════════════════
function test2_baseTemplate() {
console.log('\n📦 Test 2: Base Agent Template 完整性');
assert(fileExists('system/templates/base/agent.md'), 'base/agent.md 存在');
assert(fileExists('system/templates/base/config.json'), 'base/config.json 存在');
// config.json 校验工具策略
const config = JSON.parse(fs.readFileSync(path.join(ROOT, 'system/templates/base/config.json'), 'utf-8'));
assert(config.cli?.toolPolicy?.mode === 'restricted', 'toolPolicy.mode = restricted');
assert(
Array.isArray(config.cli?.toolPolicy?.allowedTools) && config.cli.toolPolicy.allowedTools.includes('bash'),
'allowedTools 包含 bash'
);
// agent.md 校验四阶段规范
assert(
fileContains('system/templates/base/agent.md', 'Research') &&
fileContains('system/templates/base/agent.md', 'Plan') &&
fileContains('system/templates/base/agent.md', 'Execute') &&
fileContains('system/templates/base/agent.md', 'Verify'),
'agent.md 包含 RPEV 四阶段关键词'
);
assert(
fileContains('system/templates/base/agent.md', 'task transition'),
'agent.md 引用 task transition CLI 命令'
);
// _registry.json 校验
assert(fileExists('system/templates/_registry.json'), '_registry.json 存在');
const registry = JSON.parse(fs.readFileSync(path.join(ROOT, 'system/templates/_registry.json'), 'utf-8'));
assert(registry.some(t => t.id === 'base'), '_registry.json 包含 base 模板');
}
// ═══════════════════════════════════════
// Test 3: Hot Swap 换脑 — 模拟文件替换
// ═══════════════════════════════════════
function test3_hotSwap() {
console.log('\n🧠 Test 3: Hot Swap 模拟换脑');
// 创建临时测试 Agent 目录
const testAgentDir = path.join(DATA, 'agents', '_test_harness_agent');
const skillDir = path.join(testAgentDir, '.claude', 'skills', 'main_skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Original Skill\nI am the original.', 'utf-8');
fs.writeFileSync(path.join(testAgentDir, 'config.json'), JSON.stringify({ id: '_test_harness_agent', type: 'skill', status: 'idle' }), 'utf-8');
fs.writeFileSync(path.join(testAgentDir, 'identity.md'), '**名称:** 测试小兵\n**Emoji:** 🧪', 'utf-8');
// 模拟 Hot Swap: 复制 meta-plan 到 main_skill
const sourceSkill = path.join(ROOT, 'system/skills/meta-plan/SKILL.md');
if (fs.existsSync(sourceSkill)) {
fs.copyFileSync(sourceSkill, path.join(skillDir, 'SKILL.md'));
const content = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf-8');
assert(content.includes('meta-plan'), 'Hot Swap 后 SKILL.md 内容变为 meta-plan');
assert(!content.includes('Original Skill'), 'Hot Swap 后原始内容已被覆盖');
} else {
assert(false, 'Hot Swap 测试', '源技能文件 meta-plan/SKILL.md 不存在');
}
// 清理
fs.rmSync(testAgentDir, { recursive: true, force: true });
}
// ═══════════════════════════════════════
// Test 4: Context Flush 历史清空模拟
// ═══════════════════════════════════════
function test4_contextFlush() {
console.log('\n🛑 Test 4: Context Flush 记忆断舍离模拟');
const testAgentDir = path.join(DATA, 'agents', '_test_flush_agent');
const convDir = path.join(testAgentDir, 'conversations', 'test-conv-001');
fs.mkdirSync(convDir, { recursive: true });
// 模拟 history.json 有大量历史
const fakeHistory = Array.from({ length: 50 }, (_, i) => ({
role: i % 2 === 0 ? 'user' : 'assistant',
content: `Message ${i}: ${'x'.repeat(200)}`,
timestamp: new Date().toISOString()
}));
fs.writeFileSync(path.join(convDir, 'history.json'), JSON.stringify(fakeHistory), 'utf-8');
assert(JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8')).length === 50,
'预置 50 条历史消息');
// 模拟 Context Flush: 清空 history
fs.writeFileSync(path.join(convDir, 'history.json'), '[]', 'utf-8');
const afterFlush = JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8'));
assert(afterFlush.length === 0, 'Context Flush 后 history.json 被清空为空数组');
// 模拟注入新起步信息
afterFlush.push({
role: 'user',
content: '【系统指令:强制状态切换】你已进入 PLAN 阶段。',
messageId: 'msg-flush-test',
timestamp: new Date().toISOString()
});
fs.writeFileSync(path.join(convDir, 'history.json'), JSON.stringify(afterFlush), 'utf-8');
const restored = JSON.parse(fs.readFileSync(path.join(convDir, 'history.json'), 'utf-8'));
assert(restored.length === 1 && restored[0].content.includes('PLAN'), '注入新阶段起步指令成功');
// 清理
fs.rmSync(testAgentDir, { recursive: true, force: true });
}
// ═══════════════════════════════════════
// Test 5: _lessons.md 注入 System Prompt
// ═══════════════════════════════════════
function test5_lessonsInjection() {
console.log('\n⚠️ Test 5: _lessons.md 防坑注入');
// 模拟 buildSystemPrompt 中 _lessons.md 的读取逻辑
const testAgentDir = path.join(DATA, 'agents', '_test_lessons_agent');
const wsDir = path.join(testAgentDir, 'workspace');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(path.join(testAgentDir, 'config.json'), JSON.stringify({ id: '_test_lessons_agent', type: 'skill' }), 'utf-8');
// 无 _lessons.md 时
const lessonsPath = path.join(wsDir, '_lessons.md');
assert(!fs.existsSync(lessonsPath), '初始状态无 _lessons.md');
// 写入教训
fs.writeFileSync(lessonsPath, `# 试错记录
- 2026-03-26: Python2 语法在 node 22 环境下不兼容, 必须用 python3
- 2026-03-26: 不要用 fs.existsSync 检查远程 URL, 应该用 http.get
`, 'utf-8');
const content = fs.readFileSync(lessonsPath, 'utf-8');
assert(content.includes('python3'), '_lessons.md 包含真实教训');
assert(content.includes('fs.existsSync'), '_lessons.md 包含第二条防坑记录');
// 清理
fs.rmSync(testAgentDir, { recursive: true, force: true });
}
// ═══════════════════════════════════════
// Test 6: billing.log 计费格式
// ═══════════════════════════════════════
function test6_billingLog() {
console.log('\n💰 Test 6: billing.log 计费日志');
const logsDir = path.join(DATA, 'logs');
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
const billingPath = path.join(logsDir, 'billing.log');
// 模拟写入一条计费记录
const testEntry = `${new Date().toISOString()} | Agent: _test_billing | Conv: test-conv | Cost: $0.003200 | Duration: 4521ms\n`;
fs.appendFileSync(billingPath, testEntry, 'utf-8');
const content = fs.readFileSync(billingPath, 'utf-8');
assert(content.includes('_test_billing'), 'billing.log 包含测试 Agent ID');
assert(content.includes('Cost: $'), 'billing.log 包含美元成本字段');
assert(content.includes('Duration:'), 'billing.log 包含耗时字段');
// 清理测试行(不删文件,只清测试数据)
const cleanedContent = content.split('\n').filter(l => !l.includes('_test_billing')).join('\n');
fs.writeFileSync(billingPath, cleanedContent, 'utf-8');
}
// ═══════════════════════════════════════
// Test 7: GoalsService 新增状态兼容
// ═══════════════════════════════════════
function test7_goalsServiceStates() {
console.log('\n🎯 Test 7: GoalsService 新增状态兼容');
const { GoalsService } = require(path.join(ROOT, 'server/services/goals.js'));
const svc = new GoalsService(); // 使用全局 goals dir
// 验证源码中有新状态
const goalsSource = fs.readFileSync(path.join(ROOT, 'server/services/goals.js'), 'utf-8');
assert(goalsSource.includes('researching'), 'GoalsService 源码包含 researching 状态');
assert(goalsSource.includes('planning'), 'GoalsService 源码包含 planning 状态');
assert(goalsSource.includes('executing'), 'GoalsService 源码包含 executing 状态');
assert(goalsSource.includes('verifying'), 'GoalsService 源码包含 verifying 状态');
assert(goalsSource.includes('pending_approval'), 'GoalsService 源码包含 pending_approval 状态');
}
// ═══════════════════════════════════════
// Test 8: CLI task transition 命令注册
// ═══════════════════════════════════════
function test8_cliRegistration() {
console.log('\n🔧 Test 8: CLI task transition 命令注册');
const lemonSrc = fs.readFileSync(path.join(ROOT, 'bin/lemon.js'), 'utf-8');
assert(lemonSrc.includes("'task transition'"), "bin/lemon.js 注册了 'task transition' 命令");
assert(lemonSrc.includes("phase: { type: 'string'"), 'task transition 接受 phase 参数');
assert(lemonSrc.includes("artifact: { type: 'string'"), 'task transition 接受 artifact 参数');
assert(lemonSrc.includes("agentId: { type: 'string'"), 'task transition 接受 agentId 参数');
}
// ═══════════════════════════════════════
// Test 9: agent-chat.js Transition 路由注册
// ═══════════════════════════════════════
function test9_transitionRoute() {
console.log('\n🌐 Test 9: Transition API 路由注册');
const routeSrc = fs.readFileSync(path.join(ROOT, 'server/routes/agent-chat.js'), 'utf-8');
assert(routeSrc.includes("/:agentId/task/transition"), '存在 /:agentId/task/transition 路由');
assert(routeSrc.includes('Hot Swap'), '路由包含 Hot Swap 换脑逻辑');
assert(routeSrc.includes('Context Flush'), '路由包含 Context Flush 逻辑');
assert(routeSrc.includes('pending_approval'), '路由包含 P0 审批拦截逻辑');
assert(routeSrc.includes('billing.log'), '路由包含 billing.log 计费写入');
assert(routeSrc.includes('_lessons.md'), '路由包含 _lessons.md 注入');
}
// ═══════════════════════════════════════
// Test 10 (Online): 真实 API 调用测试
// ═══════════════════════════════════════
async function test10_onlineAPI() {
console.log('\n🌍 Test 10: 在线 API 调用测试 (需要服务器)');
try {
// 10a: 调用 transition API (对一个不存在的 Agent 应返回 404)
const res1 = await httpPost('/agent/_nonexistent_agent/task/transition', { phase: 'plan' });
assert(res1.status === 404, 'Transition API 对不存在的 Agent 返回 404');
// 10b: 如果 master Agent 存在, 尝试对它做一次 research -> plan 的切换
const res2 = await httpPost('/agent/master/task/transition', { phase: 'plan', artifact: '' });
assert(res2.status === 200 && res2.body?.success === true, 'Master Agent transition(plan) 返回 success');
// 10c: 检查 master 的 main_skill 是否被换成了 meta-plan
const masterSkillPath = path.join(ROOT, 'system/agents/master/.claude/skills/main_skill/SKILL.md');
if (fs.existsSync(masterSkillPath)) {
const content = fs.readFileSync(masterSkillPath, 'utf-8');
assert(content.includes('meta-plan'), 'Master 的 main_skill 已被换为 meta-plan');
} else {
assert(false, 'Master 的 main_skill/SKILL.md 不存在(可能目录结构不同)');
}
} catch (err) {
assert(false, '在线 API 测试', `服务器连接失败: ${err.message}. 请先启动服务器 (node server/index.js)`);
}
}
// ═══════════════════════════════════════
// Runner
// ═══════════════════════════════════════
async function main() {
console.log('═══════════════════════════════════════');
console.log(' 🏗️ Harness Engineering 测试套件');
console.log('═══════════════════════════════════════');
const isOnline = process.argv.includes('--online');
test1_metaSkillFiles();
test2_baseTemplate();
test3_hotSwap();
test4_contextFlush();
test5_lessonsInjection();
test6_billingLog();
test7_goalsServiceStates();
test8_cliRegistration();
test9_transitionRoute();
if (isOnline) {
await test10_onlineAPI();
} else {
console.log('\n⏭️ 跳过在线测试 (使用 --online 参数启用)');
}
// 结果汇总
console.log('\n═══════════════════════════════════════');
console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败, 共 ${passed + failed} 项`);
if (failed > 0) {
console.log('\n 失败项:');
results.filter(r => r.status === '❌').forEach(r => {
console.log(` ❌ ${r.testName}: ${r.detail}`);
});
}
console.log('═══════════════════════════════════════\n');
process.exit(failed > 0 ? 1 : 0);
}
main();