dream-service.test.js
19.1 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
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
/**
* dream 的骨架(29-DREAM §四 步骤 2 / 3 / 5)。
*
* ── 三条判据各盯一个已知会出事的地方 ──────────────────────────────────
* ① **「有哪些公司」只认 `company_agents` 表,不扫目录。**
* `system/agents/{aid}/{cid}/` 是约定不是校验:实查 96 个二级目录里混着
* `train/` `workspace/` 和带时间戳的测试残留(`tmp-w7-*-1786986291739-*`)。
* 拿目录枚举公司 = 把测试夹具当经验原料喂给提炼 agent。
* ② **空转不许起 agent 调用。**
* 实查 75 份 memory.md 只有 7 份有非模板内容 —— 每日跑起来绝大多数 agent 没有新原料。
* 不拦的话每天几十次「读完发现没东西」的 LLM 调用在烧钱。
* ③ **去敏判定失败 = 拒绝**(决策 A 的失效方向)。
* 「少沉淀一条经验」与「泄漏一次机密」代价不对称,判据必须偏向贵的那一侧。
*
* 外加可追溯(决策 D):每条沉淀都要带得回原料。
*/
import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-dream-'));
process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite');
const ROOT = path.resolve(__dirname, '../../..');
const AGENT = 'zz-dream-body';
const CO_A = 'zz-dream-co-a';
const CO_B = 'zz-dream-co-b';
const BODY = path.join(ROOT, 'system', 'agents', AGENT);
const dream = require('../../services/dream-service');
const wdb = require('../../services/work-graph-db');
function seedMemory(companyId, lines) {
const dir = path.join(BODY, companyId, 'memory');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'memory.md'),
`# 我的记忆\n\n## 关键事实\n\n_(刚入职,尚无记忆)_\n\n${dream.RAW_SECTION}\n${lines.map((l) => `- ${l}`).join('\n')}\n`,
'utf-8');
}
function hire(companyId) {
const db = wdb.getDb();
const now = new Date().toISOString();
db.prepare(`INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)`)
.run(AGENT, AGENT, `system/agents/${AGENT}`, now, now);
db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
VALUES (?, ?, ?, 'active', ?)`).run(companyId, AGENT, AGENT, now);
}
describe('dream · 骨架', () => {
beforeEach(() => {
fs.rmSync(BODY, { recursive: true, force: true });
dream.setDistiller(null);
dream.setScreener(null);
});
afterAll(() => {
fs.rmSync(BODY, { recursive: true, force: true });
fs.rmSync(TMP, { recursive: true, force: true });
delete process.env.GRAPH_DB_PATH;
});
it('① 公司清单来自 company_agents 表 —— 目录里的测试残留不算公司', () => {
hire(CO_A);
seedMemory(CO_A, ['给 A 做官网 → 验收脚本先行']);
// 目录里再造两个「看起来像公司」的:一个是 ArenaSK 时代的运行态,一个是带时间戳的测试残留
for (const junk of ['train', 'tmp-w7-bootstrap-1786986291739-9n2xv']) {
seedMemory(junk, ['这条不该被当成经验原料']);
}
const companies = dream.companiesOf(AGENT);
expect(companies).toContain(CO_A);
expect(companies).not.toContain('train');
expect(companies).not.toContain('tmp-w7-bootstrap-1786986291739-9n2xv');
const { fresh } = dream.collectMaterial(AGENT);
expect(fresh.map((f) => f.text)).toEqual(['给 A 做官网 → 验收脚本先行']);
});
it('② 没有新原料 → 一次 agent 调用都不起', async () => {
hire(CO_A);
seedMemory(CO_A, ['第一条']);
const distill = vi.fn(async () => []);
dream.setDistiller(distill);
const first = await dream.dreamOnce(AGENT);
expect(first.skipped).toBeNull();
expect(distill).toHaveBeenCalledTimes(1);
// 原料没变 → 第二轮直接跳过,且**说清为什么**(别让调用方猜)
const second = await dream.dreamOnce(AGENT);
expect(second.skipped).toBe('no-new-material');
expect(distill).toHaveBeenCalledTimes(1);
// 多了一条才继续,且只喂**增量**(不是整段重来)
seedMemory(CO_A, ['第一条', '第二条']);
dream.setDistiller(async ({ entries }) => {
expect(entries.map((e) => e.text)).toEqual(['第二条']);
return [];
});
expect((await dream.dreamOnce(AGENT)).skipped).toBeNull();
});
it('③ 去敏判定不通过 / 抛异常,都不沉淀(失效方向是「少沉淀」)', async () => {
hire(CO_A);
seedMemory(CO_A, ['一条经验']);
const SRC = `${CO_A}:memory.md#1`; // 出处必须是本轮真喂进去的,编的会被前置闸门直接拒
dream.setDistiller(async () => [
{ title: '通过的', body: '抽象经验', sources: [SRC] },
{ title: '被拒的', body: '含公司名', sources: [SRC] },
{ title: '判定炸了的', body: '???', sources: [SRC] },
]);
dream.setScreener(async ({ candidate }) => {
if (candidate.title === '判定炸了的') throw new Error('判定 agent 挂了');
return { shareable: candidate.title === '通过的', reason: '含公司特定信息' };
});
const r = await dream.dreamOnce(AGENT);
expect(r.distilled).toBe(3);
expect(r.accepted).toBe(1);
expect(r.rejected).toHaveLength(2);
// 判定抛异常那条必须留下**为什么**,否则现场只看到「少了一条」
expect(r.rejected.find((x) => x.title === '判定炸了的').reason).toContain('判定失败');
const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8');
expect(exp).toContain('通过的');
expect(exp).not.toContain('被拒的');
});
it('编出处 / 不给出处的产物直接拒 —— 可追溯是唯一的质量抓手(决策 D)', async () => {
// 「这条结论对不对」机器判不了,但「引用是不是编的」可以。
// 编了出处的经验最危险:人抽查时顺着一个不存在的指针放弃,然后默认它是真的。
hire(CO_A);
seedMemory(CO_A, ['真素材']);
dream.setDistiller(async () => [
{ title: '编出处的', body: 'x', sources: ['不存在的公司:memory.md#99'] },
{ title: '不给出处的', body: 'y' },
{ title: '规矩的', body: 'z', sources: [`${CO_A}:memory.md#1`] },
]);
dream.setScreener(async () => ({ shareable: true }));
const r = await dream.dreamOnce(AGENT);
expect(r.accepted).toBe(1);
expect(r.rejected).toHaveLength(2);
expect(r.rejected.find((x) => x.title === '编出处的').reason).toContain('本轮不存在的出处');
expect(r.rejected.find((x) => x.title === '不给出处的').reason).toContain('没有给出 sources');
// 编出处那条**没被喂给去敏判定**(前置闸门,省一次调用)
const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8');
expect(exp).toContain('规矩的');
expect(exp).not.toContain('编出处的');
});
it('auditDistilled 把结论与原料原文并排摆出来,悬空指针标 null', async () => {
hire(CO_A);
seedMemory(CO_A, ['原料甲的正文']);
dream.setDistiller(async ({ entries }) => [
{ title: '经验甲', body: '正文', sources: entries.map((e) => e.source) },
]);
dream.setScreener(async () => ({ shareable: true }));
await dream.dreamOnce(AGENT);
const audit = dream.auditDistilled(AGENT);
expect(audit).toHaveLength(1);
expect(audit[0].title).toBe('经验甲');
expect(audit[0].sources[0].text).toBe('原料甲的正文'); // 顺着指针取回了原文
// 原料被清掉之后,指针悬空要显示成 null 而不是假装还在
seedMemory(CO_A, []);
expect(dream.auditDistilled(AGENT)[0].sources[0].text).toBeNull();
});
it('每条沉淀都带得回原料(决策 D 的可追溯起步)', async () => {
hire(CO_A);
seedMemory(CO_A, ['素材一']);
dream.setDistiller(async ({ entries }) => [
{ title: '经验甲', body: '正文', sources: entries.map((e) => e.source) },
]);
dream.setScreener(async () => ({ shareable: true }));
await dream.dreamOnce(AGENT);
const exp = fs.readFileSync(path.join(BODY, 'memory', 'experience.md'), 'utf-8');
expect(exp).toContain(`${CO_A}:memory.md#1`);
});
it('两家公司的原料都进来,且各自带自己的出处(跨公司汇流的最小形态)', async () => {
hire(CO_A); hire(CO_B);
seedMemory(CO_A, ['A 的经验']);
seedMemory(CO_B, ['B 的经验']);
const seenSources = [];
dream.setDistiller(async ({ entries }) => {
seenSources.push(...entries.map((e) => e.source));
return [];
});
await dream.dreamOnce(AGENT);
expect(seenSources).toEqual(expect.arrayContaining([`${CO_A}:memory.md#1`, `${CO_B}:memory.md#1`]));
});
it('不注入任何东西时走的是默认实现(两次真 agent 调用,这里只换掉 CLI 那一层)', async () => {
hire(CO_A);
seedMemory(CO_A, ['素材一']);
const calls = [];
dream.setDreamAgentCaller(async (agentName, prompt) => {
calls.push({ agentName, prompt });
if (agentName === dream.DISTILLER_AGENT) {
return `好的。\n\`\`\`json\n[{"title":"结论一","body":"做法一","sources":["${CO_A}:memory.md#1"]}]\n\`\`\``;
}
return '```json\n{"shareable":true,"reason":"没有可识别主体"}\n```';
});
try {
const r = await dream.dreamOnce(AGENT);
expect(r.accepted).toBe(1);
// 两个 agent 各被调到,且**判定那次的提示词里不许出现原料**(决策 A 的核心)
expect(calls.map((c) => c.agentName)).toEqual([dream.DISTILLER_AGENT, dream.SCREENER_AGENT]);
expect(calls[0].prompt).toContain('素材一');
expect(calls[1].prompt).not.toContain('素材一');
expect(calls[1].prompt).toContain('结论一');
} finally {
dream.setDreamAgentCaller(null);
}
});
});
/**
* 默认实现(真 agent 调用那一侧)—— 29-DREAM §四 步骤 3。
*
* 这一组**不起真 CLI**:验的是「回复 → 结构化产物」这段解析与失效方向,
* 而不是模型写得好不好。模型那半归真跑,进不了 CI。
*/
describe('dream · 默认实现的解析与失效方向', () => {
const d = require('../../services/dream-service');
it('extractJson:围栏块优先,其次括号配平;取不出来返回 null(不猜)', () => {
expect(d.extractJson('废话\n```json\n[{"a":1}]\n```\n后记', 'array')).toEqual([{ a: 1 }]);
expect(d.extractJson('前言 [{"a":2}] 后语', 'array')).toEqual([{ a: 2 }]);
expect(d.extractJson('```json\n{"shareable":true}\n```', 'object')).toEqual({ shareable: true });
// 类型不符不将就:要数组给了对象 → null
expect(d.extractJson('```json\n{"a":1}\n```', 'array')).toBeNull();
// 通篇没有 JSON → null,而不是硬凑一个
expect(d.extractJson('我觉得这批记录没什么可提炼的。', 'array')).toBeNull();
});
it('提炼取不出 JSON → **抛**,不是静默返回空(真调 defaultDistiller)', async () => {
// 「产出格式坏了」和「没提炼出东西」是两件事。混为一谈会让前者以后者的形式
// 永远藏下去 —— 每天都显示「今天没经验可沉淀」,而真相是解析一直在失败。
d.setDreamAgentCaller(async () => '我觉得这批记录没什么可提炼的。');
try {
await expect(d.defaultDistiller({ agentId: 'x', entries: [{ source: 's', text: 't' }] }))
.rejects.toThrow(/取不出 JSON 数组/);
} finally { d.setDreamAgentCaller(null); }
});
it('提炼产物形状不对的那几条被丢掉,合规的留下(真调 defaultDistiller)', async () => {
d.setDreamAgentCaller(async () => '```json\n[{"title":"好的","body":"正文"},{"body":"缺 title"},"字符串"]\n```');
try {
const out = await d.defaultDistiller({ agentId: 'x', entries: [{ source: 's', text: 't' }] });
expect(out).toHaveLength(1);
expect(out[0].title).toBe('好的');
} finally { d.setDreamAgentCaller(null); }
});
it('判定回复解析不了 → shareable:false,且 reason 带上原文片段(真调 defaultScreener)', async () => {
// fail-closed:判不准就不放。reason 必须能让人看出「是判否还是判炸」。
d.setDreamAgentCaller(async () => '我觉得应该可以吧');
try {
const v = await d.defaultScreener({ candidate: { title: 't', body: 'b' } });
expect(v.shareable).toBe(false);
expect(v.reason).toContain('我觉得应该可以吧');
} finally { d.setDreamAgentCaller(null); }
});
it('判定回复里 shareable 不是布尔(比如 "true" 字符串)也判否,不将就', async () => {
d.setDreamAgentCaller(async () => '```json\n{"shareable":"true"}\n```');
try {
expect((await d.defaultScreener({ candidate: { title: 't', body: 'b' } })).shareable).toBe(false);
} finally { d.setDreamAgentCaller(null); }
});
it('两个本体真实存在且过得了毕业闸门(否则默认实现一跑就 Agent not found)', () => {
const locator = require('../../services/agent-locator');
for (const id of [d.DISTILLER_AGENT, d.SCREENER_AGENT]) {
expect(fs.existsSync(path.join(ROOT, 'system', 'agents', id, 'agent.md'))).toBe(true);
expect(locator.missingBodyDefinition(id)).toEqual([]);
}
});
it('两个本体的 agent.md 里写着各自的产出契约,且与代码读的键名逐字一致', () => {
// 这一条守的是「产生端是 agent 时,『谁产生它』的答案是一份提示词」——
// 27-P3/P4 §十 硬规矩 1 的第 13 处就栽在这里:引擎四环全通,
// 而产出方的 agent.md 里根本没有那个键。
const read = (id) => fs.readFileSync(path.join(ROOT, 'system', 'agents', id, 'agent.md'), 'utf-8');
const distiller = read(d.DISTILLER_AGENT);
for (const key of ['title', 'body', 'sources']) expect(distiller).toContain(key);
const screener = read(d.SCREENER_AGENT);
for (const key of ['shareable', 'reason']) expect(screener).toContain(key);
// 判定方**不许**看到原料:本体里要明写这一点,否则下一个人会顺手把原料塞进提示词
expect(screener).toContain('看不到原料');
});
});
/**
* 定义文件写入(决策 B)与定时挂载(决策 C)。
*
* 这两块的共同点:**失效方向都是静默的**。
* 定义文件写坏 → 本体行为漂了 / 雇不进公司,都不报错;
* 定时挂载写错 → 要么每天空烧几十次 LLM,要么一次都不跑,两种都没人会发现。
*/
describe('dream · 定义文件写入与定时', () => {
const d = require('../../services/dream-service');
const locator = require('../../services/agent-locator');
const ACCEPTED = [{ title: '结论一', body: '做法一', sources: ['s1'] }];
it('未进 git 历史的文件不写,且逐个报出为什么(不写不许是静默的)', () => {
// zz-dream-body 是测试造的运行态本体,不在 git 里 —— 正好是闸门要拦的那种
const r = d.writeDefinitionFiles(AGENT, ACCEPTED, { dryRun: true });
expect(r).toHaveLength(3);
for (const x of r) {
expect(x.written).toBe(false);
expect(x.reason).toMatch(/没有被 git 跟踪|读不到/);
}
});
it('真写一次:区块外一个字节不动、幂等、写完毕业闸门仍过', () => {
// 挑一个**真被 git 跟踪且干净**的本体来走真路径 —— 那正是闸门放行的前提。
// 写完在 finally 里还原,并断言确实还原干净(否则这条测试会把仓库弄脏)。
const BODY = 'dream-screener';
const abs = path.join(ROOT, 'system', 'agents', BODY, 'soul.md');
const orig = fs.readFileSync(abs, 'utf-8');
expect(orig).not.toContain(d.DREAM_BEGIN); // 样本非空:现在还没有区块
try {
const r1 = d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['soul.md'] });
expect(r1[0].written).toBe(true);
const after1 = fs.readFileSync(abs, 'utf-8');
expect(after1).toContain(d.DREAM_BEGIN);
expect(after1).toContain('结论一');
// 区块外一个字节不动
expect(after1.slice(0, after1.indexOf(d.DREAM_BEGIN)).trim()).toBe(orig.trim());
// 写完毕业闸门仍过
expect(locator.missingBodyDefinition(BODY)).toEqual([]);
// 幂等:同一批经验再写一次,只有一个区块,且长度不涨
d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['soul.md'] });
const after2 = fs.readFileSync(abs, 'utf-8');
expect(after2.match(new RegExp(d.DREAM_BEGIN, 'g'))).toHaveLength(1);
expect(after2.length).toBe(after1.length);
} finally {
fs.writeFileSync(abs, orig, 'utf-8');
expect(fs.readFileSync(abs, 'utf-8')).toBe(orig);
}
});
it('agent.md 的前 2000 字符会被改动时就不写 —— 规划 agent 只看这一段', () => {
// dream-screener/agent.md 比 2000 字符短,追加区块必然落进前 2000 字符里,
// 所以真调下来必须是「不写 + 说清理由」。
const BODY = 'dream-screener';
const abs = path.join(ROOT, 'system', 'agents', BODY, 'agent.md');
const orig = fs.readFileSync(abs, 'utf-8');
const r = d.writeDefinitionFiles(BODY, ACCEPTED, { files: ['agent.md'] });
expect(r[0].written).toBe(false);
expect(r[0].reason).toContain('前 2000 字符');
expect(fs.readFileSync(abs, 'utf-8')).toBe(orig); // 一个字节都没动
});
it('没有通过闸门的产物时,三个文件都不写且理由一致', () => {
const r = d.writeDefinitionFiles(AGENT, [], { dryRun: true });
expect(r.map((x) => x.written)).toEqual([false, false, false]);
expect(r[0].reason).toContain('没有通过闸门的产物');
});
it('可跑 dream 的本体清单跳过保留 id(`_base` / `_registry.json` 不是 agent)', () => {
const ids = d.listDreamableAgents();
expect(ids.length).toBeGreaterThan(10); // 样本非空
expect(ids.some((x) => x.startsWith('_'))).toBe(false);
expect(ids).toContain('dream-distiller');
});
it('定时器 unref 且幂等挂载(少了 unref,短命进程会被 24 小时的 timer 吊住不退出)', () => {
const t1 = d.startDaily({ intervalMs: 60_000 });
const t2 = d.startDaily({ intervalMs: 60_000 });
expect(t2).toBe(t1); // 重复挂载不产生第二个
expect(typeof t1.unref).toBe('function');
d.stopDaily();
});
it('扫描时一个本体抛异常不拖垮整轮,且留下是谁抛的(真调 dreamSweep)', async () => {
hire(CO_A);
seedMemory(CO_A, ['会让提炼抛的原料']);
d.setDistiller(async () => { throw new Error('故意炸'); });
try {
// agents 显式给,避免真扫 76 个本体
const rs = await d.dreamSweep({ agents: [AGENT, 'dream-screener'] });
expect(rs).toHaveLength(2);
const bad = rs.find((r) => r.agentId === AGENT);
expect(bad.error).toContain('故意炸'); // 是谁炸的、为什么,都留下了
const other = rs.find((r) => r.agentId === 'dream-screener');
expect(other.error).toBeUndefined(); // 没被拖垮
} finally {
d.setDistiller(null);
}
});
});