第13章:测试体系与贡献指南
前十二章你已经走完了从”跑起来”到”改源码”的完整路径:第 1-7 章学会了用 dsh 完成日常任务,第 8 章理解了 Cordis 的”一切皆插件”范式,第 9-10 章读懂了核心源码与运行时,第 11-12 章掌握了 capability seam 与扩展开发。本章回答最后一个工程问题:当你想改这个仓库、或想让自己的插件进入生态时,如何保证改得对、改得稳、改得能被维护者接受。
DeepSeek Harness 处于 developer preview,迭代极快。它的测试与贡献规则都围绕一个核心矛盾设计:模型输出不可复现,但工程必须可回归。 本章先拆解分层测试体系(尤其 snapshot 铁律),再讲门禁、代码约定、Agent Note 与贡献流程。
13.1 测试命令全景
仓库把测试分成四个 tier,每个 tier 回答不同的问题。命令清单在根 AGENTS.md 的 Commands 一节,测试策略的权威文档是 docs/testing.md。
| 命令 | 层级 | 回答的问题 | 是否需要 API key |
|---|---|---|---|
pnpm run test |
单元测试 | 每个包、每个函数按契约工作吗 | 否 |
pnpm run test:coverage |
覆盖率门禁 | 每行代码都被某条测试触达了吗 | 否 |
pnpm run test:e2e |
真实 API 端到端 | 整个产品接真实模型能跑通吗 | 是(无 key 自动跳过) |
pnpm run test:snapshot |
无 key 快照回放 | 组装的完整应用对外行为变了吗 | 否(录制才需要 key) |
pnpm run test:web |
浏览器快照 | Web GUI 渲染变了吗 | 否(Linux PR 门禁) |
这四个 tier 不是并列的,而是从”零件”到”整机”的递进:单元测试证明每个零件按契约工作;覆盖率门禁证明没有一行代码是白写的;e2e 证明整机接上真实模型能跑;snapshot 回放证明整机对外暴露的行为没有偷偷变。缺了任何一层,都会留下一种特定类别的”假绿”。
新贡献者的标准起点是 docs/development.md 的 Setup tutorial:Node 22.19+/24+、启用 Corepack 的 pnpm(仓库在 package.json 里钉住 pnpm@11.7.0)、Git 2.26+;pnpm install 之后跑一次 pnpm run typecheck,通过即代表环境就绪。pnpm install 还会配置 worktree-local 的 Lefthook hooks 和 dsh-translation-pairing 合并驱动。
13.1.1 单元测试:pnpm run test
基于 vitest,覆盖 packages/**/tests/**、examples/**/tests/** 以及 scripts/**/*.spec.ts。测试文件跟着被测代码走,不集中堆放。testing.md 明确要求优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对契约回归的”永久测试”(permanent tests),典型如 packages/core/agent-loop/tests/contract-regressions.spec.ts。
有个细节值得注意:每个 registry 都要有一条 HMR 安全测试——dispose 掉贡献它的 fiber,再断言清理干净。这是”一切皆插件”的直接推论:插件可以热重载,挂载与卸载就必须对称。
13.1.2 覆盖率门禁:pnpm run test:coverage
注意:CI 的覆盖率门禁是 test:coverage,不是 test(AGENTS.md 专门解释了这一点)。它对 packages/*/*/src 执行 per-file 100% 的行覆盖。AGENTS.md 里有个判断:
一行未被覆盖的代码,往往是门禁正确地指出了该死掉的代码,而不是一条该补的测试。
行覆盖是必要条件,永远不是充分条件——它只证明”这行跑过”,不证明”功能按发布的样子工作”。所以覆盖率高不等于测试有效,这也是 snapshot 层存在的理由之一。
一个平台相关的例外:packages/shell/pwsh-local/src 的 per-file 100% 需要真的 pwsh。没有 pwsh 的主机上,它的 executor 套件自动跳过,vitest.config.ts 豁免该文件;CI runner 自带 pwsh,执行完整门槛。
13.1.3 真实 API 端到端:pnpm run test:e2e
带 key 的测试直接打真实 provider API——DeepSeek 模型,加上各 provider 自带的冒烟测试(EXA_API_KEY、PERPLEXITY_API_KEY 等,各自用自己的 key 门控)。每个套件在自己的 key 缺失时自动跳过,所以无 key 的 CI 照常绿。
testing.md 里有一句话是整套测试哲学的浓缩:
我们是 DeepSeek——不要抠真实 API 测试。无 key 测试只能证明”管道通了”;只有带 key 的测试能证明”agent 真的对着真实模型工作”。
最高价值的用例是冒烟测试(smoke test):启动真实 example,发一条 prompt,检查”世界”是否如预期变化。它专门抓”单测全绿、产品全挂”这一类 mock 抓不到的故障(postmortem 0001 记录过一次这类事故)。
13.1.4 snapshot 测试:test:snapshot / test:snapshot:record
test:snapshot 是无 key 的快照回放:ACP 启动真实的 automation-server example,重放一段录制好的会话,比对规范化后的 JSON-RPC 与重新持久化后的日志;headless 后端场景通过一个未导出的 JSONL 测试驱动启动各自的 example 组合。可以用 -t <name> 过滤单个场景。
配套命令:
pnpm run test:snapshot:record——模型转录变了,重新录制期望输出(需要 key)pnpm run test:snapshot:refresh——回放输入仍然有效时刷新pnpm run test:web——浏览器快照(Linux 必过 PR 门禁),Chromium 比对回放的浏览器输出与apps/web/tests/snapshots/
record 在模型转录变了时用(重新录期望输出),refresh 在回放输入仍有效时用(只刷新比对基线)。两者的每条 JSONL 与期望输出 diff 都要逐条过目。
这一层是第 13.2 节的主角。先记住结论:无 key 的录制回放,是验证”模型输出”这一不可复现事物的唯一工程手段。
13.1.5 其余相关命令
pnpm run clean——删除构建产物和已删包的残留pnpm dsh --profile headless "task"——从源码跑一次任务(需要DEEPSEEK_API_KEY)pnpm run demo:cordis——agent 修改自己的运行时(需要 key)pnpm run demo:acp——ACP 自动化服务器(需要DEEPSEEK_API_KEY)pnpm run check:windows-wine——只在诊断已知 Windows 故障时用(需要 wine),CI 拥有该信号
从源码跑这些 demo 之前要先 pnpm run build。headless 一次性 agent 需要环境或仓库根 .env 里的 DEEPSEEK_API_KEY;demo:cordis 这个自指 demo 可以检查并修改自己活着的插件运行时(默认 web,或 acp)。
13.1.6 从源码跑产品的完整流程
新 checkout 的完整路径(见根 README.md 的 Run from source):
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web
pnpm dsh 用 node --import tsx/esm 启动 apps/cli/src/bin.ts,不构建、转发每个参数。构建产物过时时,启动会报错并指示先 pnpm run build。launcher 不检查新鲜度,所以陈旧的 bundle 会跑旧浏览器代码直到重建。
13.2 snapshot 测试为什么是铁律
13.2.1 问题:模型输出不可复现
单元测试能成立的前提是”确定性”:同一个输入永远得到同一个输出。但 LLM 的输出天然不满足这个前提——同一个 prompt,两次调用可能返回不同的措辞、不同的工具调用顺序。如果测试直接断言”模型应该回答某句话”,这个测试要么永远红,要么永远不可信。
所以 DeepSeek Harness 采取的是录制回放(record/replay)策略:
- 用真实 key 跑一次真实的 example,把完整转录录下来(
test:snapshot:record) - 之后的验证不再调模型,而是回放这段录制(
test:snapshot),比对”组装后的完整应用”对外暴露的规范化输出与重新持久化的日志 - 无 key 也能跑——因为回放不需要模型
这样把”不可复现的模型输出”转换成了”可复现的应用行为比对”。模型输出变了,就重新录制一次;应用结构变了,回放会暴露差异。
无 key 还有一层工程意义:它让没有 DeepSeek key 的贡献者和 CI 不被阻塞——录制是少数人的一次性工作,回放是所有人的日常验证。testing.md 里有一句”自跳过不是成本信号”:self-skip 保证无 key 的 CI 常绿,但绝不意味着你可以跳过带 key 的测试。
13.2.2 铁律的原文
testing.md 把这条规则写成了硬性要求:
每个非平凡的、模型或产品用户可见的行为变更,必须在同一个 PR 里,通过一个真实可运行的 example 的 snapshot 套件,新增或更新一个无 key 快照。
包级测试、e2e 断言、mock/测试专用组合、以及 PR 里的文字说明,都不能替代组装后应用的真实转录。harness 支持不够时,就在同一个变更里补上 harness 支持。
这就是”为什么”的答案:只有录制回放能捕获”整个产品拼起来之后”的行为,而 mock 和单测只能证明零件本身没坏。
13.2.3 三套 snapshot 覆盖不同表面
| 表面 | 归属 | 机制 |
|---|---|---|
| ACP 自动化 | examples/<name>/tests/snapshots/(examples/acp-agent 为主) |
启动真实 automation-server,回放录制会话,比对 JSON-RPC + 重持久化日志 |
| headless 后端 | examples/headless-agent |
内部 canonical-event JSONL 快照 + 回放 fixture |
| Web GUI 浏览器 | apps/web/tests/snapshots/ |
Chromium 比对回放输出;CI 强制 DSH_SNAPSHOT=replay 只读 |
| 交互终端 | apps/cli/tests/snapshots/ |
JSONL 驱动的场景;瞬时呈现用包内语义矩阵 + PTY 用例 |
一个细节:ACP 有一个 text-turn 场景完整固定了 system-prompt/tool-schema 的内容,其他 fixture 把它 token 化——所以改一行 prompt 只 churn 一行,而不是全量 diff。
13.2.4 两套 SDK 也要跟着改
agent-loop、session-lifecycle 与 SessionEventMap 的变化会同时投影到 TypeScript 和 Python 两套 SDK 的预期输出里:
- TypeScript 客户端:
examples/jsonrpc-agent/tests/snapshots/ - Python 客户端:
scripts/snapshots/python-sdk-single-exe/(只有必需的python-runtimeCI job 才跑)
两套 SDK 是各自独立投影 agent loop、会话生命周期和 SessionEventMap 的——改其中任何一个,两边都要改,没有”改一边另一边自动跟”这回事。testing.md 特别点名:pnpm run test 两个都不覆盖。所以改了会话生命周期却忘了更新两套 SDK 的预期输出,单测不会报错,CI 的 snapshot 门禁会拦下你。
13.2.5 一个 ACP 场景的完整链路
以 examples/acp-agent 的主场景为例,test:snapshot:record 走的是:
- 启动真实 automation-server example,发一段真实转录
- 把规范化后的 JSON-RPC 输出与重新持久化的日志存为期望输出
- 之后
test:snapshot回放这段录制,逐字比对
其中 pwsh-tool-turn 场景会启动真实 pwsh,没有 pwsh 的主机上跳过;text-turn 场景固定完整 system-prompt/tool-schema 内容,其余 fixture 把它 token 化,所以改一行 prompt 只 churn 一行。录制与刷新都在本地做,每条 JSONL 与期望输出的 diff 都要人工过目;CI 强制只读回放(DSH_SNAPSHOT=replay),绝不写期望输出。
13.2.6 fixture 的发现与迁移
提交的 session 格式 JSONL 用规范的 packed-row 布局,keyless snapshot 门禁靠每个 fixture 的 session header 发现它。旧的 fixture 布局由临时迁移器(scripts/migrate-packed-session-fixtures.ts)重写。fixture 必须能在 macOS/Linux 上回放——修 fixture,不修 normalizer(不要为了套过比对去改规范化逻辑)。
13.3 测试策略的三条纪律
testing.md 除了分层,还立了几条跨层的纪律。理解它们能少走很多弯路。
13.3.1 优先用真实实现,而不是 mock
只在昂贵或非确定性的边界(LLM adapter、网络、时钟)上 mock,其余全部保持真实。一个手搓的替身只能证明”桥能传字节”,不能证明”发布的工具按断言工作”。桥接工具调用的测试用的是”脚本化 mock 模型 + 真实工具 + 真实执行器”:makeBridgeHarness({ withBash: true }) 接入 dsh-bash-local 和 dsh-tool-bash,然后真跑 echo。
13.3.2 验证”世界”,而不是”自报”
e2e 断言要重新执行命令或重新读文件来验证——只对 agent 自己的输出做关键字探测,等于让一个会作弊的 agent 蒙混过关。还要断言未触碰的文件字节级不变。e2e 测试拥有自己的资源:在测试里创建 harness,在 afterEach 里 dispose(即使失败/重试/超时);共享 fixture 放在普通的 tests/harness.ts,绝不放另一个 *.e2e.ts(import 一个 spec 会重复注册它的 describe,重复打真实 API)。
13.3.3 测试真实的入口路径
产品可见的插件要求一个非单测的、真实组合的测试。手搓的 ctx.plugin(...) 套件不够:要通过 Loader 和 app/process 启动一个测试专用的 cordis.yml,只 mock 外部服务或非确定性输入,断言模型可见的 request/log、持久化状态或用户可见输出。
“真实入口路径”还意味着发布的产物——一个包的 bin 在纯 node 下跑构建后的 lib/bin.js,暴露 tsx 掩盖的故障(settle 竞态、模块解析、被吞的加载失败)。这也适用于非 index 运行时入口(worker-thread 兄弟模块 lib/worker.cjs)和跨 bundle 共享的单例模块(packages/sdk/server/tests/built-scope-carrier.e2e.ts)。保持构建产物冒烟绿(packages/examples/*/tests/built-bin.e2e.ts),并断言”确实缺失的配置以非零退出”。
13.3.4 源码平面与产物平面分离
所有 vitest 配置都把 vite-tsconfig-paths 指向 tsconfig.base.json,裸 workspace import 解析到 src,绝不过包 exports 解析到构建后的 lib/——否则陈旧的产物会加载第二份模块单例。构建产物只在显式需要时消费(lib 模式的子进程和构建产物冒烟测试)。
13.3.5 恢复路径与负载边界也要测
testing.md 还点名了一类容易漏的用例:恢复测试按步骤区分 chunk 前/后失败,证明失败的 chunk 不会派生出任何消息或工具副作用;还要覆盖耗尽、取消、策略组合、持久化、状态、wire 计数、传输关闭的空闲超时,以及发布的 Loader 组合。测试哲学一句话:验证的是行为,不是”正确性”这个抽象概念。
13.3.6 守卫要能真的变红
testing.md 有一条尖锐的要求:守卫只有在回归真的能触发它时才叫守卫。对没有 inject 的插件(bundle/composition 插件),一个 Loader 冒烟在 default export 替换掉必需的 named exports 时仍然绿灯——所以要加显式的 expect('default' in mod).toBe(false) 断言,外加一个 unwrapExports 往返断言,并且证明它有效:引入回归、看着它红、再撤回。
13.3.7 子进程启动模式
CI 和 build-having 测试 lane 通过共享的双模式 launcher,从构建后的 lib/ 跑每个 example 或 Cordis 配置子进程。不要手写 --import tsx 给这些子进程。协议与操作系统 fixture(不加载 Cordis)用可擦除的 .ts 直接跑 Node,不带 tsx 或根 paths map。只有”测试对象是源码路径解析”的测试才能选 src,并且要在测试里陈述这个契约。
13.4 静态检查与构建门禁
除了测试,还有一层”改了什么就必须检查什么”的门禁。
| 命令 | 职责 | 工具 |
|---|---|---|
pnpm run typecheck |
类型检查 | TypeScript(Host lib 阶段 → Client tsc) |
pnpm run lint |
代码风格 | oxlint |
pnpm run duplication |
跨文件 TS 克隆检测 | jscpd |
pnpm run build |
tsc 产出 lib/types,tsdown 打包运行时 | tsc + tsdown |
pnpm run hygiene |
发布面卫生 | knip + publint + workspace constraints + NodeNext 消费者检查 |
hygiene 最值得展开——它是”发布面”的守门员:
- knip 抓未使用导出(死代码)
- publint 校验包入口点对照构建产物
lib/*.js - workspace constraints 校验每个包注册在且仅在一个 aggregate(Host/Client 二选一),split 包必须指向匹配的 leaf
- NodeNext 消费者检查(
verify-node-next-types)用一个临时 NodeNext 消费者校验构建后的声明文件
build 的产出顺序是:tsc -b tsconfig.host.json(Host 侧 tsc 发到 lib/types)→ tsdown Host 打包(同时跑 Typert 生成契约)→ tsc -b tsconfig.client.json → tsdown Client 打包 → pnpm run build:web。凡是消费构建产物 lib/ 的门禁,都要先跑 pnpm run build——新 checkout 在 build 之前没有任何 bundle 的 JS 和声明文件。
13.4.1 typecheck 的 Host/Client 双 aggregate
这个仓库刻意分成 Host 与 Client 两个 aggregate(docs/development.md 的 TypeScript project layout 一节)。原因值得知道:两侧都在 cordis 的 Context 接口上用相同的 key 声明合并不同的服务,一个程序同时看到两边的合并会报碰撞。所以:
tsconfig.host.json是 Host aggregate(Host 包、examples、tests、scripts、website、api/remotes 的 Host 项目)tsconfig.client.json是 Client aggregate(packages/client/*及其测试、apps/web、api/remotes 的 Client 项目)- 根
tsconfig.json只是 solution 根,不构成程序 api/remotes是唯一 split 双 config 的包
pnpm run typecheck 先完成 Host lib 阶段(含 Typert 生成契约),再做 Client tsc。这也是 pre-push hook 只跑 typecheck 的原因——它涵盖了完整的 Host lib 阶段。
13.4.2 check:all:一键全量
pnpm run check:all 提供全面的本地门禁集合。它独立于 Git hooks,不是 agent 的默认指令——这一点在 13.10 展开。Git hooks 刻意不跑测试、snapshot、文档检查、build、hygiene;pre-commit 只做暂存配对校验、oxlint、第三方声明再生成、空白错误检查与 vendor 清单守卫,pre-push 跑 typecheck。
13.4.3 沙箱内命令失败的边界
AGENTS.md 里有一条面向 agent 贡献者的指令:当 gh、pnpm、build、test、generator 命令因为 agent 自己的沙箱挡了凭据、网络、IPC、文件监听或嵌套 sandbox-exec 而失败时,用最窄的 host 提权重试,再诊断认证或项目故障。要求沙箱证据,绝不绕过真正的测试失败或被测的产品沙箱。
13.5 文档门禁:doc-sync 与 website:build
DeepSeek Harness 的文档不是”人肉维护”的,而是被门禁机械校验的。pnpm run doc-sync 是所有文档门禁的总入口,叶子清单在 scripts/run-gates.ts。
| 门禁 | 校验什么 |
|---|---|
verify-agent-note-format |
Agent Note 的头部与骨架格式(见 13.8) |
verify-config-catalog |
生成的 config-catalog 与源码配置声明一致 |
verify-persistence-catalog |
生成的持久化事件目录与源码 SessionEventMap 一致 |
verify-cordis-catalog |
生成的 Cordis API 区域与源码签名一致 |
verify-type-equiv |
文档里 ts type-equiv 粘贴的声明与源码逐字一致 |
verify-export-jsdoc |
每个模块/导出的 JSDoc 契约 |
type-equiv 尤其巧妙:文档子系统页面把源码声明连同原始 JSDoc 一起粘贴,读者看到的是”精确的类型定义 + 源码契约”。为了让粘贴件不漂移,块用 ts type-equiv 围栏标记,并在 scripts/type-equiv.manifest.json 里登记源文件和符号。改了一个被文档化声明或其 JSDoc,门禁会失败直到你更新粘贴件。
pnpm run website:build 是 VitePress 构建,兼作死链检查——这是文档里所有相对链接保持有效的机械保证(Agent Note 之间、子系统页面之间的链接都靠它兜底)。
config-catalog 与 persistence-catalog 同样是生成的(scripts/gen-config-catalog.ts / scripts/gen-persistence-catalog.ts),文件头写着”不要手工编辑”。generator 还会交叉检查运行时 schema——每个被 schema 校验的 key 都必须能在粘贴的声明类型上定位,所以粘贴件藏不住一个 loader 接受的字段。
doc-sync 里的 doc-typecheck 还会编译文档里可编译的围栏块,双语 .zh.md 复用英文兄弟块的条目——只要整段追踪的围栏序列字节一致且顺序一致。
13.6 代码约定(AGENTS.md 精选)
仓库的 AGENTS.md 是面向 agent 贡献者的规则集。下面挑与”改代码”最相关的几条,每一条都有它存在的理由。
这套规则集的独特之处在于目标读者:DeepSeek Harness 本身就是用 AI agent 辅助开发的,AGENTS.md 是写给 agent 贡献者的”可执行契约”。所以规则写得像 spec——每条都有明确判定标准,而不是”尽量写清楚”式的劝告。CLAUDE.md 是 AGENTS.md 的符号链接,编辑真实文件而不是链接。
13.6.1 运行时 invariant 断言”有归属的关系”
Runtime invariants assert owned relationships. 检查权威事件流或可变数据,而不是服务/方法的”存在性”、插件元数据、effects,或固定的纯示例。
如果实在找不到合理的关系,”一个解释清楚的空 companion 就是正确的”。这条规则防止测试断言”假关系”——断言某个服务存在,只能证明接线没断,不能证明行为正确。
13.6.2 switch 用判别标签 + assertNever
封闭联合(closed union)以 assertNever 收尾;可合并扩展的联合则落入一个有文档说明的 default。会话日志的 SessionEvent 正是这样设计的——一个正确的判别联合,switch (event.type) 直接收窄 event.data,不需要 cast。
13.6.3 空 catch 必须说明吞掉了什么
一个空的
catch必须写明它吞掉的是什么、为什么没有别的东西能到达这里;并且让try只包含一条语句。
这条直接针对”静默吞异常”这一 AI 编码重灾区。
13.6.4 跨边界 id 品牌化
不透明的跨边界 id 一律品牌化(Branded<B>,来自 dsh-brand 包),绝不用裸 string。ApprovalRequestId、SessionId 都是例子——品牌化让”这个字符串其实是个审批 id”这一事实进入类型系统,防止把审批 id 当成工具调用 id 混用。
13.6.5 TODO 标注规范
三个标签按紧急程度排序(见 docs/development.md):
| 标签 | 语义 |
|---|---|
FIXME |
应该阻塞新发布的问题。除非 reviewer 明确同意,否则发布不能带着打开的 FIXME 出门 |
TODO |
应该尽快修,等资源到位 |
XXX |
也许某天修;最低优先级,无承诺 |
选对标签,扫代码的人就能一眼区分”发布阻塞项”和”有生之年”。
13.6.6 其余值得记住的
- Registrations are effects:每个贡献都走
ctx.effect()/ctx.on();registry 的register()返回 disposer。 - Model-visible ⟺ logged:任何进入模型请求的东西,都必须能从会话日志重建;新增模型可见输入就要求新增一个 session event。
- Waterfall 监听器必须调用
next():不调用就短路了链条。 - Plugins, not loop changes:新行为挂在文档化的扩展点上;改
agent-loop需要更新 docs/architecture.md(见 13.9)。 - Explicit > implicit at package boundaries:默认值是 owning 实现里一个显式的
resolve(request): Spec步骤,绝不是一个藏在run()里的?? default。 - No hardcoded tunables:部署差异用可校验的
Config字段表达,DEFAULT_*常量或测试钩子不算”可配置”。 - Misconfiguration fails loud:配置错误要么在加载时自包含地大声失败,要么在最早的解析点失败,绝不静默跳过缺失的引用。
- Prefer symmetry for parallel values:并行的值要有对称性,无法解释的不对称通常意味着漏了一次抽取。
- Tests describe behavior, not correctness:改过时行为要连同它的测试一起改,并在 PR 里说明为什么。
- ESM everywhere(
"type": "module");跨包用包名、本地相对 import 用.ts。dshCLI 的源码启动走 tsx 的 ESM-only hook,它触达的模块必须保持 ESM。 - 命名规范:每个 npm 包都是
@deepseek-ai/dsh-<name>;vendored 包重作用域且private: true;@deepseek-ai/cordis是每个 harness 包的 peerDependency。 - Prefer maintained dependencies over hand-rolling:当依赖真的能删掉自有代码和测试时,优先用维护良好的依赖而不是手搓。
- Vendoring:
vendor/是钉住的源码拷贝(清单带上游 SHA),更新走 sync 流程,重打或退役记录在案的本地修改,然后pnpm run test && pnpm run build。
13.6.7 类型安全与文档
- 全仓
strict: true+noImplicitAny;每个残留的any都要解释为什么无法收窄。 - 每个模块和导出都有简洁的 JSDoc 说明非显然契约;函数式导出带
@param/@returns,由verify-export-jsdoc强制执行。 - 注释与文档陈述完整契约与上下文,不复述代码、不叙述控制流。写
contract、boundary、shape之前,先问有没有更精确的词——用response fields、JSON validation、ESM exports。 - 文档随每次代码变更一起更新:受影响的 README 与 JSDoc 契约同步改。
- 文件以恰好一个换行符结尾(
git diff --cached --check在 pre-commit 里把关)。
文档侧还有一份 docs/AGENTS.md 管文档约定:现状式陈述、一段一行、每个事实一个归属地、词预算。仓库把”文档约定”和”代码约定”拆成两份 AGENTS.md,本身就是第 13.5 节那套”文档被机械校验”哲学的外在表现。
13.7 贡献流程:从反馈到 PR
贡献入口是 CONTRIBUTING.md(有中文版 CONTRIBUTING.zh.md)。它第一段就写明了当前阶段的现实:
DeepSeek Harness 仍处于早期阶段,并在积极开发中。很抱歉,我们目前无法接受外部 PR。
这不是客套,而是 developer preview 的必然:一个没有稳定契约、随时准备破坏性重命名的仓库,外部 PR 的合入成本会淹没一个小团队。所以官方给出的参与方式是分层的:
| 参与方式 | 具体动作 |
|---|---|
| 报告问题 | 在 GitHub Discussions 发帖;给希望团队关注的讨论投票(团队很小,未必每帖都回,但会持续看) |
| 生态贡献 | 写插件并加 dsh-plugin topic;写博客/教程;回答社区问题 |
| 内部贡献 | (仅限官方)走 PR + label 体系 + Agent Note |
官方明确表示:官方仓库里的包并不天然比社区包更重要。这个仓库是一种理念、一份官方示例、一处灵感来源,而不是对社区的强制方向。
13.7.1 PR label 体系
对能提交 PR 的官方贡献者,标签有硬性规定(见 AGENTS.md):
- 一个
kind/*——PR 的性质 - 所有实质性的
area/*——改动的区域 - 原生改动还要加 native Issue Type
一条 PR 一个 kind/*,多个 area/*,缺失会不合规。
13.7.2 本地最小编排:改什么就查什么
AGENTS.md 的 Run relevant checks locally 一节规定:提交/推送前,先根据改动面选最窄的检查,报告你实际跑过的命令。原则是”证据匹配表面”:
- 行为改动 → 针对性测试
- 模型/用户输出改动 → snapshot
- 文档改动 →
doc-sync - 发布路径改动 → build/hygiene + 构建产物冒烟
- provider 行为改动 → 真实 API e2e
绝不默认跑全套,也绝不为提交/推送重复跑一个已经通过的检查。穷尽覆盖和平台矩阵交给 CI。
13.7.3 Git hooks 与双语文档机制
仓库用 Lefthook 做本地快检点(docs/development.md 的 Git integrations):
pre-commit——校验暂存的配对记录、用项目无关的.oxlintrc.staged.json配置校验暂存文件并应用 Oxlint 修复(一次有界重试)、在暂存文件是输入之一时再生成THIRD_PARTY_NOTICES.md、检查暂存 diff 的空白错误、跑 vendor 清单守卫pre-merge-commit——Git 创建自动合并提交前做同样的索引级配对检查pre-push——跑pnpm run typecheck
hooks 刻意不跑测试、snapshot、文档检查、build、hygiene——这些留给贡献者按改动面选跑,穷尽覆盖归 CI。仓库还有一套双语文档的配对合并驱动:冲突的 .i18n.yaml 记录由确认的祖先、当前、其他 owner blob 派生,owner 冲突时 fail-closed。这套机制与本教程无关,但说明了仓库对”文档与代码同步”的偏执。
13.7.4 贡献者的凭据约定
真实的 DeepSeek adapter 和带 key 的 agent demo 从环境或仓库根 gitignored 的 .env 读凭据:
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_BASE_URL=https://... # 可选
DEEPSEEK_BASE_URL 可选,默认公共 API。真实 API e2e 套件在 DEEPSEEK_API_KEY 未设置时自我跳过。这条与第 14 章 14.4 的用户侧凭据解析是两个层面:这里是仓库贡献者,那里是产品用户。
13.8 Agent Note:决策的机器可校验档案
这是 DeepSeek Harness 贡献流程里最独特的一环。规则原文(.agents/notes/README.md):
非平凡的变更必须在同一个 PR 里新增或更新至少一篇 Agent Note;只有纯机械或局部编辑可以豁免。
13.8.1 什么是 Agent Note
Agent Note 记录的是”代码和文档都承载不了的决策”——为什么这么做、放弃了什么。它位于 .agents/notes/,路径本身就是元数据:
{lifecycle}/{class}/yyyy-mm-dd-topic-title.md
- lifecycle(顶层目录)是状态:
proposed/(提议)、implemented/(已落地)、rejected/(已否决) - class(嵌套目录)是决策类别(封闭集合,分类门禁会拒绝其他目录):
| class | 覆盖什么 |
|---|---|
feature |
新的用户或模型可见能力 |
bug-fix |
修正缺陷或收尾 postmortem 暴露的缺口 |
simplification |
在不加能力的前提下删代码/行为/表面积 |
architecture |
关于发布源码的结构性决策——包如何关联、运行时词汇是什么 |
process |
围绕代码的工具、策略、工作流——门禁、包管理器、vendoring |
testing |
测试基础设施与策略 |
文件名里的日期是话题首次提出的日期(以 git 历史为准)。
13.8.2 文件格式骨架
前两行固定:
# Agent Note: <title>
Status: implemented
Status 三种取值,且必须与所在 lifecycle 目录一致(门禁交叉检查):
Status: proposedStatus: implementedStatus: rejected — <why, in one line>
正文骨架按 lifecycle 区分。implemented/ 的骨架是:
## Problem
## Decision
…bespoke sections…
## Alternatives considered
## Consequences
## Problem 写动机(独立于解法也能成立);## Decision 用现在时描述已落地的事实;## Alternatives considered 是强制的,记录每个被否决的替代方案及否决理由;## Consequences 记录这个取舍付出了什么、换来了什么。
## Alternatives considered 的强制性的理由很直白:记下了”做出了什么”却没记”打败了什么”的决策,等于邀请后人重开诉讼。日期早于 2026-07-05 且替代方案无法从记录重建的旧笔记,可以用一行注释占位(门禁只对 pre-format 文件接受):
<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->
proposed/ 用 ## Proposal(可以用将来时)、## Acceptance criteria、## Risks;rejected/ 保留提案时的骨架,裁决写在 Status: 行上。
这套格式由 pnpm run verify-agent-note-format(doc-sync 的一部分)机械校验。格式存在的理由本身就有一篇 Agent Note 解释:统一格式让决策可检索、可交叉引用、可在目录间迁移时保持机器可校验。
13.8.3 迁移与冻结
Agent Note 会在目录间迁移:proposed/ → implemented/ 要把 ## Proposal 重写成现在时的 ## Decision,把 ## Acceptance criteria 和 ## Risks 折叠进 ## Consequences。已落地的低价值记录移入冻结的 archived/ 树——一旦封存,永不编辑,文档门禁跳过它。中文对照件 .zh.md 结构逐节镜像英文件。
一条容易忽略的规则:implemented/ 的 Agent Note 要跟着实际落地保持当前——之后代码移动了文件、重命名了包、改了 key/默认值,Agent Note 要在同一次变更里更新这些事实(路径、名称、结构),而不是决策本身。
13.9 改动 agent-loop 与模型可见输入的连带义务
有三类改动会牵动仓库的多个面,改动时必须同步:
- 改
agent-loop:必须更新 docs/architecture.md。规则原文是 “Plugins, not loop changes”——新行为应该挂在文档化扩展点上,而不是动循环本身。 - 新增模型可见输入:必须新增对应的 session event。因为 “Model-visible ⟺ logged”,任何到达模型请求的东西都要能从日志重建。
- agent-loop / session-lifecycle /
SessionEventMap变更:必须同一个 PR 里更新 TypeScript 和 Python 两套 SDK 的预期输出(见 13.2.4),pnpm run test不覆盖这两者。
这三点共同指向一个原则:这些是”跨表面”的变更,单测的绿灯不足以证明你改对了。
AGENTS.md 还有一条配套约定:为 capability seam、生命周期路径、转录输出规划 unit、e2e、snapshot 覆盖;缺失的 snapshot-harness 支持要在同一变更里补上。这与第 11-12 章讲 seam 时”完整 seam 一次到位”是同一精神——一个只有 Provider 没有 Consumer 的 seam 不算完成。
13.10 本地最小验证与 CI 分工
把 13.4、13.7.2 的原则收拢成一个明确的分工:
| 谁 | 职责 |
|---|---|
| 你(本地) | 选覆盖”被改表面”的最窄检查,跑一遍,报告跑过的命令 |
| CI | 穷尽覆盖、构建产物冒烟、Node 22.19 / 24 / 26 兼容矩阵 |
CI 的关键门禁在 .github/workflows/ci.yml:keyless 门禁分组进宽 lane,并在支持的 Node 版本上跑较小的兼容信号;产物消费者等在 lane 内的一个 build。单独的 real-API workflow 跑 pnpm run test:e2e,绑定它配置的 worker。当前的 gate/job 清单以 scripts/run-gates.ts 和 workflow 文件为准。
Node 版本矩阵是 22.19、24、26(见 docs/development.md 的 Prerequisites 与 CI 说明)。引擎下限决策本身也是一篇 Agent Note。
本地全量演练(rehearse all)只在明确要求、诊断 CI、或做不可约的仓库级变更时才做——这是 AGENTS.md 的原文立场。日常开发记住一个心法:把最窄的检查跑在本地,把最宽的矩阵留给 CI。
还有一条只在你诊断已知 Windows 故障时才用:pnpm run check:windows-wine(需要 wine),CI 拥有这个信号,本地不必主动跑。
13.11 生态插件贡献姿势
既然外部 PR 暂时不收,社区参与主战场是插件生态。官方姿态很明确:社区包与官方包平等。
| 步骤 | 要点 |
|---|---|
| 建仓 | 自己的 GitHub 仓库,独立于官方仓库 |
| 加 topic | 给仓库加 dsh-plugin topic,便于被发现 |
| 声明 bundle | 若插件提供 cordis.patch.yml 补丁层,在 package.json 里声明 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } |
| 可安装 | 用户通过 dsh plugin --profile <name> add <package-or-git-spec> 安装 |
安装后,dsh.profile.bundles 会与安装状态对账:声明了 bundle patch 的依赖自动加入层栈,没声明的依赖保持普通包并给一次性警告,移除的依赖退出层栈。
对插件作者的技术约定,直接沿用第 11-12 章讲的 seam 与 AGENTS.md 的约定:ESM everywhere、注册即 effect(ctx.effect() / ctx.on())、显式优于隐式、配置错误大声失败。
两个安装细节:git 托管的插件若带源码,会在 prepare 脚本里构建,pnpm ≥10 会拦到消费者允许为止——首次 add 会失败并给出 allowBuilds 提示(以及指向 profile 的 pnpm-workspace.yaml 的 dsh 指针),把打印的 key 抄进去再跑一次。装构建好的 tarball 或本地 checkout 不需要这个允许。
写作时也别忘了——官方鼓励你写 DeepSeek Harness 的博客和教程,这正是本教程存在的意义。
13.12 本章小结
- 测试分四层:单元(
test)、覆盖率门禁(test:coverage,CI 用的是它而非test)、真实 API e2e(test:e2e,无 key 自动跳过)、快照回放(test:snapshot,无 key)。 - snapshot 是铁律:模型输出不可复现,只能靠”真实 example 录制 + 无 key 回放比对”验证;每个非平凡模型/用户可见变更须同 PR 更新 keyless snapshot,包测试和 mock 不可替代。
- 三条测试纪律:优先真实实现而非 mock、验证世界而非自报、测试真实入口路径;源码平面与产物平面分离。
- 静态与构建门禁各司其职:typecheck(Host/Client 双 aggregate)、lint(oxlint)、duplication(jscpd)、build、hygiene(knip + publint + workspace constraints + NodeNext 消费者)。
- 文档被机械校验:
doc-sync管 Agent Note/配置目录/持久化目录/type-equiv/export-jsdoc,website:build兼作死链检查。 - 代码约定:invariant 断言有归属的关系、switch 判别标签 + assertNever、空 catch 说明吞掉什么、跨边界 id 品牌化、TODO 按 FIXME/TODO/XXX 分级。
- 贡献现状:官方暂不接受外部 PR,反馈走 GitHub Discussions;PR 用
kind/*+area/*label;非平凡变更须同 PR 附机器可校验的 Agent Note。 - 改 agent-loop 或模型可见输入,须同步 docs/architecture.md 与 TS/Python 两套 SDK 预期输出。
- 本地只跑覆盖改动面的最窄检查,穷尽覆盖与 Node 矩阵归 CI;生态插件走 dsh-plugin topic + 独立仓库 + bundle 声明。
- 贡献者从
pnpm install+pnpm run typecheck起步;凭据走环境或仓库根.env,demo 从源码跑要先 build。 - 文档三层生成物(config-catalog / persistence-catalog / type-equiv)被 doc-sync 机械校验,改源码声明必须同步更新粘贴件。