开源 Agent 套件 ECC 的测试体系:怎么给一套提示词系统写测试

2026-07-29

本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。

**ECC 的测试套件里,绝大多数用例测的不是代码能不能跑通,而是一段文本还在不在、一个字段填没填对。**这句话听上去像是在挑刺,其实它是所有「提示词系统」类项目的共同处境:你交付的东西是给模型读的指令,而指令没有编译期,没有类型,没有栈回溯。ECC 的做法是把可测的部分尽量往前推——凡是能变成结构约束的,就写成校验器;凡是只能靠措辞承载的,就把措辞本身钉成断言;只有真正跨进程、跨网络的地方,才写成传统意义上的行为测试。

站内已有三篇讲通用方法的文章:Agent 回归测试怎么做讲的是回归集的组织方式,Agent 评测方法讲的是打分口径,让 AI 写测试讲的是用模型生成用例。这三篇是方法论;本篇不重复它们,只做一件事——把一个你现在就能 clone 下来打开的真实项目,逐层拆给你看它到底把方法论落成了什么样的文件。

一、被测对象:这个仓库里几乎没有传统意义的业务代码

先看清楚测试面对的是什么。ECC 的 agents 目录有 67 个 agent 定义,skills 目录有 281 个技能目录,commands 目录有 94 个命令,它们全部是带 YAML frontmatter 的 markdown。再加上 manifests/ 里的安装清单、schemas/ 下的一批 JSON Schema、hooks/ 里的钩子脚本、.codex/config.toml 这类各家 harness 的适配配置。真正的可执行逻辑集中在 scripts/(Node)和 src/llm(Python)两处。

也就是说,这个项目的「产品」大部分是数据和文本,缺陷形态也就跟着变了:不是空指针,而是某个 agent 的 model 字段写了个不存在的值、某个 SKILL.md 的 description 用了多行块标量导致下游表格渲染断行、README 里写的技能数量跟目录里实际数量对不上、有人从自己机器上复制路径进文档把用户名带了出去。这些缺陷 lint 管不了,人工 review 会漏,只能靠一层一层的自动检查去兜。

下面这张表是这套测试体系的全貌,路径都是仓库里的真实位置:

组成部分它负责什么对应仓库位置你什么时候会碰到它
结构校验器agent / command / rule / skill / hook / 安装清单的必填项与取值范围scripts/ci/validate-agents.js 等一组 validate-*.js新增或改动 agents/*.mdcommands/*.md
计数对账文档里写的数量与仓库实际文件数是否一致scripts/ci/catalog.js加了一个技能却忘了同步 README
文本卫生扫描个人绝对路径泄漏、不安全的 Unicode 字符scripts/ci/validate-no-personal-paths.jsscripts/ci/check-unicode-safety.js从自己机器复制片段粘进文档
指令内容断言指定 markdown 里的安全约束段落是否还在tests/ci/agent-instruction-safety.test.js精简或重写某个 SKILL.md
配置默认值断言参考配置有没有意外钉死模型、有没有接上角色文件tests/codex-config.test.js.codex/config.toml
进程边界契约外部 CLI 桥接的参数、环境变量、退出码tests/scripts/ito-cli-bridge.test.js接一个会 spawn 子进程的能力
测试运行器递归发现 tests/**/*.test.js 并逐个起子进程执行tests/run-all.js本地跑 npm test
合规测量生成场景、跑 agent、按规格分类工具调用序列skills/skill-comply/想知道某条规则实际有没有被遵守
Python 侧src/llm 抽象层,以及合规 runner 自身的不变量tests/conftest.pytests/test_invariant_runner.py改 Python 部分

两套语言各走各的入口。Python 侧极其克制,tests/conftest.py 全文只做两件事:把 src 插进 sys.path,以及注册一个 unit 标记用来标注快速用例。剩下的配置在 pyproject.toml[tool.pytest.ini_options] 里,testpaths 指向 testsasyncio_mode 设为 auto。Node 侧则完全是手写的:没有 Jest、没有 Vitest,每个 .test.js 都是一个可以 node 直接执行的独立脚本,内部自带一个几行的 test() 辅助函数,结尾打印 Passed:Failed: 两行,再按失败数决定退出码。

