/** * 图执行的**真实链路 smoke** —— 全程零注入,唯一被替换的是最外层的 Claude CLI 子进程。 * * ── 这条测试还的是哪笔债 ────────────────────────────────────────────── * [27-P3/P4](../../../docs/todo/system-design/27-p3-p4-work-graph-console.md) §十 末 * 「四条硬规矩」第 2 条: * * > **测试全绿 ≠ 能跑**。P3 的引擎用例至今仍一律注入假 executor / 假 planner, * > 第 7、8、9、10 号缺陷和当时的「planWorkGraph 未接线」全部在覆盖范围之外—— * > **夹具正好绕开了缺失的那一环**。……**凡是靠注入点验的能力,都要有一条真实路径的 * > smoke 进 CI 才算验收完成**——这条欠账仍未还。 * * 在这之前,唯一走真实路径的是 `scripts/real-pm-smoke.js`(人工跑、要真 API 额度、 * 而且验的是 DevLoop 不是图执行),**没进 CI,随时会腐坏**。 * * ── 「零注入」的边界画在哪 ──────────────────────────────────────────── * 不调 `setAgentExecutor` / `setPlanWorkGraph`。链路完整走: * advanceOne → defaultAgentExecutor → AgentManager → ClaudeCliProtocol * → onAcpEvent(mock/files, session/result) → 落盘 → readNodeProduct * → settleNode → spendBudget → 边界守卫 * 只有最后那层真子进程被 `LEMONBOSS_CLAUDE_MODE=mock` 换成 fixture 回放 —— * 真 LLM 进不了 CI(要额度、要分钟级时间、输出不确定),但**「真 LLM」和「真代码路径」 * 是两件事**,这里换掉前者、保住后者。 * * ── 三条断言各盯一处「假 executor 天然绕开」的接线 ──────────────────── * ① 产物落盘:`mock/files` → `defaultAgentExecutor` 的 onAcpEvent → 节点产物目录。 * 假 executor 直接返回 `{status:'success'}`,这一环整个不存在。 * ② token 计数:`session/result.usage` → onAcpEvent 累加 → `spendBudget`。 * 这是 §六 验收 10 卡了两轮的那一维(「三个维度写在一起,只有它没有产出方」)。 * ③ 边界守卫真的开了:`boundaryGuardApplicable` 只在 `agentExecutor === defaultAgentExecutor` * 时返回 true —— 注入假 executor 的用例里它**恒为 false**,那条分支实际零覆盖。 * * ── 隔离 ────────────────────────────────────────────────────────────── * GRAPH_DB_PATH / GRAPH_RUNS_DIR 都指临时目录,且必须在 require 之前设 * (两个模块都在顶部快照)。`LEMONBOSS_CLAUDE_MODE` 同理:ClaudeCliProtocol 的 * MOCK_MODE 是模块级 const。 */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-real-chain-')); process.env.GRAPH_DB_PATH = path.join(TMP, 'graph.sqlite'); process.env.GRAPH_RUNS_DIR = path.join(TMP, 'runs'); // fixture 名 = 本体 id(图执行侧的 agent 名带公司前缀,mock 会先剥掉)。 // 用 prd-planning:本体真实存在,fixture 自带 files[](含 readNodeProduct 要读的 // attempt-{attempt}.json)与 usage。 const AGENT = 'prd-planning'; const COMPANY = 'zz-real-chain-co'; let wdb; let engine; describe('图执行真实链路 smoke(零注入,只换掉真 CLI 子进程)', () => { let runId; let nodeRunId; let rootRunId; beforeAll(async () => { for (const m of ['../../services/graph-db', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine']) { delete require.cache[require.resolve(m)]; } wdb = require('../../services/work-graph-db'); engine = require('../../services/graph-engine'); const db = wdb.getDb(); const now = new Date().toISOString(); db.prepare(`INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)`) .run(AGENT, AGENT, `system/agents/${AGENT}`, now, now); const g = wdb.createWorkGraph({ companyId: COMPANY, nodes: [{ nodeName: AGENT, systemAgentId: AGENT }], edges: [] }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: COMPANY, executionMode: 'manual', // 额度给足:这条 smoke 要验的是「有没有数」,不是「够不够花」 budget: { total_agent_calls: 10, total_tokens: 5_000_000, spent: { agent_calls: 0, tokens: 0 } }, }); engine.markRunReady(run.id); runId = run.id; // createRun 的返回值不一定带 root_run_id,从库里读回来(这条 run 自己就是 root) rootRunId = wdb.getRun(run.id).root_run_id; expect(rootRunId).toBe(run.id); // ready → running(「下一步」只对 running/paused 可用) engine.startRun(runId, { mode: 'manual' }); const out = await engine.nextStep(runId, { task: '真实链路 smoke:产出一份 PRD' }); if (!out.ok) throw new Error(`nextStep 没推动:${JSON.stringify(out)}`); nodeRunId = out.node.id; // 节点必须真的跑完 —— 停在 running / fail 的话下面三条断言验的是残局 expect(out.node.status).toBe('success'); }, 60_000); afterAll(() => { // 真实链路会在本体下建公司态目录(AgentManager 的 cwd 是 system/agents/{aid}/{cid}/{taskId})。 // 它 gitignore 挡着、不进版本库,但留着会让别的用例遍历 system/agents/ 时多扫一份, // 也让 `ls system/agents/prd-planning/` 出现一个没人认领的公司。 fs.rmSync(path.join(__dirname, '../../../system/agents', AGENT, COMPANY), { recursive: true, force: true }); delete process.env.LEMONBOSS_CLAUDE_MODE; delete process.env.GRAPH_DB_PATH; delete process.env.GRAPH_RUNS_DIR; fs.rmSync(TMP, { recursive: true, force: true }); }); it('① mock/files 真的落到了节点产物目录(假 executor 下这一环整个不存在)', () => { const dir = path.join(process.env.GRAPH_RUNS_DIR, rootRunId, 'nodes', nodeRunId); expect(fs.existsSync(path.join(dir, 'attempt-0.json'))).toBe(true); expect(fs.readFileSync(path.join(dir, 'prd.md'), 'utf8')).toContain('PRD'); // attempt-0.json 是引擎 readNodeProduct 要读的那份 —— 它读不到就判不出 verdict, // 所以这条同时也证明了 `{attempt}` 占位符替换那一步没坏 const product = JSON.parse(fs.readFileSync(path.join(dir, 'attempt-0.json'), 'utf8')); expect(product.verdict).toBe('no_prior_failure'); }); it('② token 用量真的从 session/result.usage 数进了预算(§六 验收 10 那一维)', () => { const spent = JSON.parse(wdb.getRun(rootRunId).budget_json).spent; expect(spent.agent_calls).toBe(1); // fixture 报 1200 + 340;断言等值而不是 >0 —— 「大于 0」在累加写成 `=` 而非 `+=` 时也成立 expect(spent.tokens).toBe(1540); }); it('③ 边界守卫在这条路径上是**开着**的(注入假 executor 时它恒为假,那条分支零覆盖)', () => { // 判据取自引擎自己的开关函数,而不是去数 timeline 事件: // 守卫在宿主仓库干净时本来就不产生事件,拿「没有事件」当「没开」会反过来判错。 expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(true); // 对照:注入之后必须变假 —— 少了这条,上面那句在函数写成 `return true` 时也全绿 engine.setAgentExecutor(async () => ({ status: 'success' })); expect(engine.boundaryGuardApplicable({ nodeKind: 'agent_call' })).toBe(false); engine.setAgentExecutor(null); }); });