dream-service.test.js 19.1 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 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
/**
 * 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);
    }
  });
});