dream-service.test.js 6.95 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('默认实现没接线时抛得明白(别让调用方以为「跑了但没产出」)', async () => {
    hire(CO_A);
    seedMemory(CO_A, ['一条']);
    await expect(dream.dreamOnce(AGENT)).rejects.toThrow(/提炼实现尚未接线/);
  });
});