/** * 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); } }); });