dream-service.test.js
6.95 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
/**
* 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, ['一条经验']);
dream.setDistiller(async () => [
{ title: '通过的', body: '抽象经验', sources: ['a'] },
{ title: '被拒的', body: '含公司名', sources: ['b'] },
{ title: '判定炸了的', body: '???', sources: ['c'] },
]);
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 ({ 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('默认实现没接线时抛得明白(别让调用方以为「跑了但没产出」)', async () => {
hire(CO_A);
seedMemory(CO_A, ['一条']);
await expect(dream.dreamOnce(AGENT)).rejects.toThrow(/提炼实现尚未接线/);
});
});