tests/run-all.js 就是靠这个约定把它们串起来的:递归遍历 tests 目录,按 tests/**/*.test.js 过滤,逐个 spawnSync 起子进程,然后用正则从子进程输出里把 Passed:Failed: 后面的数字抓出来累加。这个设计的取舍很直白——零依赖、每个文件都能单独跑、失败时不会因为框架自身的抽象层而变得难懂;代价是断言协议变成了「标准输出的格式」,任何输出格式不合规的测试文件,数字都统计不进总数。

二、结构校验器与内容断言:把格式和措辞一起钉死

npm test 并不是简单地调 run-all.js,它是一串用 && 连起来的关卡,前面全是校验器,最后才轮到测试文件:

node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js
  && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js
  && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js
  && node scripts/ci/validate-install-manifests.js
  && node scripts/ci/validate-no-personal-paths.js
  && npm run catalog:check && npm run command-registry:check
  && node tests/run-all.js

validate-agents.js 为例,它自己手写了一个 frontmatter 解析器,剥 BOM、兼容 CRLF、只把顶格的键当作顶层键,然后检查这几件事:必填字段是否齐全、有没有重复的顶层键、model 是不是合法取值、tools 是不是被人误写成了 YAML 列表。

const REQUIRED_FIELDS = ['model', 'tools'];
const VALID_MODELS = ['haiku', 'sonnet', 'opus'];

这里有个细节值得留意:tools 必须是逗号分隔的标量,写成 YAML 序列会直接报错。这不是洁癖,而是因为下游要把它当字符串消费。校验器把这类约定固化下来之后,新贡献者不需要读文档也能被挡住。

validate-skills.js 的取向不太一样。它同样检查每个技能目录里有没有非空的 SKILL.md、frontmatter 里有没有 namedescription 是不是误用了 ||-|+ 这类会保留内部换行的字面块标量(换行会打断以 description 为键的扁平表格渲染;折叠式的 > 不保留内部换行,不在拦截之列,脚本给出的建议写法就是内联标量或折叠标量),但它把 frontmatter 类的发现默认降级成 WARN,只有传 --strict 或设 CI_STRICT_SKILLS=1 时才升级为错误;结构性问题(文件缺失或为空)则始终是错误。脚本注释里写明了原因:存量数据缺陷正在另行清理,不想让 CI 长期红着。这是一个很务实的处理——校验强度分级,让新增内容受严格约束,同时不被历史包袱卡死。

再往上一层就是给自然语言写断言了。tests/ci/agent-instruction-safety.test.js 维护了一张表,每一项指定一个文件、一个必须存在的小节标题、以及一组必须能匹配上的正则:

{
  path: 'skills/autonomous-agent-harness/SKILL.md',
  heading: '## Consent and Safety Boundaries',
  requiredPatterns: [
    /explicitly requested and scoped/i,
    /Do not create schedules/i,
    /Prefer dry-run plans/i,
  ],
}

被保护的都是安全边界类措辞:默认只读、需要显式批准、不要安装依赖、不要收集或打印真实用户数据、不要自己创建定时任务。这类测试的性质需要说清楚——它保证的是「这段约束还写在指令里」,不保证「模型照做了」。有人重构 SKILL.md 顺手删掉整节,测试会红;有人把同一个意思换了种说法,测试也会红(这是误报,得改断言);而模型在真实会话里绕过了这条约束,测试不会红。理解这个边界,你才不会高估这一层的价值,也不会低估它——在一个技能数量以百计的仓库里,它至少让「安全约束被静默删掉」这件事变得不可能。

同一层还有几个卫生扫描。validate-no-personal-paths.js 用正则找 /Users/<name>C:\Users\<name>,但放行 userexampleyourname 这类占位用户名,并且豁免 docs/fixes/ 下的事故报告(那里记录别人机器上的真实路径是合理的)。check-unicode-safety.js 则按扩展名白名单遍历文本文件做字符检查。这两个脚本解决的是同一类问题:人把本地上下文带进了要公开发布的文本。

