mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/pr202-merge-review
This commit is contained in:
6
docs/cookbook/adding-a-package.i18n.yaml
Normal file
6
docs/cookbook/adding-a-package.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-a-package.md: 2930cee9ab64b382f6211335ae639bce45629d1d
|
||||
adding-a-package.zh.md: 10e906c320203c3658c103fc8936540f72697d65
|
||||
@@ -1,6 +1,8 @@
|
||||
# Cookbook: adding a workspace package
|
||||
|
||||
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package. (Verified by the bash and adapter packages; if it drifts, fix it here.)
|
||||
English | [中文](adding-a-package.zh.md)
|
||||
|
||||
The file-by-file checklist for a new `@deepseek-ai/dsh-<name>` package. This checklist is validated against the bash and adapter packages as templates; if it drifts from them, fix it here.
|
||||
|
||||
## 1. Create the package
|
||||
|
||||
|
||||
83
docs/cookbook/adding-a-package.zh.md
Normal file
83
docs/cookbook/adding-a-package.zh.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 实操手册:添加 workspace 包(package)
|
||||
|
||||
[English](adding-a-package.md) | 中文
|
||||
|
||||
为新建 `@deepseek-ai/dsh-<name>` 包提供的逐文件清单。本清单以 bash 和 adapter 这两个包为模板进行验证;如果清单与模板有出入,请在此修正。
|
||||
|
||||
## 1. 创建包
|
||||
|
||||
```
|
||||
packages/<group>/<pkg>/
|
||||
package.json # copy from packages/core/tools, adjust name/description/deps
|
||||
tsconfig.json # extends ../../../tsconfig.base.json, rootDir src,
|
||||
# outDir lib/types, references: ../../../vendor/cosmokit,
|
||||
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
|
||||
# you use Config, + ../../<group>/<dep> for each dsh dep)
|
||||
src/index.ts # service default export or plugin (name/inject/apply/Config)
|
||||
tests/<x>.spec.ts
|
||||
README.md # service API, events, extension points, design notes,
|
||||
# + gated Model Experience context blocks or short sentence
|
||||
# + the gated "Known Limitations and Deferred Work" section
|
||||
# (or a whitelist entry in scripts/verify-package-readme-limitations.ts)
|
||||
```
|
||||
|
||||
当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。
|
||||
|
||||
package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表要精确:`lib/index.js`、`lib/types/**/*.d.ts`、`lib/types/**/*.d.ts.map` 和 `src`;不要发布 `lib/types` 下的 JS 或 JS-map 中间产物,也不要发布陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。
|
||||
|
||||
包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。
|
||||
|
||||
## 2. 在根配置中注册
|
||||
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | 已有分组无需编辑;新分组需为 `@deepseek-ai/dsh-*` 通配符添加 `./packages/<group>/*/src` 候选路径 |
|
||||
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
|
||||
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages/<group>/<pkg>" }` |
|
||||
| `knip.json` | 仅当包有非 `*.spec.ts` 入口时需要(如 `*.e2e.ts` → 添加 per-workspace override,参照 `packages/llm/llm-deepseek`) |
|
||||
|
||||
以下内容由 glob 或包 manifest 发现机制自动覆盖,无需手动编辑:根 `package.json` workspaces、`scripts/publint-all.ts`、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`、`scripts/check-workspace-constraints.ts`。
|
||||
|
||||
## 3. 确定包拓扑
|
||||
|
||||
对于可替换的能力,将接口、实现、消费方拆分为独立的包(见 docs/architecture.md § "Capability seams"——bash 三组件是模板)。单一用途的插件保持为一个包。
|
||||
|
||||
## 4. 编写包 README
|
||||
|
||||
将包特有的服务 API、配置、事件、扩展点和设计说明放在前面。limitations 部分记录持久的消费方缺口和本包拥有的非显而易见的维护者约束;日常清理事项留在源码 TODO 或 RFC 中。间接的 Model Experience 语句可以点名暴露本包贡献的消费方,但不重述该消费方的实现。包 README 以如下规范序列结尾:
|
||||
|
||||
````markdown
|
||||
## Model Experience
|
||||
|
||||
### Request surface and condition
|
||||
|
||||
**What the model sees**: An exact data-dependent shape, an anchored generated-catalog link, or an introduction to the verbatim literal below.
|
||||
|
||||
**Token effect**: Fixed, conditional, retained, replaced, capped, or zero-direct token effect.
|
||||
|
||||
#### Verbatim text for this context surface, when needed
|
||||
|
||||
```markdown
|
||||
Stable system-prompt prose of any length, or another long non-generated literal, copied exactly from source.
|
||||
```
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Consumer-visible gap** — exact boundary, consequence, or maintainer constraint.
|
||||
````
|
||||
|
||||
根据实现填写 Model Experience。每个直接、条件、上限、生命周期或辅助模型的 surface 使用一个 H3,包含上述两个字段。引用包拥有的稳定文本:系统提示词放在带标题的 H4 加 `markdown` 围栏中,其他短文本以命名占位符内联,其他长文本使用相同的嵌套形式。仅概述数据依赖或提供方拥有的文本。tool-schema surface 链接到生成的[工具目录](../tool-catalog.md)中对应的锚定章节,仅说明该处缺失的差异。当作用域可以隐藏 prompt 或 schema 其中之一而不影响另一个时,将二者分开。[行文标准](../../.agents/skills/dsh-prose-standard/SKILL.md)约束完整性与归属;验证器强制执行机械形状。
|
||||
|
||||
没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience RFC](../rfc/implemented/process/2026-07-12-package-model-experience-contract.md) 记录了设计动机。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```sh
|
||||
pnpm install # registers the workspace
|
||||
pnpm run doc-sync
|
||||
pnpm run constraints && pnpm run typecheck && pnpm run lint
|
||||
pnpm run test:coverage # 100% per-file over src (types.ts exempt)
|
||||
pnpm run build && pnpm run hygiene
|
||||
```
|
||||
|
||||
测试要求:每个注册表/注册操作都需要一个 HMR(热模块替换)安全测试(从子 fiber 注册,dispose(资源释放)它,断言清理完成)。鼓励编写充分的测试——见 [docs/testing.md](../testing.md)。
|
||||
6
docs/cookbook/adding-a-tool.i18n.yaml
Normal file
6
docs/cookbook/adding-a-tool.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-a-tool.md: 4920c98894326fb8eab3b3d5df298baf1da33c1d
|
||||
adding-a-tool.zh.md: 3caf1e62f15f2f15103836b4d3f22be20ba02385
|
||||
@@ -1,5 +1,7 @@
|
||||
# Cookbook: adding a tool
|
||||
|
||||
English | [中文](adding-a-tool.zh.md)
|
||||
|
||||
How to give the model a new capability. Reference implementations: `examples/echo-agent/src/echo-tool.ts` (minimal) and `packages/bash/tool-bash` (production-grade, three-package seam).
|
||||
|
||||
## The minimal shape
|
||||
@@ -49,7 +51,7 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task
|
||||
|
||||
## Execution policy and observation
|
||||
|
||||
Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](./extension-cookbook.md#a-hook-plugin-permission-gate)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points).
|
||||
Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](./extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap core dispatch with a deadline/retry/metrics scope, `tools/post-execute` to transform or attach model-facing context, and `tools/result` to observe the immutable normalized outcome without changing it. A sandboxing implementation can also sit behind the tool's executor capability seam; the exact contracts are in the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points).
|
||||
|
||||
## Code Mode reaches your tool for free
|
||||
|
||||
|
||||
85
docs/cookbook/adding-a-tool.zh.md
Normal file
85
docs/cookbook/adding-a-tool.zh.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 实操手册:添加工具
|
||||
|
||||
[English](adding-a-tool.md) | 中文
|
||||
|
||||
如何为模型赋予一项新能力。参考实现:`examples/echo-agent/src/echo-tool.ts`(最小化)和 `packages/bash/tool-bash`(生产级,由三个包(package)构成的 seam)。
|
||||
|
||||
## 最小形态
|
||||
|
||||
```ts
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'my-tool'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'read_file',
|
||||
description: 'Read a file from disk.', // what the model sees
|
||||
parameters: {
|
||||
path: { type: 'string', required: true, description: 'Absolute path' },
|
||||
limit: { type: 'number' }, // optional by default
|
||||
},
|
||||
async execute(args, exec) {
|
||||
// args is TYPED from the schema: { path: string; limit?: number }
|
||||
// exec carries immutable identity + token; signal is the operational field
|
||||
return [{ type: 'text', text: await readFile(args.path, 'utf8') }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
注册基于副作用:dispose(资源释放)插件 fiber 即注销该工具(请编写 HMR(热模块替换)测试)。schema 会自动流入系统提示词的组装过程。
|
||||
|
||||
## execute() 契约的规则
|
||||
|
||||
- **参数已为你校验。** `defineTool` 在 `execute` 运行前,会根据 `SchemaSpec` 校验模型生成的 `arguments`(类型、必填键、枚举成员、嵌套对象/数组——见[运行时参数校验](../rfc/implemented/architecture/2026-06-11-runtime-arg-validation.md)),因此 `execute` 内部的 args 已匹配 `InferArgs`。你仍需手动检查 DSL 无法表达的值约束(非空字符串、正数、跨字段规则),对这些情况抛出描述性 Error。直接注册的原始 JSON-Schema 工具(MCP)不由 harness 校验,它们自行校验输入。
|
||||
- **注册借用你的只读定义。** 类型化的同进程贡献不是序列化边界;注册后不要修改其 schema 或替换回调。`schemas()` 只物化显式的模型可见投影。如需热替换工具,请 dispose 其所属副作用并注册替代品;回调闭包内的可变状态仍是普通的插件状态。
|
||||
- **执行身份受保护。** 注册表在一次递归遍历中将 `arguments` 物化为分离的无损 JSON,在策略开始前冻结该值,并分配一个不透明的 `exec.token`;`callId`、`name`、`arguments`、`agent`、`token` 以及可选的外层传输 `parent` token 在整个分发过程中保持不可变。`parent` 仅用于身份标识,不暴露活跃的外层执行。请将 `args` 视为只读输入。around-dispatch 包装器只能添加、替换或移除 `exec.signal`,以施加取消或截止时间。
|
||||
- **抛出异常或返回非 JSON 数据意味着 `isError`。** 注册表捕获异常,并在观察者运行前物化最终结果。格式错误或非 JSON 的结果变为 `{ isError: true }`,防止出现无法记录的活跃成功。基础设施故障请抛异常;当模型需要解读领域失败时,请在结果文本中报告。
|
||||
- **遵守 `exec.signal`。** 信号触发时取消进行中的工作。
|
||||
- **使用 `meta` 附加持久化的卡片数据(可选)。** `execute` 可以返回 `{ content, meta }` 而非裸的 `ContentBlock[]`。`meta` 是 JSON 可序列化的载荷,核心将其视为不透明数据,持久化在 `tool/result` 事件上并回传给你的 `presentResult`(这样需要 `args` 之外信息的卡片——如 `write`/`edit` 的已应用 hunk diff——在会话回放中依然存活)。仅在此处放 UI 数据,绝不放入模型可见的 `content`。
|
||||
- **使用 `exec.agent` 发送异步通知。** `agent.inject(content, {source: {kind: 'plugin', plugin: '<name>'}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 agent(智能体)保持空闲)。请防范已 dispose 的 agent(try/catch)。
|
||||
|
||||
## 长时间运行的工作
|
||||
|
||||
遵循 tool-bash 的后台模式:`run_in_background` 标志立即返回一个 task id;配套工具增量轮询和终止;完成通知通过 `agent.inject()` 到达。限定缓冲区大小,将完整输出溢写到磁盘,避免静默丢失。
|
||||
|
||||
> TODO: 目前每个工具都手动重新实现这套后台模式。未来需要一个通用的长时间运行工具层,统一处理 task id、增量轮询、终止和完成通知。
|
||||
|
||||
## 执行策略与观测
|
||||
|
||||
尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](./extension-cookbook.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝(后续监听器无法撤销);使用 `tools/execute` 为核心分发包装截止时间/重试/指标作用域;使用 `tools/post-execute` 转换或附加模型可见的上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。沙箱实现也可以位于工具执行器的能力 seam 之后;确切契约见 [`dsh-tools` README](../../packages/core/tools/README.md#extension-points)。
|
||||
|
||||
## Code Mode 自动触达你的工具
|
||||
|
||||
在 [Code Mode](../../packages/core/tools/README.md) 中,每个可见的已注册工具都可通过 `await tools.<name>(args)` 调用,无需额外集成。SDK 从同一份 JSON Schema 派生参数,调用重新进入正常的执行流水线。请将描述写成面向模型的 API 文档;非文本结果块在程序中变为占位符。
|
||||
|
||||
## 工具在编辑器中的渲染方式(ACP 展示)
|
||||
|
||||
工具的 `execute` 返回模型可见的内容;其**编辑器卡片**是一个独立的、可选的关注点,通过 `defineTool` 选项中的两个纯展示方法声明。请与 `execute` 同步设计,而非事后补充——编辑器(如 Zed,通过 ACP(Agent Client Protocol)桥接)会展示该卡片,没有展示方法的工具回退为一个朴素的通用卡片(标题 = 工具名,原始 args 作为输入)。
|
||||
|
||||
两个方法都返回一个 **`card` 标签的渲染意图**——选择与你的工具行为匹配的卡片类型:
|
||||
|
||||
- `presentCall(args)` → 一个 `ToolCallView`(PENDING 卡片):
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`——默认。设置 `kind` 获取图标(`read`/`search`/…);设置 `locations: [{ path, line? }]` 标注工具涉及的文件,使有能力的编辑器跟随/跳转。
|
||||
- `{ card: 'terminal', title, description?, cwd? }`——你的调用本身就是 shell 命令。`title` 是命令,`description` 渲染在终端卡片上方。(tool-bash。)
|
||||
- `{ card: 'diff', title, diffs, locations? }`——你的调用创建或修改文件。`diffs: [{ path, oldText, newText }]`(新文件时 `oldText: null`)渲染为内联 diff 卡片。(tool-fs `write`/`edit`。)
|
||||
- `presentResult(args, { content, isError, meta? })` 返回完成后的卡片:
|
||||
- `generic` 提供可选的标题和内容。
|
||||
- `terminal` 提供原始输出和可选的退出元数据;桥接层渲染能力特定或围栏回退视图。
|
||||
- `diff` 提供已应用的 hunk,通常由持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为 ACP 更新会替换 pending 卡片的内容。
|
||||
|
||||
硬性规则(违反会出问题):
|
||||
|
||||
- **纯函数。** 这些方法在实时流式输出和会话日志回放时都会运行,因此必须是 `args`(加 result)的纯函数——不做 I/O、不读会话状态、不用时钟/随机数。diff 从 args 派生(`write` 使用 `oldText: null`,因为调用时的展示器没有文件先前内容);**桥接层**(而非工具)填充会话 cwd 并相对化展示路径标题。如果你发现自己想在 `presentCall` 内获取文件旧内容或工作目录,请停下——那属于桥接层或未来的 result-event 形态,不属于展示器。
|
||||
- **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径——这些都不得出现在 `execute` 返回给模型的内容中;它们只存在于展示层。(`terminal` 结果视图携带原始 `output`;桥接层添加围栏。)
|
||||
- **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的 arg 形态会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。
|
||||
|
||||
中性词汇定义在 `dsh-tools` 中(绝不在工具中导入 ACP 类型);ACP 桥接层将每个 `card` 映射到协议格式(wire format)。设计与原因见[渲染意图联合体 RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。
|
||||
|
||||
## 每个工具必须的测试
|
||||
|
||||
覆盖参数拒绝、每种结果形态和 HMR dispose。对于有副作用的工具,使用脚本化的 `MockAdapter` 驱动真实工具通过 agent loop(智能体循环),并断言其 `tool/call` 和 `tool/result` 会话事件。对于编辑器卡片,断言 `presentCall` 和 `presentResult` 的精确视图,并通过真实桥接层添加一个 [ACP 快照](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md);终端卡片的场景设置 `terminalOutput: true` 以覆盖 capable-client 路径。
|
||||
6
docs/cookbook/adding-a-vendored-package.i18n.yaml
Normal file
6
docs/cookbook/adding-a-vendored-package.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-a-vendored-package.md: d7b5b93b59fb39d8369be6eb42fb0a8b977c68b4
|
||||
adding-a-vendored-package.zh.md: 86b1e6c959180ba15b6fcb56b6dfe5a3be791b47
|
||||
@@ -1,5 +1,7 @@
|
||||
# Cookbook: adding a vendored package
|
||||
|
||||
English | [中文](adding-a-vendored-package.zh.md)
|
||||
|
||||
When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-http`), it is **vendored** as pinned source under `vendor/`, not added as an npm dependency — see [the vendoring decision](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md) for why. [vendor/README.md](../../vendor/README.md) covers *updating* an already-vendored package; this guide is the file-by-file checklist for adding a **new** one. (Verified against the existing vendored set; if it drifts, fix it here.)
|
||||
|
||||
## 1. Copy the source in
|
||||
|
||||
60
docs/cookbook/adding-a-vendored-package.zh.md
Normal file
60
docs/cookbook/adding-a-vendored-package.zh.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 实操手册:添加一个 vendored 包(package)
|
||||
|
||||
[English](adding-a-vendored-package.md) | 中文
|
||||
|
||||
当 harness 需要引入另一个上游 Cordis 包(如 `@cordisjs/plugin-http`)时,应将其作为固定版本的源码 **vendor** 到 `vendor/` 下,而非作为 npm 依赖添加——原因见[vendoring 决策](../rfc/implemented/process/2026-06-11-vendor-cordis-as-source.md)。[vendor/README.md](../../vendor/README.md) 介绍如何*更新*已有的 vendored 包;本指南是添加**新** vendored 包的逐文件清单。(已对照现有 vendored 集合验证;如有偏差,请在此修正。)
|
||||
|
||||
## 1. 复制源码
|
||||
|
||||
```
|
||||
vendor/<dir>/
|
||||
package.json # from upstream; set "private": true, keep name/exports/type
|
||||
tsconfig.json # extends ../../tsconfig.base.json (see shape below)
|
||||
src/ # the upstream src/ verbatim
|
||||
README.md LICENSE # if upstream ships them
|
||||
```
|
||||
|
||||
`tsconfig.json` 与其他 vendored 包保持一致:`rootDir: src`、`outDir: lib/types`、上游代码所需的严格性放宽项,以及对所导入的每个其他 vendored 包的 `references` 条目:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src", "outDir": "lib/types",
|
||||
"noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false,
|
||||
"noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../cordis" }, { "path": "../cosmokit" }]
|
||||
}
|
||||
```
|
||||
|
||||
`package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。
|
||||
|
||||
vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地的构建形态与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。
|
||||
|
||||
## 2. 在根配置中注册
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|---|---|
|
||||
| `tsconfig.base.json` | 在 `paths` 中添加 `"<npm-name>": ["./vendor/<dir>/src"]` |
|
||||
| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }` |
|
||||
| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/<dir>" }`(置于 `packages/*` 条目之前) |
|
||||
| `vendor/README.md` | 添加一行 manifest 表格行(dir、npm name、version、upstream repo、commit SHA)并记录所有本地修改 |
|
||||
| `scripts/publint-all.ts` | 仅当该 vendored 包本身从此仓库发布时才需要(vendored 依赖通常不发布——跳过) |
|
||||
|
||||
以下由 glob 自动覆盖,无需手动编辑:根 `package.json` 的 workspaces(`vendor/*`)、`tsdown.config.ts`、`vitest.config.ts`、`eslint.config.mjs`。只有当构建形态偏离根默认值时(双 ESM/CJS 或多入口——参见 `vendor/schemastery` 和 `vendor/logger-console`),才需要单独的 `vendor/<dir>/tsdown.config.ts`;其入口应读取 `lib/types` 下输出的 JS。
|
||||
|
||||
## 3. 注意 manifest 守卫
|
||||
|
||||
`scripts/check-vendor-manifest.sh`(pre-commit 钩子)会在 `vendor/*/src` 下有暂存改动但 `vendor/README.md` 未一起暂存时失败。请将 manifest 更新与源码一起暂存,以通过提交检查。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
```sh
|
||||
pnpm install # registers the workspace
|
||||
pnpm run typecheck
|
||||
pnpm run build && pnpm run test && pnpm run constraints
|
||||
```
|
||||
|
||||
源码 `paths` 映射由构建配置和根类型检查配置共享。重要的隔离边界是 project-reference 图:vendored 源码必须通过其自身的 `vendor/<dir>/tsconfig.json` 被引用,而非被拉入根目录的严格程序中。
|
||||
6
docs/cookbook/adding-an-llm-adapter.i18n.yaml
Normal file
6
docs/cookbook/adding-an-llm-adapter.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
adding-an-llm-adapter.md: 70306ccf119f523dd812a859eef1e8628383bb48
|
||||
adding-an-llm-adapter.zh.md: e6d151adfc793a829a876031d5d7280ba078c8e9
|
||||
@@ -1,5 +1,7 @@
|
||||
# Cookbook: adding an LLM adapter
|
||||
|
||||
English | [中文](adding-an-llm-adapter.zh.md)
|
||||
|
||||
How to connect a new model provider. Reference implementations: `packages/llm/llm-deepseek` (hand-rolled HTTP/SSE) and `packages/llm/llm-pi-ai` (wrapping an LLM library). Read the `StreamChunk` doc in `packages/llm/llm/src/types.ts` first — it records the protocol conventions both adapters were verified against.
|
||||
|
||||
## The shape
|
||||
|
||||
45
docs/cookbook/adding-an-llm-adapter.zh.md
Normal file
45
docs/cookbook/adding-an-llm-adapter.zh.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 实操手册:添加 LLM 适配器
|
||||
|
||||
[English](adding-an-llm-adapter.md) | 中文
|
||||
|
||||
如何接入一个新的模型提供方。参考实现:`packages/llm/llm-deepseek`(手写 HTTP/SSE)与 `packages/llm/llm-pi-ai`(封装 LLM 库)。请先阅读 `packages/llm/llm/src/types.ts` 中的 `StreamChunk` 文档——它记录了两个适配器都经过验证的协议约定。
|
||||
|
||||
## 基本形态
|
||||
|
||||
```ts ignore-check
|
||||
class MyAdapter extends LlmAdapter {
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { … }
|
||||
}
|
||||
|
||||
export const name = 'llm-myprovider'
|
||||
export const inject = ['llm']
|
||||
export const Config: z<Config> = z.object({ apiKey: z.string(), … })
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.llm.registerAdapter(['model-a', 'model-b'], new MyAdapter(…))
|
||||
}
|
||||
```
|
||||
|
||||
注册基于副作用(HMR 安全);每个模型名称对应一个适配器,重复注册会抛出异常。密钥采用 Cordis 原生方式管理:schemastery Config 带环境变量回退,通过 cordis.yml 的 `!!js process.env.MY_KEY` 注入。代码中禁止临时读取密钥文件。
|
||||
|
||||
## 协议义务(两个实现共同验证的契约)
|
||||
|
||||
- 在 `finish` **之前**发出 `usage`;`finish` 之后**不再发出任何内容**。稳健做法:缓冲 finish/usage 直到提供方的流结束标记,再统一 flush(可处理提供方在末尾发送仅含 usage 的分片的情况)。
|
||||
- 工具调用的 `arguments` 全程为原始 JSON 字符串;流式片段以 `argumentsDelta` 发送。如果你的提供方返回已解析的对象,请在 `block-end` 时重新 stringify。
|
||||
- 按首次出现的流顺序分配块 `index`;同一个块的每次 delta 复用该 index。
|
||||
- 错误有且仅有两条合法路径:从 `stream()` **抛出**(传输与协议故障——使用带稳定 code 的 `LlmError`),或以 `finish {kind: 'error' | 'aborted'}` 结束流(提供方带内故障)。消费方两者都处理;按故障类别选择路径并加以文档化。
|
||||
- 遵守 `options.signal`(将其传递给 fetch 或你的 SDK)。
|
||||
- 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。
|
||||
|
||||
提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。
|
||||
|
||||
## 经验证有效的结构
|
||||
|
||||
将适配器拆分为可测试的阶段(llm-deepseek 的布局):协议格式(wire format)类型(`types.ts`,豁免覆盖率)→ 请求序列化器 → SSE/传输解析器 → 分片转换状态机 → 一个将它们串联的薄适配器类。每个阶段配备独立的单元测试套件。
|
||||
|
||||
## 测试
|
||||
|
||||
- **单元测试:mock 提供方,而非 harness。** 用脚本化的 `node:http` 服务器模拟提供方的协议格式,覆盖正常路径、所有错误状态码、畸形载荷、连接提前关闭和中止——无需网络,且能满足 100% 逐文件覆盖率门禁。对基于 SDK 的适配器同样适用(将 SDK 的 baseURL 指向 mock 服务器)。
|
||||
- **恶意分帧测试。** 在任意字节位置(包括 UTF-8 字符中间)切割流载荷——真实网络环境正是如此。
|
||||
- **E2E:`tests/*.e2e.ts`**,通过 `pnpm run test:e2e` 运行,以 `describe.skipIf(!process.env.MY_KEY)` 守卫,确保无密钥的 CI 保持绿色。覆盖你映射的每个模型 × 每种提供方模式(thinking 开/关、effort 级别)、一次包含后续轮次(历史中带工具结果)的工具调用往返,以及仅做宽松断言(子串/结构匹配、有界的 maxTokens——真实模型是非确定性的)。
|
||||
- 在 `knip.json` 中注册 e2e 文件模式(per-workspace `entry` 覆盖),否则 knip 会将其标记为未使用。
|
||||
6
docs/cookbook/extension-cookbook.i18n.yaml
Normal file
6
docs/cookbook/extension-cookbook.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
extension-cookbook.md: 40ee22b352c884d7f295c87726c54ab8e166c844
|
||||
extension-cookbook.zh.md: 4e5bc68c973649574bcb2404bea00096eb9ca41f
|
||||
@@ -1,14 +1,18 @@
|
||||
# Cookbook: extension plugin shapes
|
||||
|
||||
English | [中文](extension-cookbook.zh.md)
|
||||
|
||||
> FIXME: This important guide has not received sufficient human design review; complete that review before the first release.
|
||||
|
||||
The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see [adding a package](./adding-a-package.md), [adding a tool](./adding-a-tool.md), and [adding an LLM adapter](./adding-an-llm-adapter.md); for the seams these hook into see [docs/architecture.md](../architecture.md).
|
||||
|
||||
## A tool plugin
|
||||
|
||||
A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `execute` args, result shaping, the `run_in_background` pattern) lives in [adding-a-tool.md](./adding-a-tool.md) — that guide is the source of truth for the tool shape. Raw JSON-Schema `ToolDefinition`s are also accepted by `ctx.tools.register()` directly (that is how MCP-sourced tools arrive); `defineTool` is the typed sugar for first-party tools.
|
||||
|
||||
## A hook plugin (permission gate)
|
||||
## A hook plugin (permission-gate example)
|
||||
|
||||
A hook returns a typed decision from the `tools/pre-execute` gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.)
|
||||
This permission gate is one example of a hook plugin. It returns a typed decision from the `tools/pre-execute` gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this seam. Hook plugins can intercept other seams and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception seam; it needs no external protocol.
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
125
docs/cookbook/extension-cookbook.zh.md
Normal file
125
docs/cookbook/extension-cookbook.zh.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 实操手册:扩展插件形态
|
||||
|
||||
[English](extension-cookbook.md) | 中文
|
||||
|
||||
> FIXME:这篇重要指南尚未经过充分的人工设计审查;请在首次发布前完成审查。
|
||||
|
||||
针对 harness 扩展表面编写的三种插件形态,以示意性代码片段呈现(省略了 import 和辅助桩——不可直接复制运行)。完整的分步指南见[添加包(package)](./adding-a-package.md)、[添加工具](./adding-a-tool.md)和[添加 LLM(大语言模型)适配器](./adding-an-llm-adapter.md);这些插件所挂接的 seam 见 [docs/architecture.md](../architecture.md)。
|
||||
|
||||
## 工具插件
|
||||
|
||||
工具在 `ctx.tools` 上注册。带注解的 `defineTool` 示例(类型化的 `execute` 参数、结果塑形、`run_in_background` 模式)见 [adding-a-tool.md](./adding-a-tool.md)——该指南是工具形态的真源。`ctx.tools.register()` 也直接接受原始 JSON-Schema `ToolDefinition`(MCP 来源的工具就是这样到达的);`defineTool` 是为第一方工具提供的类型化语法糖。
|
||||
|
||||
## 钩子插件(以权限门禁为例)
|
||||
|
||||
这个权限门禁是钩子插件的一个示例。它从 `tools/pre-execute` 门禁返回一个类型化的决策,用于允许或拒绝一次调用;沙箱、权限和 plan-mode 插件都可以使用该 seam。钩子插件也可以拦截其他 seam,本身并不等同于权限门禁。「原生钩子」是在拦截 seam 上运行的普通 Cordis 插件,不需要外部协议。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
declare function isAllowed(exec: ToolExecution): Promise<boolean>
|
||||
|
||||
export const name = 'permission-gate'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (!(await isAllowed(exec))) {
|
||||
return { kind: 'deny', reason: 'Denied by policy.' }
|
||||
}
|
||||
return next()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
这个 waterfall(瀑布式事件)是可重排的策略层。当不变式需要单调的最终拒绝时使用 `ctx.tools.guard()`;当插件需要包裹实际分发生命周期时(超时/重试/指标;仅 `exec.signal` 可替换)使用 `tools/execute`;显式结果变换使用 `tools/post-execute`;对不可变最终结果的受限观察使用 `tools/result`。选择规则见[添加工具指南](./adding-a-tool.md#execution-policy-and-observation)。
|
||||
|
||||
## UI 插件
|
||||
|
||||
UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.send()` / `agent.steer()` 将输入驱动回去。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
declare function render(text: string): void
|
||||
declare function onUserInput(handler: (text: string) => void): void
|
||||
|
||||
export const name = 'my-ui'
|
||||
export const inject = ['agents']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
|
||||
render(event.data.chunk.text)
|
||||
}
|
||||
})
|
||||
onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }]))
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端驱动插件(外部协议桥接)
|
||||
|
||||
*客户端驱动*是面向协议格式(wire format)对端的 UI 插件。它拥有 stdio,因此必须禁用 stdout 日志;通过工厂创建或恢复 agent(智能体);将 harness 事件映射为协议消息;将请求映射为 `send()` 或 `cancel()`。每个请求从持久的 `turn/end` 恰好结算一次(即使渲染失败),并通过 `AgentHandle.dispose()` 拆除 agent 以使 dispose(资源释放)达到静止状态。
|
||||
|
||||
`packages/ui/acp` 是完整的工作示例:它将 agent 桥接到 ACP(Agent Client Protocol)(基于 stdio 的 JSON-RPC),使 Zed 及其他 ACP 编辑器能够驱动它。其 README 描述了完整的方法接口以及它在审批 seam 上注册的权限提示应答器。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'my-protocol-bridge'
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// Stream every logged assistant text/reasoning delta out to the client.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (chunk.type === 'text-delta') {
|
||||
// sendToClient({ kind: 'message_chunk', text: chunk.text })
|
||||
}
|
||||
}
|
||||
})
|
||||
// Inbound "prompt": create/resume an agent and feed it; settle on turn end.
|
||||
// Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
|
||||
}
|
||||
```
|
||||
|
||||
## 可运行的组装示例
|
||||
|
||||
三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-agent`](../../packages/ui/stdio-agent),ACP 演示加载 [`@deepseek-ai/dsh-acp-agent`](../../packages/ui/acp-agent),两个 app 包通过 [`@deepseek-ai/dsh-agent-core`](../../packages/core/agent-core) bundle 共享主干。
|
||||
|
||||
## 功能→机制映射
|
||||
|
||||
每个产品功能都映射到一个文档化扩展 seam 上的监听器——微内核声明由此可验证([微内核 RFC](../rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md))。没有任何一行修改循环本身。
|
||||
|
||||
`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 Code Mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。
|
||||
|
||||
| 产品功能 | 插件机制 |
|
||||
|---|---|
|
||||
| 钩子系统(用户级 + 项目级) | `agent/session-start`、`agent/prompt-submit`、`agent/request`、`agent/step-result`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-continuation` 上的监听器——每个拦截 waterfall 返回一个类型化 Decision;`dsh-hooks-claude` / `dsh-hooks-codex` 桥接器将钩子配置文件映射到这些 seam 上 |
|
||||
| `/goal` | 通过 `agent/turn-continuation` 强制继续 + `steer()` 提醒 |
|
||||
| `/loop` | 在 `turn/end` 会话事件上 `send()` 下一次迭代;或强制继续 |
|
||||
| 动态工作流 | `ctx.workflows` + worker-thread 引擎 + `workflow` 工具;结构化的进程内子任务通过作用域化的 prompt/工具注册、单调工具守卫、最终 `tools/result` 提交(包括外层 `run_code`)和终端 `agent/turn-stop` 来强制输出 |
|
||||
| 排队消息 + steering(中途引导) | 核心 `Agent.send()` / `Agent.steer()` |
|
||||
| 上下文压缩(context compaction)(自动 + 手动) | `ctx.compact` seam + 串行 `agent/pre-step` seam 上的后端(`dsh-compact-basic`);自动 = 每步之前的 token 压力检查;手动触发调用同一个 `ctx.compact` 例程([压缩 RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)——面向模型的 `/compact` 消费方工具已推迟) |
|
||||
| 系统提示词可配置性 | `ctx.systemPrompt.section()`,支持排序与作用域局部覆盖 |
|
||||
| AGENTS.md(根目录) | 一个读取该文件的 section provider |
|
||||
| AGENTS.md(子目录,按需触发)+ 文件变更通知 | 从 watcher / tool-result 监听器调用 `agent.inject()` |
|
||||
| 内置工具 | `ctx.tools.register()`;schema 自动流入装配——`dsh-tool-*` 系列(bash、fs、web、subagent、todo)是已交付的示例 |
|
||||
| ToolSearch / 渐进式披露 | 当可见集变化时替换一个作用域化的 `ctx.tools.restrict()` 注册;注册表保持展示、查找和执行三者对齐 |
|
||||
| 工具截止时间 / 重试 / 指标 | 用 `tools/execute` 包裹核心分发;包装器可替换 `exec.signal`、委托执行,并在同一词法生命周期内检视规范化结果 |
|
||||
| 最终工具结果指标 / 审计 / 捕获 | 用 `tools/result` 观察不可变的权威结果;仅当插件需要变换结果或附加上下文时才使用 `tools/post-execute` |
|
||||
| 单调终端轮次策略 | 从串行 `agent/turn-stop` 返回 `{ action: 'stop' }`,此时 continuation 和 steering 已折叠完毕 |
|
||||
| 子进程沙箱(landlock / sandbox-exec) | 通过 `dsh-bash-sandbox` 使用 `ctx.sandbox` 后端;能力级别的拒绝使用 `tools/pre-execute` |
|
||||
| 权限系统 / AskUserQuestion | 从 `tools/pre-execute` 返回 `ask` 并通过 `ctx.approval` 应答;为普通用户提问注册一个独立的面向模型的 ask 工具 |
|
||||
| Plan mode | `tools/pre-execute`(拒绝写操作)+ 通过 `ctx.systemPrompt.section()` 或 `agent.inject()` 注入模式提示词段(model-visible ⟺ logged:`agent/request` 仅塑形调用配置) |
|
||||
| 子 agent 委派 | `ctx.subagents` 提供方注册表(`dsh-subagent-spawn`/`-fork`/`-acp`)+ `dsh-tool-subagent` 向模型暴露一个已配置的提供方 |
|
||||
| MCP | 每个服务器一个插件:发现工具 → `ctx.tools.register()` |
|
||||
| Skill(技能) | section + 工具注册;调用时通过 `inject()` 注入 skill 内容 |
|
||||
| 记忆 | section provider + 工具 |
|
||||
| 定时任务(cron) | 插件注册面向模型的调度工具;定时器触发 → 空闲时 `send(…, {source: {kind: 'cron', …}})`/忙碌时 `inject()` 通知 |
|
||||
| UI(GUI;CLI 输出 JSONL) | 监听 `session/event`(助手分片、边界、工具活动);输入 → `send()` |
|
||||
| 遥测 / 可回放 trace | `session/event` → JSONL;回放 = `sessions.create(id, { seed })` |
|
||||
| 模型适配器 | 通过 `registerAdapter` 注册 `LlmAdapter` 子类(`dsh-llm-deepseek`、`dsh-llm-pi-ai`) |
|
||||
| 插件热重载 | 每个注册都是一个 `ctx.effect` → vendor 的 HMR(热模块替换)直接生效 |
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
responding-to-pr-review-on-a-stack.md: 3fb7eb943eeb8d703303be3f6a844870cc26fd47
|
||||
responding-to-pr-review-on-a-stack.zh.md: d96323b853c093265931904c20996df335f82926
|
||||
@@ -1,6 +1,8 @@
|
||||
# Responding to review across a stacked PR chain
|
||||
|
||||
A wave of review comments lands across several PRs in a dependent stack (`A ← B ← C …`). This is the discipline for resolving it without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch.
|
||||
English | [中文](responding-to-pr-review-on-a-stack.zh.md)
|
||||
|
||||
Review comments may target several PRs in a dependent stack (`A ← B ← C …`). This guide explains how to resolve them without corrupting the stack. The two invariants it rests on are standing orders in the root [AGENTS.md](../../AGENTS.md) § Conventions: merge commits only, and never rewrite a pushed branch.
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -9,7 +11,7 @@ A wave of review comments lands across several PRs in a dependent stack (`A ←
|
||||
3. **A fix lands on the PR that INTRODUCED the issue, then flows down.** When a comment on PR `B` points at code `B` introduced, fix it on `B` and merge `B` into `C` — even if `C` also carries the file. Originating the fix downstream leaves `B` shipping the unfixed code and hides the fix from `B`'s reviewer.
|
||||
4. **Each review fix is a separate commit, never an amend.** The "fix review findings" commit documents what the review caught. Amending is fine only for your own not-yet-pushed, not-yet-reviewed work.
|
||||
|
||||
## Working the wave
|
||||
## Resolve comments through the stack
|
||||
|
||||
1. Triage every comment on the merits before acting: verify the claim against the code — a reviewer flagging the right symptom can still mis-diagnose the cause.
|
||||
2. Map each accepted finding to its originating PR, fix it there, then merge down the chain in order.
|
||||
|
||||
26
docs/cookbook/responding-to-pr-review-on-a-stack.zh.md
Normal file
26
docs/cookbook/responding-to-pr-review-on-a-stack.zh.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 在堆叠 PR 链中回应评审意见
|
||||
|
||||
[English](responding-to-pr-review-on-a-stack.md) | 中文
|
||||
|
||||
评审意见可能同时针对一条依赖堆叠(`A ← B ← C …`)中的多个 PR(Pull Request)。本指南说明如何在不破坏堆叠的前提下解决这些意见。它依赖的两个不变式是根 [AGENTS.md](../../AGENTS.md) § Conventions 中的常设指令:只用 merge commit,以及永远不改写已推送的分支。
|
||||
|
||||
## 基本规则
|
||||
|
||||
1. **每个 PR 分支一个 worktree。** 每个 PR 的修复在该 PR 自己的 worktree 中进行;并行修复绝不共享同一个 checkout。
|
||||
2. **通过将父分支向下合并来更新子分支**(在子分支中执行 `git merge <parent-branch>`,产生一个新的 merge commit)。绝不对已推送的分支做 rebase/amend/force-push:改写会使分支与父 PR 及 GitHub 记录的内容产生分歧,破坏堆叠合并图,并抹去评审修复历史。
|
||||
3. **修复落在引入问题的那个 PR 上,然后向下流动。** 当 PR `B` 上的评论指向 `B` 引入的代码时,在 `B` 上修复,再将 `B` 合并到 `C`——即使 `C` 也包含该文件。把修复发起在下游会导致 `B` 带着未修复的代码交付,并对 `B` 的评审者隐藏修复。
|
||||
4. **每个评审修复是一个独立 commit,绝不 amend。** "修复评审发现"的 commit 记录了评审捕获的内容。只有你自己尚未推送、尚未评审的工作才可以 amend。
|
||||
|
||||
## 沿堆叠解决评审意见
|
||||
|
||||
1. 在行动之前先就事论事地审视每条评论:对照代码验证其论断——评审者指出了正确的症状,但仍可能误诊原因。
|
||||
2. 将每个被接受的发现映射到其发起 PR,在那里修复,然后按顺序沿链向下合并。
|
||||
3. 委派的修复需要信任但验证:子 agent(智能体)的报告描述的是意图,不一定是实际落地的内容。请亲自在实际代码树上重新运行门禁;对于回归守卫,要证明它在未修复的代码上**失败**(引入回归、观察变红、再还原)——两种情况都通过的守卫什么也守不住。子 agent 将问题重新定性为「已处理」时,这是一个需要亲自深入的信号。
|
||||
4. 在评审线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),而非发顶层评论;说明修复内容及承载修复的 commit。
|
||||
5. 合并堆叠之前,检查依赖方:删除一个 PR 的 base 分支会自动关闭依赖它的 PR。用 `gh pr list --state open --base <branch> --json number --jq length` 检查每个分支(非零 = 有打开的依赖方),当子 PR 仍以该分支为 base 时,合并时不带 `--delete-branch`。完整的落地流程见 [dsh-merging-stacked-prs](../../.agents/skills/dsh-merging-stacked-prs/SKILL.md) skill(技能)。
|
||||
|
||||
## 验证
|
||||
|
||||
- 每个已修复的 PR 显示一个新 commit(PR 时间线中没有 force-push 图标)。
|
||||
- 每个子 PR 相对其父 PR 的 diff 仍然只包含自身的变更。
|
||||
- 门禁在堆叠中的每个 PR 上都通过,而不仅仅是顶部。
|
||||
@@ -169,7 +169,7 @@ abstract list(): Promise<SessionHeader[]>
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
Source: [`packages/session-persistence/session-persistence/src/index.ts:30`](../../packages/session-persistence/session-persistence/src/index.ts)
|
||||
|
||||
## `ctx.sessionQuery` — `SessionQueryService`
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
| Cordis | Cordis | | | |
|
||||
| dispose | dispose | dispose(资源释放) | | |
|
||||
| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |
|
||||
| fiber | fiber | fiber(插件运行时) | | |
|
||||
| fiber | fiber | | | |
|
||||
| fixture | fixture | fixture(测试前置数据) | | |
|
||||
| fork | fork | | | |
|
||||
| Function Calling | Function Calling | Function Calling(函数调用) | | |
|
||||
@@ -51,7 +51,6 @@
|
||||
| loader | loader | | | |
|
||||
| manifest | manifest | manifest(元数据清单) | | |
|
||||
| monorepo | monorepo | | | |
|
||||
| package | package | | | 保留英文;指 npm 包(`@deepseek-ai/dsh-*`) |
|
||||
| schema | schema | | | |
|
||||
| schema DSL | schema DSL | | | |
|
||||
| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |
|
||||
@@ -125,6 +124,7 @@
|
||||
| module | 模块 | | | |
|
||||
| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |
|
||||
| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |
|
||||
| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |
|
||||
| pairing | 配对 | | | |
|
||||
| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |
|
||||
| permission | 权限 | | | |
|
||||
|
||||
@@ -79,6 +79,11 @@ You are a senior technical translator specializing in LLM and agent development
|
||||
|
||||
#### When translating into English
|
||||
- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.
|
||||
- Convert enumeration commas (、) to English commas; convert 「」 quotes to English double quotes.
|
||||
- Render the terminology table's English column the same way the Chinese column binds the other direction: listed terms use exactly the table's English form; first-occurrence glosses do not carry over (English prose never glosses an English term with Chinese).
|
||||
- Chinese topic-comment sentences and dropped subjects become explicit English subjects; prefer concise declaratives over nominalizations.
|
||||
- Do not transliterate Chinese engineering idioms literally: render the underlying concept (误报 → false positive, 执行红线 → enforcement frontier), consulting the terminology table first.
|
||||
- Keep the register of institutional developer documentation: contractions are acceptable, marketing language and hedging (very, quite, simply) are not.
|
||||
|
||||
## Terminology
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export type SurfaceOp =
|
||||
|
||||
### SurfaceManager: delta-based, not full rebuild
|
||||
|
||||
A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change — full rebuild is only needed after a wholesale log replacement (e.g., seeding).
|
||||
A `SurfaceManager` class (private to `Session`) maintains the cached linked list. It tracks `_lastProcessedSeq` and processes only the **delta** (new events since the last access) rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial delta folded on first access.
|
||||
|
||||
Delta processing is O(1) when no new events and O(new events) when new events arrive.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite or invalidation.
|
||||
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite.
|
||||
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
@@ -193,26 +193,14 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
|
||||
export class SurfaceManager {
|
||||
/** Incremental state shared with the complete surface fold. */
|
||||
private _state = createFoldState()
|
||||
/** The last processed seq. -1 forces a full rebuild on first access. */
|
||||
/** The last processed seq. -1 folds the seeded log on first access. */
|
||||
private _lastProcessedSeq = -1
|
||||
|
||||
constructor(private log: readonly SessionEvent[]) {}
|
||||
|
||||
/**
|
||||
* Reset to unprocessed state. Call after the log has been replaced
|
||||
* wholesale (e.g. after Session seed). Not needed for normal appends —
|
||||
* those are picked up incrementally.
|
||||
*/
|
||||
invalidate(): void {
|
||||
this._lastProcessedSeq = -1
|
||||
// A wholesale rebuild is a rewrite: bump the generation so incremental
|
||||
// consumers (the session's derived-message cache) discard their view.
|
||||
this._state = createFoldState(this._state.replaceGeneration + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface's rewrite generation: bumped by every folded `replace` op and
|
||||
* by {@link invalidate}. A replace is the ONE operation that rewrites the
|
||||
* The surface's rewrite generation, bumped by every folded `replace` op.
|
||||
* A replace is the ONE operation that rewrites the
|
||||
* surface non-monotonically, so an incremental consumer of {@link nodes}
|
||||
* (the session's derived-message cache) compares this between visits — an
|
||||
* unchanged generation guarantees every node it has not seen is a pure tail
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Derived-message cache contract against a scratch oracle: project new nodes
|
||||
* once, rebuild on surface generation changes, return fresh arrays over shared
|
||||
* once, rebuild on surface replacements, return fresh arrays over shared
|
||||
* frozen messages, and remain value-equal to replay at every step.
|
||||
*/
|
||||
|
||||
@@ -61,16 +61,6 @@ describe('derived-message cache', () => {
|
||||
expect(Object.isFrozen(first[0])).toBe(true)
|
||||
})
|
||||
|
||||
it('rebuilds after surface.invalidate() (the generation covers wholesale rebuilds too)', () => {
|
||||
const session = new Session(SessionId('cache-invalidate'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
userText(session, 'one')
|
||||
const before = session.deriveMessages()
|
||||
session.surface.invalidate()
|
||||
const after = session.deriveMessages()
|
||||
expect(after).toEqual(before)
|
||||
expect(after[0]).not.toBe(before[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
|
||||
@@ -81,14 +81,6 @@ describe('SurfaceManager', () => {
|
||||
expect(nodes[1]!.next).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidate resets to full rebuild', () => {
|
||||
const s = surfaceSession()
|
||||
expect(s.surface.nodes.length).toBe(2)
|
||||
// After invalidate, the surface should rebuild from scratch on next access.
|
||||
;(s.surface).invalidate()
|
||||
expect(s.surface.nodes.length).toBe(2) // same result, but rebuilt
|
||||
})
|
||||
|
||||
it('empty surface yields empty nodes', () => {
|
||||
const s = new Session(SessionId('empty'))
|
||||
// Only turn boundaries, no surface nodes.
|
||||
@@ -386,7 +378,7 @@ describe('surface type guards', () => {
|
||||
})
|
||||
|
||||
describe('SurfaceManager.replaceGeneration', () => {
|
||||
it('folds the pending log delta on access and counts replaces and invalidations', () => {
|
||||
it('folds the pending log delta on access and counts replaces', () => {
|
||||
const s = new Session(SessionId('gen'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
@@ -400,10 +392,5 @@ describe('SurfaceManager.replaceGeneration', () => {
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
|
||||
expect(s.surface.replaceGeneration).toBe(1)
|
||||
|
||||
// invalidate() is a rewrite too: the generation moves forward (and the
|
||||
// refold re-counts the replace), never backwards.
|
||||
s.surface.invalidate()
|
||||
expect(s.surface.replaceGeneration).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SessionPersistence, PersistenceCoordinator,
|
||||
type PersistenceBackend, type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
} from './format.ts'
|
||||
@@ -83,15 +83,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
/**
|
||||
* The per-session init promises, exposed for white-box tests that await a
|
||||
* specific session's onCreated (there is no public API to await one init).
|
||||
*/
|
||||
get inits(): Map<Session, Promise<void>> {
|
||||
return this.coordinator.inits
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---
|
||||
|
||||
/** Read a stored prefix by id across all cwd buckets when cwd is unknown. */
|
||||
|
||||
@@ -488,12 +488,11 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
|
||||
// A new Session object reuses the id. Object-keyed initialization must run independently,
|
||||
// detect the disk collision, and reject instead of appending through session A's stale cursor.
|
||||
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
|
||||
let b!: Session
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
|
||||
await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
|
||||
})
|
||||
|
||||
it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => {
|
||||
@@ -511,12 +510,11 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
|
||||
let b!: Session
|
||||
await ctx2.plugin(Object.assign((inner: Context) => {
|
||||
b = inner.sessions.create(SessionId('x')) // no cwd
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(backend.inits.get(b)).rejects.toThrow(/already has a persisted log on disk/)
|
||||
await expect(ctx2.sessions.flush(b)).rejects.toThrow(/already has a persisted log on disk/)
|
||||
|
||||
// The "/w" log is untouched — no no-cwd events were grafted onto it, and no
|
||||
// `_no-cwd` log for "x" was created.
|
||||
@@ -533,7 +531,6 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog())
|
||||
await ctx.sessionPersistence.load(SessionId('divergent'))
|
||||
|
||||
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
|
||||
// A seed that keeps every seq/type/time but mutates a payload must NOT be
|
||||
// accepted as "the same session" — otherwise drain filters those seqs as
|
||||
// already persisted and the divergent payload is silently lost.
|
||||
@@ -544,7 +541,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
|
||||
await expect(ctx.sessions.flush(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
|
||||
})
|
||||
|
||||
it('a second live session reusing a bound id is rejected', async () => {
|
||||
@@ -557,12 +554,11 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
|
||||
await firstFiber.dispose()
|
||||
|
||||
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
|
||||
let second!: Session
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(backend.inits.get(second))
|
||||
await expect(ctx.sessions.flush(second))
|
||||
.rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
|
||||
})
|
||||
|
||||
@@ -594,12 +590,11 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
|
||||
const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
|
||||
let s!: Session
|
||||
await ctx2.plugin(Object.assign((inner: Context) => {
|
||||
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(backend.inits.get(s)).rejects.toThrow(/ENOTDIR/)
|
||||
await expect(ctx2.sessions.flush(s)).rejects.toThrow(/ENOTDIR/)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SessionPersistence, PersistenceCoordinator,
|
||||
type PersistenceBackend, type StoredPrefix,
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
|
||||
} from './schema.ts'
|
||||
@@ -110,14 +110,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
// One method serves both public `list` and the backend hook; delegating it to
|
||||
// the coordinator would call this hook recursively.
|
||||
|
||||
/**
|
||||
* The per-session init promises, exposed for white-box tests that await a
|
||||
* specific session's onCreated (there is no public API to await one init).
|
||||
*/
|
||||
get inits(): Map<Session, Promise<void>> {
|
||||
return this.coordinator.inits
|
||||
}
|
||||
|
||||
// --- PersistenceBackend hooks (the SQLite storage primitives) ---
|
||||
|
||||
/** Read a stored prefix by id (ids are globally unique — no scope to scan). */
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { Context } from 'cordis'
|
||||
import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { seedCoversPrefix } from './index.ts'
|
||||
|
||||
/**
|
||||
* A stored session's header, valid contiguous event prefix, and optional opaque
|
||||
@@ -110,6 +109,15 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
|
||||
return errors
|
||||
}
|
||||
|
||||
/** Whether a live session seed reproduces a persisted prefix exactly. */
|
||||
function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
|
||||
return prefix.length <= seed.length
|
||||
&& prefix.every((event, index) => {
|
||||
const seedEvent = seed[index]
|
||||
return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the backend-agnostic session write-path orchestration. A backend
|
||||
* constructs one (`new PersistenceCoordinator(ctx, this)`), implements
|
||||
@@ -134,10 +142,10 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
private chains = new Map<SessionId, Promise<unknown>>()
|
||||
/**
|
||||
* Init promises keyed by live session object, preventing an id-reusing
|
||||
* replacement from inheriting stale initialization. Readonly access supports
|
||||
* backend white-box tests.
|
||||
* replacement from inheriting stale initialization. Flush is the public
|
||||
* observation boundary; callers do not inspect this bookkeeping directly.
|
||||
*/
|
||||
readonly inits = new Map<Session, Promise<void>>()
|
||||
private inits = new Map<Session, Promise<void>>()
|
||||
|
||||
constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) {
|
||||
this.installWritePath()
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
|
||||
// Re-export the metadata vocabulary so consumers import it from the seam.
|
||||
@@ -22,35 +21,6 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a live seed exactly reproduces a durable prefix, including full
|
||||
* payloads. This distinguishes resume/HMR rebinding from an id collision.
|
||||
* @param seed - the live session's creation-time event snapshot.
|
||||
* @param prefix - the persisted prefix the seed must reproduce.
|
||||
* @returns `true` when the prefix fits within the seed and every event matches by JSON text.
|
||||
*/
|
||||
export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
|
||||
return prefix.length <= seed.length
|
||||
&& prefix.every((event, index) => {
|
||||
const seedEvent = seed[index]
|
||||
return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a batch that is not wholly losslessly JSON-serializable. Live session
|
||||
* appends already enforce this; persistence append paths also accept replay or
|
||||
* direct batches that may bypass a live session instance. Validation uses the
|
||||
* same one-pass materializer as the coordinator, so getters are read once.
|
||||
* @param events - the complete event batch to validate.
|
||||
*/
|
||||
export function assertSerializable(events: readonly SessionEvent[]): void {
|
||||
const snapshot = snapshotJsonValue(events)
|
||||
if (snapshot === undefined) {
|
||||
throw new Error('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable append-only session storage. Implementations preserve contiguous,
|
||||
* losslessly JSON-serializable events; {@link append} resolves only after
|
||||
|
||||
@@ -13,7 +13,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
import { meta, oneTurnLog, appendLog } from './contract.ts'
|
||||
|
||||
/**
|
||||
@@ -39,11 +38,6 @@ export interface CoordinatorFixture {
|
||||
const WORK = '/w'
|
||||
const OTHER = '/other'
|
||||
|
||||
/** The per-session init map a backend exposes for white-box init awaits. */
|
||||
function inits(persistence: SessionPersistence): Map<Session, Promise<void>> {
|
||||
return (persistence as unknown as { inits: Map<Session, Promise<void>> }).inits
|
||||
}
|
||||
|
||||
/** Append a whole event log to a live session, event by event (drives session/event). */
|
||||
function send(session: Session, events: readonly SessionEvent[]): void {
|
||||
appendLog(session, events)
|
||||
@@ -168,7 +162,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const seed = oneTurnLog()
|
||||
// A fork: a brand-new id whose seed came from elsewhere.
|
||||
const forked = ctx.sessions.create(SessionId('forked'), { seed, meta: { cwd: WORK } })
|
||||
await inits(ctx.sessionPersistence).get(forked) // onCreated persisted the seed
|
||||
await ctx.sessions.flush(forked) // onCreated persisted the seed
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
|
||||
expect(loaded.events).toEqual(seed)
|
||||
// A flush with no NEW events must not double-write.
|
||||
@@ -197,7 +191,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
try {
|
||||
const loaded = await second.ctx.sessionPersistence.load(SessionId('resumed'))
|
||||
const s2 = second.ctx.sessions.create(SessionId('resumed'), { seed: loaded.events, meta: { cwd: WORK } })
|
||||
await inits(second.ctx.sessionPersistence).get(s2) // let onCreated adopt
|
||||
await second.ctx.sessions.flush(s2) // let onCreated adopt
|
||||
s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
await second.ctx.parallel('session/flush', s2)
|
||||
@@ -370,7 +364,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
try {
|
||||
const s2 = second.ctx.sessions.create(SessionId('collide'), { meta: { cwd: WORK } })
|
||||
s2.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await expect(inits(second.ctx.sessionPersistence).get(s2))
|
||||
await expect(second.ctx.sessions.flush(s2))
|
||||
.rejects.toThrow(/already has a persisted log|id collision/)
|
||||
} finally {
|
||||
await second.fiber.dispose()
|
||||
@@ -388,14 +382,14 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
firstSession = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await inits(ctx.sessionPersistence).get(firstSession) // register the lazy state
|
||||
await ctx.sessions.flush(firstSession) // register the lazy state
|
||||
await firstFiber.dispose() // disposed before any append → never materialized
|
||||
|
||||
let reuse!: Session
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
reuse = inner.sessions.create(SessionId('abandoned'), { meta: { cwd: WORK } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(inits(ctx.sessionPersistence).get(reuse)).resolves.toBeUndefined()
|
||||
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
|
||||
reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', reuse)
|
||||
@@ -415,7 +409,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
first = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await inits(ctx.sessionPersistence).get(first)
|
||||
await ctx.sessions.flush(first)
|
||||
// Append a turn but do NOT flush — events sit in the write-behind buffer.
|
||||
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
@@ -425,7 +419,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(inits(ctx.sessionPersistence).get(reuse)).rejects.toThrow(/already bound to a different live session/)
|
||||
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/already bound to a different live session/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -462,7 +456,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// A live session with that id arrives and claims it (cursor 0 matches
|
||||
// trivially), persisting its seed.
|
||||
const live = ctx.sessions.create(SessionId('lazy-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).resolves.toBeUndefined()
|
||||
await expect(ctx.sessions.flush(live)).resolves.toBeUndefined()
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
|
||||
} finally {
|
||||
@@ -487,7 +481,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
fresh = inner.sessions.create(SessionId('preview'), { meta: { cwd: WORK } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(inits(ctx.sessionPersistence).get(fresh))
|
||||
await expect(ctx.sessions.flush(fresh))
|
||||
.rejects.toThrow(/do not match this live session|already has a persisted log|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
@@ -511,7 +505,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
], meta: { cwd: WORK } })
|
||||
await inits(ctx.sessionPersistence).get(cont)
|
||||
await ctx.sessions.flush(cont)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('claim'))
|
||||
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
||||
} finally {
|
||||
@@ -531,7 +525,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// cwd scope is the fence (without it, WORK events would append under the
|
||||
// OTHER header). Rejected as a collision.
|
||||
const live = ctx.sessions.create(SessionId('wrong-cwd-claim'), { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -549,7 +543,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// A live session whose SEED matches the loaded prefix but whose cwd is
|
||||
// WORK must still be rejected — the cwd guard runs before the seed check.
|
||||
const live = ctx.sessions.create(SessionId('wrong-cwd-load'), { seed: events, meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
@@ -565,7 +559,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// A live session reusing the id but WITH cwd WORK is a cwd mismatch
|
||||
// (undefined vs WORK) and must be rejected.
|
||||
const live = ctx.sessions.create(SessionId('no-cwd-state'), { seed: oneTurnLog(), meta: { cwd: WORK } })
|
||||
await expect(inits(ctx.sessionPersistence).get(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
await expect(ctx.sessions.flush(live)).rejects.toThrow(/different cwd|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix,
|
||||
SessionPersistence, PersistenceCoordinator,
|
||||
type PersistenceBackend, type StoredPrefix,
|
||||
} from '../src/index.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog } from './contract.ts'
|
||||
@@ -53,11 +53,6 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
return this.coordinator.load(id)
|
||||
}
|
||||
|
||||
/** White-box accessor: await a specific session's onCreated init. */
|
||||
get inits(): Map<Session, Promise<void>> {
|
||||
return this.coordinator.inits
|
||||
}
|
||||
|
||||
// --- PersistenceBackend hooks (the Map storage primitives) ---
|
||||
|
||||
// A Map-backed store has no torn tails, so `tornMarker` is never set. Ids are
|
||||
@@ -157,38 +152,3 @@ describe('SessionPersistence service registration', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared persistence helpers', () => {
|
||||
it('accepts a seed that reproduces the persisted prefix exactly', () => {
|
||||
const log = oneTurnLog()
|
||||
expect(seedCoversPrefix(log, log.slice(0, 3))).toBe(true)
|
||||
expect(seedCoversPrefix(log, [])).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a prefix longer than the seed', () => {
|
||||
const log = oneTurnLog()
|
||||
expect(seedCoversPrefix(log.slice(0, 2), log)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a same-envelope event with mutated data', () => {
|
||||
const log = oneTurnLog()
|
||||
const tampered = structuredClone(log)
|
||||
const event = tampered[1]!
|
||||
tampered[1] = {
|
||||
...event,
|
||||
data: { ...event.data, content: [{ type: 'text', text: 'tampered' }] },
|
||||
} as SessionEvent
|
||||
expect(seedCoversPrefix(tampered, log.slice(0, 2))).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts JSON-serializable event data', () => {
|
||||
expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a batch containing non-JSON-serializable event data', () => {
|
||||
const bad = [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: 1n } },
|
||||
] as unknown as SessionEvent[]
|
||||
expect(() => { assertSerializable(bad) }).toThrow(/batch is not losslessly JSON-serializable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
"requiredSince": "2026-07-14",
|
||||
"required": [
|
||||
"README.md",
|
||||
"docs/cookbook/adding-a-package.md",
|
||||
"docs/cookbook/adding-a-tool.md",
|
||||
"docs/cookbook/adding-a-vendored-package.md",
|
||||
"docs/cookbook/adding-an-llm-adapter.md",
|
||||
"docs/cookbook/extension-cookbook.md",
|
||||
"docs/cookbook/responding-to-pr-review-on-a-stack.md",
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
|
||||
Reference in New Issue
Block a user