znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第13章:测试体系与贡献指南

前十二章你已经走完了从”跑起来”到”改源码”的完整路径:第 1-7 章学会了用 dsh 完成日常任务,第 8 章理解了 Cordis 的”一切皆插件”范式,第 9-10 章读懂了核心源码与运行时,第 11-12 章掌握了 capability seam 与扩展开发。本章回答最后一个工程问题:当你想改这个仓库、或想让自己的插件进入生态时,如何保证改得对、改得稳、改得能被维护者接受。

DeepSeek Harness 处于 developer preview,迭代极快。它的测试与贡献规则都围绕一个核心矛盾设计:模型输出不可复现,但工程必须可回归。 本章先拆解分层测试体系(尤其 snapshot 铁律),再讲门禁、代码约定、Agent Note 与贡献流程。

13.1 测试命令全景

仓库把测试分成四个 tier,每个 tier 回答不同的问题。命令清单在根 AGENTS.mdCommands 一节,测试策略的权威文档是 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_KEYPERPLEXITY_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 buildheadless 一次性 agent 需要环境或仓库根 .env 里的 DEEPSEEK_API_KEYdemo: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 dshnode --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)策略:

  1. 用真实 key 跑一次真实的 example,把完整转录录下来(test:snapshot:record
  2. 之后的验证不再调模型,而是回放这段录制(test:snapshot),比对”组装后的完整应用”对外暴露的规范化输出与重新持久化的日志
  3. 无 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-runtime CI job 才跑)

两套 SDK 是各自独立投影 agent loop、会话生命周期和 SessionEventMap 的——改其中任何一个,两边都要改,没有”改一边另一边自动跟”这回事。testing.md 特别点名:pnpm run test 两个都不覆盖。所以改了会话生命周期却忘了更新两套 SDK 的预期输出,单测不会报错,CI 的 snapshot 门禁会拦下你。

13.2.5 一个 ACP 场景的完整链路

examples/acp-agent 的主场景为例,test:snapshot:record 走的是:

  1. 启动真实 automation-server example,发一段真实转录
  2. 把规范化后的 JSON-RPC 输出与重新持久化的日志存为期望输出
  3. 之后 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-localdsh-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.jsontsdown 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-pushtypecheck

13.4.3 沙箱内命令失败的边界

AGENTS.md 里有一条面向 agent 贡献者的指令:当 ghpnpm、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-catalogpersistence-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.mdAGENTS.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 包),绝不用裸 stringApprovalRequestIdSessionId 都是例子——品牌化让”这个字符串其实是个审批 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 用 .tsdsh CLI 的源码启动走 tsx 的 ESM-only hook,它触达的模块必须保持 ESM。
  • 命名规范:每个 npm 包都是 @deepseek-ai/dsh-<name>;vendored 包重作用域且 private: true@deepseek-ai/cordis 是每个 harness 包的 peerDependency。
  • Prefer maintained dependencies over hand-rolling:当依赖真的能删掉自有代码和测试时,优先用维护良好的依赖而不是手搓。
  • Vendoringvendor/ 是钉住的源码拷贝(清单带上游 SHA),更新走 sync 流程,重打或退役记录在案的本地修改,然后 pnpm run test && pnpm run build

13.6.7 类型安全与文档

  • 全仓 strict: true + noImplicitAny;每个残留的 any 都要解释为什么无法收窄。
  • 每个模块和导出都有简洁的 JSDoc 说明非显然契约;函数式导出带 @param/@returns,由 verify-export-jsdoc 强制执行。
  • 注释与文档陈述完整契约与上下文,不复述代码、不叙述控制流。写 contractboundaryshape 之前,先问有没有更精确的词——用 response fieldsJSON validationESM 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: proposed
  • Status: implemented
  • Status: 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## Risksrejected/ 保留提案时的骨架,裁决写在 Status: 行上。

这套格式由 pnpm run verify-agent-note-formatdoc-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 与模型可见输入的连带义务

有三类改动会牵动仓库的多个面,改动时必须同步:

  1. agent-loop:必须更新 docs/architecture.md。规则原文是 “Plugins, not loop changes”——新行为应该挂在文档化扩展点上,而不是动循环本身。
  2. 新增模型可见输入:必须新增对应的 session event。因为 “Model-visible ⟺ logged”,任何到达模型请求的东西都要能从日志重建。
  3. 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 机械校验,改源码声明必须同步更新粘贴件。