scripts/ci/catalog.js 干的是另一件事——把 README、AGENTS.md、各语种文档、插件清单里写的数量,跟仓库里实际的文件数对账,--write 模式还能直接改回去。文档漂移在这种规模的仓库里几乎是必然的,把它变成一条 CI 关卡是很省事的选择。

tests/codex-config.test.js 给出了另一种断言取向。它读 .codex/config.toml,然后断言配置里不存在顶层的 modelmodel_provider 赋值行,理由写在断言消息里:参考配置应当继承 CLI 的默认模型与默认 provider。它同时要求配置里显式打开 multi_agent = true,要求 [agents.explorer][agents.reviewer][agents.docs_researcher] 三个小节各自通过 config_file 指向 .codex/agents/ 下真实存在的 .toml 文件,并且遍历该目录下所有角色配置,确认没有哪个把模型钉在了 o4-mini 上。

这个文件很值得单独看一眼,因为它示范了配置类断言的两个方向:断言某个键必须存在是常规操作,断言某个键必须不存在才是这类项目的特色。分发出去的参考配置一旦钉死模型,用户升级 CLI 之后拿到的还是旧默认值,而这种问题不会报错,只会让人莫名其妙地觉得效果变差了。

三、进程边界契约:唯一真正跑行为的那一层

tests/scripts/ito-cli-bridge.test.js 是整个仓库里形态最接近传统集成测试的文件,它测的是 ECC 对一个外部算力 CLI 的桥接。文件顶部的注释直接写明:这里用的可执行文件是一个进程边界探针,它从不联网、不提交 RFQ、不打开浏览器、不触达 GPU 节点。

手法是这样的:在临时目录里造一个假的 CLI 脚本,这个脚本被调用时把自己收到的 process.argv 和完整的 process.env 序列化写进一个 JSON 文件,然后正常退出;测试通过环境变量 ECC_ITO_CLI_EXECUTABLE 把 ECC 指向这个探针,跑完之后读那个 JSON,对参数和环境逐项断言。整个链路是真实的子进程 spawn,只是终点被换成了可观测的假货。

最能说明设计意图的是环境变量那条用例。它在调用时故意塞进一堆不该外泄的东西,然后逐项断言:

assert.strictEqual(childEnvironment.ITO_API_KEY, "ito_test_key");
assert.strictEqual(childEnvironment.AWS_SECRET_ACCESS_KEY, undefined);
assert.strictEqual(childEnvironment.OPENAI_API_KEY, undefined);
assert.strictEqual(childEnvironment.TEST_PASSWORD, undefined);
assert.strictEqual(childEnvironment.ECC_ITO_CLI_EXECUTABLE, undefined);

注意最后一行:连「指定可执行文件路径」这个变量本身都不许传给子进程。这是一条白名单式的环境边界,而不是黑名单式的过滤——想法上跟最小权限设计是同一套。父进程的环境默认全部不过界,只有明确列出的几项才复制过去。任何往 Agent 工具链里接外部程序的人都该抄这个思路:子进程继承父进程全部环境是默认行为,而默认行为在这里就是泄漏。

另外几条用例覆盖的是失败路径,而且失败得很讲究:不支持的操作要在 spawn 之前就拒绝(断言方式是检查探针的日志文件根本没被创建);--dry-run 不是静默成功而是直接失败,错误信息里明说没有 paper 或 dry-run 成功模式;可执行文件缺失或给的是相对路径时,报错要指向那个具体的环境变量名。这些都是「宁可炸掉也不要假装成功」的取向——对一个会花真钱的操作来说,这个取向是对的。

配套的 docs/testing/ecc-ito-real-cli-bridge.tdd.md 记录了这次改动的证据链:先贴改动前全红的输出,再贴改动后全绿的输出,中间是一张「保证 / 测试文件 / 类型 / 结果」的表格,把每一条契约映射到具体的测试文件。文档末尾有一节 Known gaps,明写着没有调用过任何真实 API、没有做过真实 GPU 资格认证、那个 CLI 仍然是本地构建未发布。这一节比前面所有绿色输出都重要——它把「测试通过」和「功能可用」这两件事明确地分开了。写这类文档的时候,把没验证的部分老实列出来,比多贴几行绿字有价值得多。

