/** * Unit Tests — R-19 **org 层**数据契约:先有读回方,才谈声明方 * * 起因是第五次真 CLI 冒烟:`graph_edge_contracts` 里 `edge_layer='org'` 是 0 行。 * 直觉判断是「orggraph-planning 模板没教它声明」,核实之后这个判断是**错的**: * * - work-graph-db.readDefGraph 给 org 图的节点**全部**写 nodeKind='subgraph' * (注释原文:「org graph 的节点全部是 subgraph executor」); * - graph-engine.describeInputs 第一行就是 `ctx.nodeKind === 'subgraph'` 时返回空串。 * * 两条合起来 = org 层的边契约在输入侧**一个读回方都没有**。先补模板的话,产出的正是 * 「声明了没人读」——本仓库已经踩了 14 次的那个形状。 * * 所以读回方接在 `buildPlanningPrompt`(org 节点派给 workgraph-planning 的提示词)上: * org 层契约的语义正落在这里 —— 「你这个 org node 要吃上游产出的 X、要产下游要吃的 Y, * 于是你规划的子图必须让入口节点消费 X、必须有节点产出 Y」。 * * 本文件锁四件事: * ① 读回方成立 org 边上的 produces → 规划提示词里出现 ③bis 段 + 上游实例的**绝对路径** * ② 三种口径 查得到给绝对路径 / required 查不到给 ⚠️ / 可选查不到整行省略 * ③ 无契约不留空标题 一条契约都没有时整段(含标题)消失,不给 agent「查过了,没有」的错觉 * ④ 模板防漂移 orggraph-planning 模板里出现的每个「边上声明键」都必须被 * org-graph-service.normalizeContracts 真读到;kind 取值域逐字对齐 ARTIFACT_KINDS * * buildPlanningPrompt / describeOrgContracts **故意不导出**(graph-engine.js 的口径: * 不为测试新开公开面),所以 ①②③ 全部走 advanceOne 这个真入口: * 用 setPlanWorkGraph 只替掉**上游** org 节点的规划(给它一张现成子图), * 被测节点则委托回真的 `defaultPlanWorkGraph`,靠替换 AgentManager 截获它发出的提示词。 * * 隔离手法同 graph-files-index.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require, * 仓库 data/ 全程不被碰;Claude CLI 一次都不起(AgentManager 被换成假货)。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const REPO = path.resolve(__dirname, '../../..'); describe('R-19 org 层数据契约(读回方 = buildPlanningPrompt)', () => { let wdb; let orgSvc; let engine; let runsDir; const TEST_DB_PATH = '/tmp/test-org-layer-contracts.sqlite'; const AM_PATH = require.resolve('../../src/core/agent-manager'); /** defaultPlanWorkGraph 发出的提示词,一次调用一条 */ let captured = []; let savedAgentManager; /** * 假 AgentManager:截下提示词就抛。 * * 抛而不是「回一张合法子图」是刻意的:本文件要验的是**提示词内容**, * 让规划"成功"还得替它造一张过五项校验的子图,那些代码与被测行为无关。 * defaultPlanWorkGraph 对 callAgent 抛出的错不重试(只对「图画错了」重试), * 所以一次 advanceOne 恰好产出一条提示词,节点按 node_error 失败 —— 正是我们要的。 */ class FakeAgentManager { async callAgent(agentName, prompt) { captured.push({ agentName, prompt }); throw new Error('fake-agent-manager: 已截获提示词,不真的调 CLI'); } } beforeAll(() => { for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'org-contracts-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', ]) { delete require.cache[require.resolve(m)]; } wdb = require('../../services/work-graph-db'); orgSvc = require('../../services/org-graph-service'); engine = require('../../services/graph-engine'); // defaultPlanWorkGraph 里是 `require('../src/core/agent-manager')`(调用时才 require), // 所以换掉 require.cache 里的 exports 就够,不必去 patch 原型 —— 也就不会构造真的 // AgentManager(它的构造函数会挂上全局 LogManager / SessionPool)。afterAll 原样还回去。 savedAgentManager = require.cache[AM_PATH]; require.cache[AM_PATH] = { id: AM_PATH, filename: AM_PATH, loaded: true, children: [], paths: [], exports: FakeAgentManager, }; }); afterAll(() => { if (savedAgentManager) require.cache[AM_PATH] = savedAgentManager; else delete require.cache[AM_PATH]; for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_RUNS_DIR; for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) { delete require.cache[require.resolve(m)]; } }); // ── 脚手架 ─────────────────────────────────────────────────── let seq = 0; /** 前缀刻意带 tmp-:resolvePlanningAgentName 会拿它去 REPO/data/companies 下找目录, * 找不到就试着 hire 一次;hire 第一步 get(companyId) 读不到 company.json 直接抛, * 被 catch 掉 —— 全程不在仓库 data/ 下建任何东西。 */ const nextCompany = () => `tmp-orgc-${process.pid}-${++seq}`; function ensureAgents(ids) { const db = wdb.getDb(); const ts = new Date().toISOString(); const ins = 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', ?, ?) `); for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); } /** * 起一张 org run,把入口节点跑通(它的子图写出 `writes` 里的文件并被索引), * 让第二个 org 节点变 ready,然后再推一步 —— 那一步就会调真的 defaultPlanWorkGraph。 * * @param edges org 边,可带 produces / needs * @param writes 入口节点的子图那个 work 节点要落盘的文件 {文件名: 内容} * @returns {Promise<{prompt: string, runId: string}>} 被测节点(org-coder)收到的规划提示词 */ async function planOrgRun({ companyId, taskId, nodes, edges, writes = {} }) { expect(orgSvc.writeGraph(companyId, { nodes, edges }).ok).toBe(true); ensureAgents(['plan-worker']); const sub = wdb.createWorkGraph({ companyId, taskId, nodes: ['plan-worker'], edges: [] }); expect(sub.errors || []).toEqual([]); // 上游 org 节点用注入的假规划(一张现成子图);被测节点委托回真路径。 engine.setPlanWorkGraph(async (ctx) => { if (ctx.nodeName === 'org-planner') return { workGraphId: sub.id }; return engine.defaultPlanWorkGraph(ctx); }); // 子图里那个 work 节点:把文件写进自己的私有目录,outputRef 指向该目录 // —— 形状与 defaultAgentExecutor 一致,settleNode 的 indexNodeOutputs 才会登记它。 engine.setAgentExecutor(async (ctx) => { const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); for (const [name, body] of Object.entries(writes)) { fs.writeFileSync(path.join(dir, name), body); } return { status: 'success', outputRef: dir }; }); let runId; try { const run = wdb.createRun({ graphKind: 'org', graphId: companyId, companyId, taskId }); runId = run.id; engine.markRunReady(run.id); engine.startRun(run.id, { mode: 'manual' }); captured = []; await engine.advanceOne(run.id); // org-planner → 起子 run const child = wdb.listRunTree(run.id).find((r) => r.parent_run_id === run.id); await engine.advanceOne(child.id); // 子图那个节点跑完 → 回写父节点 expect(wdb.getRun(child.id).run_status).toBe('completed'); await engine.advanceOne(run.id); // org-coder → 真 defaultPlanWorkGraph } finally { engine.setPlanWorkGraph(null); engine.setAgentExecutor(null); } expect(captured).toHaveLength(1); return { prompt: captured[0].prompt, runId }; } // ── ① 读回方成立(最关键的一条)─────────────────────────────── it('① org 边上声明 produces → 被测 org node 的规划提示词里出现 ③bis 段与上游实例的绝对路径', async () => { const companyId = nextCompany(); const taskId = 'task-org-in'; const { prompt } = await planOrgRun({ companyId, taskId, nodes: ['org-planner', 'org-coder', 'org-validator'], edges: [ // 入边:上游 org node 会产出一份 brd 交给 org-coder { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }, // 出边:org-coder 自己要产出一份 sdd 给下游 org node; // 同一条边上的 needs 是**下游**要吃的(产出方是隔了一个节点的 org-planner), // 它不是 org-coder 的产出 —— 混进去就会命令 org-coder 再产一份 brd。 { from: 'org-coder', to: 'org-validator', condition: 'success', produces: ['sdd'], needs: ['brd'] }, ], writes: { 'brd.md': '# 业务需求' }, }); // 提示词确实发给了规划本体(不是发给别人) expect(captured[0].agentName).toMatch(/workgraph-planning$/); // 入边那一半:给的是**绝对路径**,且指向真实落盘的那份 brd.md expect(prompt).toContain('③bis'); expect(prompt).toContain('### 上游节点交给你的输入'); const abs = prompt.match(/^- brd:(.+)$/m); expect(abs, '提示词里没有 brd 那一行').toBeTruthy(); expect(path.isAbsolute(abs[1])).toBe(true); // 真正的证据不是「长得像路径」,而是这个路径**读得出上游刚写的那份内容**。 // (rel_path 是相对公司根算的,节点私有目录在 GRAPH_RUNS_DIR 覆盖下位于公司根之外, // 所以拼回来的绝对路径带 ../ 归一后并不以公司根开头 —— 不拿前缀做断言。) expect(fs.readFileSync(abs[1], 'utf-8')).toBe('# 业务需求'); expect(prompt).not.toContain('⚠️ 上游声明会产出'); // 出边那一半:硬约束语气 + 点名 kind expect(prompt).toContain('### 你这一层必须产出的东西(硬约束)'); expect(prompt).toMatch(/必须有节点真的产出上面每一个 kind/); // 只列出边上 role='produces' 的那些。同一条出边上的 needs(brd)是下游要吃的东西, // 混进来就变成「命令 org-coder 再产一份 brd」——一条它根本没答应过的硬约束。 const produced = prompt.match(/找输入的:(.+)$/m); expect(produced, '提示词里没有「必须产出」那一行').toBeTruthy(); expect(produced[1]).toBe('sdd'); // 出边声明的 kind 不该被误当成「上游给你的输入」列在入边那一半里 expect(prompt).not.toMatch(/^- sdd:/m); }); // ── ② 另两种口径:required 查不到给 ⚠️;可选查不到整行省略 ────── it('② required 声明查不到实例 → ⚠️;required:false 查不到 → 整行省略(不留半句话)', async () => { const companyId = nextCompany(); const taskId = 'task-org-miss'; const { prompt } = await planOrgRun({ companyId, taskId, nodes: ['org-planner', 'org-coder'], edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', // brd 会真的落盘;acceptance 是 required 但没人产;log 是可选且没人产 produces: ['brd', 'acceptance', { kind: 'log', required: false }], }], writes: { 'brd.md': '# 业务需求' }, }); expect(prompt).toMatch(/^- brd:\//m); expect(prompt).toContain('- acceptance:⚠️ 上游声明会产出,但索引里查不到实例'); // 可选且查不到 → 一行都不留(留一行「查不到」会让 agent 以为这是个待办) expect(prompt).not.toMatch(/^- log[::]/m); }); // ── ③ 一条契约都没有时整段消失(含标题)────────────────────── it('③ org 图上一条契约都没声明 → ③bis 整段不出现(空标题会被读成「查过了,没有」)', async () => { const companyId = nextCompany(); const { prompt } = await planOrgRun({ companyId, taskId: 'task-org-none', nodes: ['org-planner', 'org-coder'], edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }], writes: { 'brd.md': '# 业务需求' }, // 实例在索引里,但图上没人声明要它 }); expect(prompt).not.toContain('③bis'); expect(prompt).not.toContain('上游节点交给你的输入'); expect(prompt).not.toContain('必须产出的东西'); // 其余四项输入照旧在(证明省略的只是这一段,不是整段提示词拼挂了) expect(prompt).toContain('## ③ 可读的 artifacts/'); expect(prompt).toContain('## ④ 上一轮 remediation_note'); }); // ── ⑤ 产出侧的事实:org 节点自己什么都不登记 ──────────────────── it('⑤ org 层的 produces 在产出侧不靠 indexNodeOutputs:org 节点 output_ref 恒为 null,实例是子 run 里的 work 节点登记的', async () => { const companyId = nextCompany(); const taskId = 'task-org-out'; const { runId } = await planOrgRun({ companyId, taskId, nodes: ['org-planner', 'org-coder'], edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['brd'] }], writes: { 'brd.md': '# 业务需求' }, }); // org 节点是 subgraph executor,它的 outcome 由 deriveChildOutcome 拼,**不带 outputRef**。 // 所以 indexNodeOutputs 在 org run 上第一行就 return —— 3076 行那句 layer 三元里的 // 'org' 分支对 org run 是走不到的死路径。这不是缺陷,是「org 节点自己不写盘」的必然结果。 const orgNodes = wdb.getDb() .prepare(`SELECT * FROM graph_run_nodes WHERE run_id = ?`).all(runId); expect(orgNodes.length).toBeGreaterThan(0); expect(orgNodes.map((n) => n.output_ref)).toEqual(orgNodes.map(() => null)); // 那份 brd 的索引行是**子 run 里那个 work 节点**登记的(node_run_id 属于子 run)。 // 它之所以能被 org 层的入边声明查到,只因为 graph_files 是按 (company, task) 检索的, // 而子 run 从父 run 继承同一个 task_id —— 这就是 org 层 produces 在产出侧成立的全部理由。 const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] }); expect(rows).toHaveLength(1); expect(orgNodes.map((n) => n.id)).not.toContain(rows[0].produced_by_node_run_id); const child = wdb.listRunTree(runId).find((r) => r.parent_run_id === runId); expect(rows[0].produced_by_run_id).toBe(child.id); }); // ── ④ 模板防漂移:教的键名 / 取值域必须是落库侧真读的那个 ────── it('④ orggraph-planning 模板里的边上声明键,normalizeContracts 必须真的认(差一个字母就是静默失效)', () => { const dir = path.join(REPO, 'system/agents/orggraph-planning'); const text = fs.readdirSync(dir) .filter((f) => f.endsWith('.md')) .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8')) .join('\n'); // 两个键都得教到:少 produces 就没人产,少 needs 就表达不了非相邻依赖 expect(text).toMatch(/"produces"/); expect(text).toMatch(/"needs"/); const keys = [...new Set( [...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs|provides)"/g)].map((m) => m[1]) )]; expect(keys.length).toBeGreaterThan(0); for (const key of keys) { const contracts = orgSvc.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] }); expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']); } // kind 取值域逐字对齐:模板漏教一个值,规划 agent 就永远不会用它; // 模板多教一个值,validateGraph 会以 rule=schema 把整张图打回。 for (const kind of orgSvc.ARTIFACT_KINDS) { expect(text, `ARTIFACT_KINDS 里的 ${kind} 模板没教`).toContain(kind); } // backward 边上不许声明 —— 引擎解析输入只看 forward 入边(validateGraph 也拦) expect(text).toMatch(/backward/); }); });