graph-real-chain-smoke.test.js
8 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
/**
* 图执行的**真实链路 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);
});
});