四、skill-comply:直接测模型听没听

前面几层都绕开了那个真问题。skills/skill-comply/ 是仓库里正面处理它的地方,它自己的描述是:衡量编码 agent 是否真的遵守了技能、规则或 agent 定义。

流程是自动生成期望的行为序列(从任意一个 .md 文件反推出「规格」),再生成三个提示词严格度递减的场景,SKILL.md 里把这三档写作 supportive、neutral、competing——从明确支持这条规则,到不提,到给出一个跟它相竞争的诉求。然后实际调起 agent,通过 stream-json 捕获工具调用轨迹,用模型(而不是正则)把每一次工具调用归类到规格里的某个步骤,再用确定性的方式检查时序,最后出一份自带规格、提示词和完整时间线的报告。

它自己给这个设计起的名字是 prompt independence:一条规则的价值在于提示词没有明说时它还起不起作用。这个切口比「跑一遍看看对不对」要锋利——你写在 CLAUDE.md 或者某个 SKILL.md 里的约束,在用户顺着你意思提问时当然生效,问题在于用户提出相反诉求时它还剩多少分量。想在自己项目里做类似的事,可以参考技能机制本身的写法先把被测对象整理清楚,再谈怎么测。

代价也一目了然:这一层要真的调起模型,有金钱成本,结果带随机性,跑一次也不快。所以它不在 npm test 的关卡链里,是一个按需触发的技能,而不是 CI 的一环。这个位置摆得很清楚——确定性的检查进 CI,不确定的测量按需跑。

有意思的是反过来的那一层:tests/test_invariant_runner.py 测的是 skill-comply 自己的 runner。它参数化了几种恶意的 setup 命令——用 python -c 起解释器执行 os.system、用一串 ../ 做路径穿越去够 /bin/sh、直接调用不在白名单里的二进制——然后断言沙箱不会执行它们(判据是一个约定好的标记文件没有被创建),同时确认一条无害的 echo 仍能正常通过。测量工具本身要跑外部生成的命令,那它自己就是攻击面,这个不变量必须有测试守着。

五、边界与代价:这套设计放弃了什么

它放弃了「测试通过等于行为正确」这个承诺。 结构校验器和内容断言构成的那两层,本质上是文本层面的存在性检查。指令写全了不代表模型会遵守,措辞改写了不代表语义变了。这套体系能挡住的是「静默删除」和「格式漂移」,挡不住「写了但没用」。

它的维护成本随表面积线性增长。 项目自己的贡献指南把这点摊开写了:新增一个技能、命令、agent、钩子或 CLI 工具,要同步 package.jsonbinfilesmanifests/install-components.jsonmanifests/install-modules.jsonagent.yaml,重新生成 catalog 与命令注册表,更新 README 和几张文档表格,新脚本还要加进 tests/scripts/npm-publish-surface.test.js 的发布面白名单。跨 harness 的副本另有一套 frontmatter 允许键的限制。这些要求全都由测试强制,好处是漏一处就红,坏处是加一个东西的边际成本比看上去高不少。

覆盖率只覆盖得到脚本层。 coverage 脚本用 c8 收集的范围是 scripts/**/*.jsscripts/**/*.mjs,markdown 内容本身没有覆盖率这个概念——你没法说 281 个技能目录里的指令被「覆盖」了多少。真正被断言保护的技能只有 agent-instruction-safety.test.js 那张表里点名的那几个,其余靠通用校验器兜底。

它不替你审这套东西往你机器上做了什么。 这类套件会往本地写文件、注册钩子、配置 MCP 连接、在某些路径下调起外部程序。仓库里的测试证明的是「在测试场景下不越界」,不是「装到你机器上安全」。钩子在你每次执行命令时都可能被触发,MCP 配置指向的是你自己的凭据。装之前自己读 hooks/manifests/ 下的清单,比信任任何一行绿色输出都靠谱。

