/** * 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(/提炼实现尚未接线/); }); });