dream-service.test.js 16.9 KB
/**
 * 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('不注入任何东西时走的是默认实现(两次真 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":["x"]}]\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);
    }
  });
});