不适用的场景也说清楚。 如果你的项目主体是应用代码、提示词只占一小部分,那么照搬这套「手写脚本 + 输出协议」的做法没有收益,用成熟测试框架更省事。这套结构的合理性来自一个特定前提:被测对象是海量的文本与配置,且需要在多个 harness 之间保持一致。

六、上手与避坑清单

只跑 node tests/run-all.js 不等于跑了 npm test 会踩是因为 run-all 跑得快、输出好看,容易当成全量。但 npm test 前面串着一长串校验器,frontmatter、清单、计数、路径泄漏全在那里检查,run-all 一个都不管。提交前老老实实跑完整的 npm test,它跟 CI 是同一条链。

测试文件必须放在 tests/ 下并以 .test.js 结尾。 会踩是因为 run-all 用 glob 匹配来发现文件,命名不符它就当你不存在——而且不会报错,只有一个文件都没匹配到时才会退出非零。你以为加了测试,实际一次都没跑过。照着 tests/<分类>/<名字>.test.js 的既有结构放。

输出格式就是断言协议。 会踩是因为习惯了框架,随手引个断言库或者改了打印格式。run-all 是用正则从子进程 stdout 里抓 Passed:Failed: 后面的数字来累加的,格式不对,你的用例数就统计不进总数(好在退出码非零仍会被记成失败,不至于完全静默)。抄现有文件里那个手写的 test() 函数、结尾那两行 console.log,以及 process.exit(failed > 0 ? 1 : 0)

agents/*.md 之前先看校验器。 会踩是因为 YAML 里把 tools 写成列表是最自然的写法,model 填个具体模型名也显得更精确。但校验器要求 tools 是逗号分隔的标量,model 只接受 haiku / sonnet / opus。花两分钟读一遍 scripts/ci/validate-agents.js,比对着报错猜快。

SKILL.md 的 description 写成单行。 会踩是因为描述长了就想用 | 折成多行,本地跑还看着是过的——因为这类发现默认只是 WARN。等到 CI 用 --strictCI_STRICT_SKILLS=1 跑起来就变成错误了。写成内联标量,长一点没关系。

新增任何一个可安装的东西,先把清单同步表过一遍。 会踩是因为你只关心自己那个文件写得对不对,而这套测试是表面积对账型的,它会去问 package.json、安装清单、agent.yaml、catalog、命令注册表、文档表格是不是都提到了它。漏一处红一处。改完先跑 npm run catalog:syncnpm run command-registry:write

写涉及 git 的测试时,记得清理继承来的 git 环境变量。 会踩是因为整套测试可能在 git 钩子里被触发,而 git 会设置 GIT_DIRGIT_WORK_TREE 这些变量,你测试里的 git -C <临时目录> 会被它们劫持,跑到宿主仓库上去。run-all.js 已经在起子进程前把这几个变量删掉了,你自己直接 node 单跑某个文件时要留意同样的问题。

别把它当模型质量测试用。 会踩是因为「全绿」这个信号太有安慰性。这套东西全绿只说明:格式对、文档没漂移、安全措辞还在、进程边界没破。模型有没有变笨,它一个字都没说。想知道那个,得走 skill-comply 那条路,或者自己建评测集。

收个尾。如果你手上也有一堆给模型看的 markdown 和配置,可以按这个顺序自查:有没有一个脚本能在合并前挡住 frontmatter 写错;有没有把安全约束的关键句子变成断言;有没有把「文档写的数量」和「实际文件数」对上;跨进程调用外部程序的地方,有没有做环境变量白名单而不是黑名单;以及最重要的——有没有一份文档老实写下哪些东西你其实没验证过。

想直接上手读代码,建议的顺序是:先 tests/run-all.js 看清运行机制和输出协议,再 scripts/ci/validate-agents.js 看结构校验怎么写,然后 tests/ci/agent-instruction-safety.test.js 看自然语言怎么被钉成断言,最后 tests/scripts/ito-cli-bridge.test.js 看进程边界契约的完整形态。四个文件读完,这套体系的骨架就在你手上了。

本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。