/** * Unit Tests — workgraph-planning 模板的防漂移闸:**模板教的端点必须真的存在** * * 挡的是已经实际发生过的这一类缺陷:模板(`system/agents/workgraph-planning/*.md`) * 教 agent `POST /api/company/<公司ID>/work-graphs` 去落库,而这个路由**从来没有实现过** * (`server/index.js` 只挂了 `/api/graph-runs` 与 `/api/company` 的 org-graph 系列, * 设计文档里也查不到这个端点)。后果不是功能坏掉 —— `graph-engine.js` 的 * `extractPlannedGraph` 那条「读回复取图」的分支本来就是主路径,图照样落得进去 —— * 而是**每次真跑都白烧一轮 agent 调用**去 curl 一个必然 404 的地址(第五次真 CLI 冒烟实测)。 * 这种缺陷在单测里天然无感:prompt 文本没有任何编译期检查,端点没了也不会有人红。 * * 所以这里把「模板文本」和「真实路由表」对起来查: * ① 模板里出现的每个 `/api/...` 路径,都必须能在 `server/routes/` 里找到注册; * ② 反向:被显式标注为反例(`NO-SUCH-ENDPOINT`)的路径,必须**确实不存在** —— * 哪天这个端点真上线了,这条会红,提醒把警示文本删掉,而不是留一句过时的谣言; * ③ 模板教 agent 交回的那份图示例,本身要是**严格 JSON** 且过 `validateWorkGraph`: * 引擎用 `JSON.parse` 取图,示例里带注释/尾逗号就等于教它白烧一轮。 * * ── 路由表怎么来 ──────────────────────────────────────────────── * **静态解析** `server/index.js` 的 `app.use('<前缀>', require('./routes/<文件>')[.<导出>])` * + 路由文件里的 `.get/post/...('<路径>')`,不 require 任何路由模块: * 那些模块一 require 就会拉起 service 层(sqlite 连接等),而这条 guard 只需要文本事实。 * * ponytail: 路径匹配按「段」严格比:占位符段(`:id` / `<公司ID>` / `{公司ID}`)两侧都归一成 * `*`,且**只与占位符相等** —— 字面量位置上出现占位符也算不匹配(松一点就会出现 * `/api/company/{公司ID}/work-graphs` 被 `PATCH /api/company/templates/:templateId` 匹配上, * 实测过)。上限:模板把一族端点写成 `{start,next,...}` 时靠展开逗号处理, * 而**动词(GET/POST)不参与比对** —— 端点存在但方法写错,这条查不出来。 * 要更紧就得真起 express app 逐条 match,代价是把 service 层全拉起来,不值当。 */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; const ROOT = path.resolve(__dirname, '../../..'); const TEMPLATE_DIR = path.join(ROOT, 'system/agents/workgraph-planning'); const ROUTES_DIR = path.join(ROOT, 'server/routes'); const INDEX_JS = path.join(ROOT, 'server/index.js'); /** * 反例标记:同一行里出现它,这一行的端点就**不要求**注册(反而要求它不存在,见用例 ②)。 * 选「同一行」而不是「同一段落」是为了让判据肉眼可查 —— 加一句无关的警示文字不会顺手 * 豁免掉隔壁那条真端点。 */ const NO_SUCH = 'NO-SUCH-ENDPOINT'; /** * `{start,next,...}` 这种「一族端点写成一行」的写法展开成逐条候选(`...` 省略号丢掉)。 * 只展开**带逗号**的花括号 —— `{公司ID}` 是占位符,不是选择集。 */ function expandAlternatives(p) { const m = /\{([^{}]*,[^{}]*)\}/.exec(p); if (!m) return [p]; return m[1] .split(',').map((s) => s.trim()).filter((s) => s && s !== '...') .flatMap((alt) => expandAlternatives(p.slice(0, m.index) + alt + p.slice(m.index + m[0].length))); } /** 段级归一:占位符(`:x` / `` / `{x}`)一律 `*`,字面量原样保留 */ function segments(p) { return String(p) .split('/') .filter((s) => s !== '') .map((s) => (/[:<>{}*]/.test(s) ? '*' : s)); } /** * 严格逐段相等(占位符只与占位符相等)。 * * 早先写成「`*` 匹配任意一段」,结果 `/api/company/{公司ID}/work-graphs` 被 * `PATCH /api/company/templates/:templateId` 匹配上了(占位符吃掉 `templates`、 * `work-graphs` 又被 `:templateId` 吃掉)—— 那样这条 guard 对本次要挡的缺陷直接失效。 * 所以「字面量位置上出现占位符」也算不匹配。 */ function pathMatches(a, b) { const x = segments(a); const y = segments(b); return x.length === y.length && x.every((s, i) => s === y[i]); } /** 一个模板路径「已注册」= 它展开出的每一条候选都能找到注册 */ function isRegistered(p, registered) { return expandAlternatives(p).every((cand) => registered.some((r) => pathMatches(cand, r.full))); } /** server/index.js 的挂载表:`app.use('/api/x', require('./routes/y').z)` */ function readMounts() { const src = fs.readFileSync(INDEX_JS, 'utf-8'); const re = /app\.use\(\s*'([^']+)'\s*,\s*require\('\.\/routes\/([\w-]+)'\)(?:\.(\w+))?\s*\)/g; const out = []; let m; while ((m = re.exec(src))) out.push({ prefix: m[1], file: `${m[2]}.js`, exportName: m[3] || null }); return out; } /** 这个挂载点对应的 Router 变量名(`module.exports = router` / `module.exports.companyRouter = x`) */ function routerIdentifier(src, exportName) { const re = exportName ? new RegExp(`module\\.exports\\.${exportName}\\s*=\\s*(\\w+)`) : /module\.exports\s*=\s*(\w+)\s*;/; const m = re.exec(src); return m ? m[1] : null; } /** 真实注册的完整路径集合(前缀 + 声明路径),只收字面量路径 */ function registeredPaths() { const out = []; for (const mount of readMounts()) { const file = path.join(ROUTES_DIR, mount.file); if (!fs.existsSync(file)) continue; const src = fs.readFileSync(file, 'utf-8'); const ident = routerIdentifier(src, mount.exportName); if (!ident) continue; const re = new RegExp(`\\b${ident}\\.(?:get|post|put|patch|delete)\\(\\s*'([^']*)'`, 'g'); let m; while ((m = re.exec(src))) { out.push({ full: `${mount.prefix}/${m[1]}`.replace(/\/+/g, '/'), file: mount.file }); } } return out; } /** * 模板里提到的每个 `/api/...` 路径(带出处行号,便于定位)。 * * 占位符里有中文(`<公司ID>`),所以 ASCII 字符类**不够** —— 第一版写成 * `[A-Za-z0-9_\-./:<>{},]*` 时,`/api/company/<公司ID>/agents` 被截成 `/api/company/<`, * 段数从 4 塌成 3,正好被 `GET /api/company/:companyId` 匹配上:整条 guard 对模板里 * **每一条**真实路径都变成了空跑(变异测试实测撞到)。CJK 只在 `<>` / `{}` 括号里放行, * 括号外仍限 ASCII —— 否则紧跟路径的中文正文会被吞进路径里,变成误报。 */ const ENDPOINT_RE = /\/api\/(?:<[^<>\s]*>|\{[^{}\s]*\}|[A-Za-z0-9_\-.:]|\/)+/g; function templateEndpoints() { const out = []; for (const name of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) { const lines = fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf-8').split('\n'); lines.forEach((line, i) => { for (const raw of line.match(ENDPOINT_RE) || []) { const cleaned = raw.replace(/[.,:;/]+$/, ''); out.push({ path: cleaned, file: name, line: i + 1, exempt: line.includes(NO_SUCH) }); } }); } return out; } /** ```json 围栏块里能解析出 `nodes` 数组的那些(引擎 extractPlannedGraph 同一取法) */ function graphExamples(fileName) { const text = fs.readFileSync(path.join(TEMPLATE_DIR, fileName), 'utf-8'); const out = []; const fence = /```json\s*([\s\S]*?)```/g; let m; while ((m = fence.exec(text))) { try { const doc = JSON.parse(m[1]); if (doc && Array.isArray(doc.nodes)) out.push(doc); } catch { /* 不是图的 json 块(或写坏了),交给下面的断言去管 */ } } return out; } describe('workgraph-planning 模板:教的端点必须真的存在', () => { const registered = registeredPaths(); const endpoints = templateEndpoints(); it('两侧都不是空的(防止 guard 因为改名/正则失效而静默变成空跑)', () => { expect(endpoints.length).toBeGreaterThan(0); expect(registered.length).toBeGreaterThan(20); // 抽取必须**整条**抽出来(含中文占位符):截断成 `/api/company/<` 会被 // `GET /api/company/:companyId` 匹配上,guard 就整体空跑了 for (const tail of ['/hire', '/agents']) { const hit = endpoints.find((e) => e.path.endsWith(tail)); expect(hit, `模板里抽不到以 ${tail} 结尾的端点,抽取正则多半又被截断了`).toBeTruthy(); expect(segments(hit.path).length).toBe(4); } // 已知一定注册着的几条,用来验「解析链路本身没坏」(含挂在 companyRouter 上的那条) for (const known of ['/api/company//hire', '/api/company//agents', '/api/graph-runs//{start,next}', '/api/company//graph-runs']) { expect(isRegistered(known, registered), `解析不到 ${known}`).toBe(true); } // 反面自检:一个明摆着不存在的路径不许被判成已注册(挡住"匹配太松 → guard 空跑") expect(isRegistered('/api/company//definitely-not-a-route', registered)).toBe(false); }); it('① 模板里的每个端点都在 server/routes/ 里真实注册', () => { const dangling = endpoints .filter((e) => !e.exempt) .filter((e) => !isRegistered(e.path, registered)) .map((e) => `${e.file}:${e.line} → ${e.path} 没有任何路由注册它` + `(真要写成反例,同一行加上 ${NO_SUCH} 标记并写明"不存在,不要调用")`); expect(dangling).toEqual([]); }); it(`② 标了 ${NO_SUCH} 的端点必须确实不存在(端点真上线了就该删掉这句警示)`, () => { const exempt = endpoints.filter((e) => e.exempt); // 反例本身也要在(否则这条用例是空跑):删掉那条警示行时这里会红,提醒别把它悄悄弄没了 expect(exempt.length).toBeGreaterThan(0); const stale = exempt .filter((e) => isRegistered(e.path, registered)) .map((e) => `${e.file}:${e.line} → ${e.path} 已经真的注册了,${NO_SUCH} 这句话过时了`); expect(stale).toEqual([]); }); it('③ 模板教 agent 交回的图示例:严格 JSON + 过 validateWorkGraph(引擎用 JSON.parse 取它)', () => { // 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 一行不碰。 // validateWorkGraph 是纯函数,但 require 链上有 getDb,保险起见照铁律走。 process.env.GRAPH_DB_PATH = '/tmp/test-workgraph-planning-endpoints.sqlite'; for (const m of ['../../services/graph-db', '../../services/work-graph-db']) { delete require.cache[require.resolve(m)]; } const wdb = require('../../services/work-graph-db'); // agent.md 是**唯一进提示词的那份**(agent-manager.getAgentConfig 只把 agent.md 当 prompt), // 所以它里面必须有一份可直接照抄的图示例。 const examples = graphExamples('agent.md'); expect(examples.length).toBeGreaterThan(0); const bad = []; for (const dir of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) { for (const g of graphExamples(dir)) { const verdict = wdb.validateWorkGraph(g.nodes, g.edges || []); if (!verdict.ok) bad.push(`${dir}: ${JSON.stringify(verdict.errors)}`); } } expect(bad).toEqual([]); }); }); /** * ④ 引擎自己拼的规划提示词,不许与模板对立。 * * 第六次真 CLI 冒烟挖出来的:同一条提示词里,模板部分(系统提示词)写着 * 「不许出现 wg_ + 12 位十六进制,你永远拿不到真的 wg_ id」,而引擎拼的任务描述部分 * 写着「已经自行落库成功时,改为回一行 workGraphId: wg_xxx」——**两边直接对立**, * agent 得赌听谁的。那一轮它选择听模板,是运气不是保证。 * * 同型的还有两条:引擎的内联图示例一个 produces 都没有(agent 唯一见过的「边长什么样」 * 就是零声明的),以及引擎说端点「尚未上线」(暗示将来会上线,于是它还想去试一次)。 * * 手法与上面一致:**静态读源码文本**,不 require graph-engine(一 require 就拉起 sqlite * 与 AgentManager 依赖链)。buildPlanningPrompt 不导出,也不为测试新开导出面。 * ponytail: 按「## 交回方式」到数组收尾 `];` 之间的文本切段。上限是节标题一改就切不到 —— * 所以下面第一条断言先钉住「这段必须切得到且非空」,切不到时是它红而不是静默空跑。 */ describe('引擎的规划提示词:与 workgraph-planning 模板口径一致', () => { const ENGINE_SRC = path.join(ROOT, 'server/services/graph-engine.js'); const src = fs.readFileSync(ENGINE_SRC, 'utf-8'); /** * buildPlanningPrompt 里「## 交回方式」那一节里**真正会进提示词的文本**。 * * 必须剔掉 `//` 注释行:这一节的注释本身就在解释「为什么不许说某句话」, * 连注释一起扫的话,写下禁令理由的那条注释会让 guard 自己红 * (第一版就是这样红的 —— 它量的是源码文本,而该量的是发给 agent 的文本)。 */ function handoffSection() { const start = src.indexOf('## 交回方式'); if (start < 0) return ''; const end = src.indexOf('\n ];', start); if (end < 0) return ''; return src.slice(start, end) .split('\n') .filter((l) => !l.trim().startsWith('//')) .join('\n'); } it('这一节切得到(切不到时这条红,而不是让下面三条静默空跑)', () => { const sec = handoffSection(); expect(sec.length).toBeGreaterThan(100); // 它必须确实是在讲交回图这件事,否则说明切到了别的地方 expect(sec).toContain('json'); }); it('不邀请 agent 回 workGraphId(模板明令禁止写 wg_ id,两边不许对立)', () => { const sec = handoffSection(); // 找「教它输出 wg_」的措辞。引擎侧仍可以保留**解析**真实 wg_ id 的能力, // 禁的只是在提示词里邀请它写一个 —— 它没有任何受支持的落库途径,写出来必是编的。 expect(sec).not.toMatch(/workGraphId\s*[::]\s*wg_/); expect(sec).not.toContain('自行落库成功'); }); it('内联图示例的边带 produces(示例是 agent 唯一见过的「边长什么样」)', () => { const sec = handoffSection(); const edges = sec.match(/"edges"\s*:\s*\[[^\]]*\]/); expect(edges).not.toBeNull(); expect(edges[0]).toContain('produces'); }); it('提到 work-graphs 端点时不说「尚未上线」(暗示将来会上线,agent 就还会去试)', () => { const sec = handoffSection(); if (!sec.includes('work-graphs')) return; // 不提它也行,那就没这个问题 expect(sec).not.toContain('尚未上线'); expect(sec).not.toContain('还没上线'); }); });