diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml new file mode 100644 index 0000000000..27c31ba1ef --- /dev/null +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -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 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index db0684b762..2930cee9ab 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -1,6 +1,8 @@ # Cookbook: adding a workspace package -The file-by-file checklist for a new `@deepseek-ai/dsh-` 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-` 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 diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md new file mode 100644 index 0000000000..10e906c320 --- /dev/null +++ b/docs/cookbook/adding-a-package.zh.md @@ -0,0 +1,83 @@ +# 实操手册:添加 workspace 包(package) + +[English](adding-a-package.md) | 中文 + +为新建 `@deepseek-ai/dsh-` 包提供的逐文件清单。本清单以 bash 和 adapter 这两个包为模板进行验证;如果清单与模板有出入,请在此修正。 + +## 1. 创建包 + +``` +packages/// + 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, + ../..// for each dsh dep) + src/index.ts # service default export or plugin (name/inject/apply/Config) + tests/.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//*/src` 候选路径 | +| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | +| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./packages//" }` | +| `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)。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml new file mode 100644 index 0000000000..7ff1ffff7a --- /dev/null +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -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 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 86803fdebb..4920c98894 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -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 diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md new file mode 100644 index 0000000000..3caf1e62f1 --- /dev/null +++ b/docs/cookbook/adding-a-tool.zh.md @@ -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: ''}})` 追加持久化上下文,下一次模型请求会看到它——这不是唤醒(空闲的 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.(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 路径。 diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml new file mode 100644 index 0000000000..0a359ead84 --- /dev/null +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -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 diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 59df2f617b..d7b5b93b59 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -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 diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md new file mode 100644 index 0000000000..86b1e6c959 --- /dev/null +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -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// + 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` 中添加 `"": ["./vendor//src"]` | +| `tsconfig.json` | 在 `references` 中添加 `{ "path": "./vendor/" }` | +| `tsconfig.build.json` | 在 `references` 中添加 `{ "path": "./vendor/" }`(置于 `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//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//tsconfig.json` 被引用,而非被拉入根目录的严格程序中。 diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml new file mode 100644 index 0000000000..37f934f3e6 --- /dev/null +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -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 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index fb59969e52..70306ccf11 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -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 diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md new file mode 100644 index 0000000000..e6d151adfc --- /dev/null +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -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 { … } +} + +export const name = 'llm-myprovider' +export const inject = ['llm'] +export const Config: z = 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 会将其标记为未使用。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml new file mode 100644 index 0000000000..ef1d8d606f --- /dev/null +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -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 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index a2aa255ccf..40ee22b352 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -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' diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md new file mode 100644 index 0000000000..4e5bc68c97 --- /dev/null +++ b/docs/cookbook/extension-cookbook.zh.md @@ -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 + +export const name = 'permission-gate' + +export function apply(ctx: Context) { + ctx.on('tools/pre-execute', async (exec, next): Promise => { + 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(热模块替换)直接生效 | diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml new file mode 100644 index 0000000000..d75b8cad3e --- /dev/null +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.i18n.yaml @@ -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 diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.md b/docs/cookbook/responding-to-pr-review-on-a-stack.md index 4eb7dc482d..3fb7eb943e 100644 --- a/docs/cookbook/responding-to-pr-review-on-a-stack.md +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.md @@ -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. diff --git a/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md new file mode 100644 index 0000000000..d96323b853 --- /dev/null +++ b/docs/cookbook/responding-to-pr-review-on-a-stack.zh.md @@ -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 `,产生一个新的 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 --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 上都通过,而不仅仅是顶部。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f0544b1ff4..ca83b3c489 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -169,7 +169,7 @@ abstract list(): Promise 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` diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 69f0e170e0..d14d0f2fd8 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -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 | 权限 | | | | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index a300ef64f4..60a023979a 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -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 diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 305c092303..6ba0df41c4 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -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. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 0c4dfbe9fc..7e201ab647 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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`. diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 5f7dac8254..37eebbf7c3 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -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 diff --git a/packages/core/session/tests/derived-cache.spec.ts b/packages/core/session/tests/derived-cache.spec.ts index 5d2047d9a5..5314c5e88f 100644 --- a/packages/core/session/tests/derived-cache.spec.ts +++ b/packages/core/session/tests/derived-cache.spec.ts @@ -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', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 55dd3dc0ef..eb15cf3317 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -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) }) }) diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index d5922afa94..1d13ff424e 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -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> { - 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. */ diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index a4f0334f50..cef291582a 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -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> } 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> } 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> } // 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> } 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> } 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() }) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 32b8f4ade8..7d23292e9d 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -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> { - return this.coordinator.inits - } - // --- PersistenceBackend hooks (the SQLite storage primitives) --- /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index cbdb9ba25f..4a2fed8d34 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -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 { + 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 { private chains = new Map>() /** * 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>() + private inits = new Map>() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 87bbdde129..3e6c9c21ed 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -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 diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index ffe5c7f6bd..f6692b9175 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -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> { - return (persistence as unknown as { inits: Map> }).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() diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 13943d35a7..a937a39da7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -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> { - 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/) - }) -}) diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index cea3eb393a..35a957e57d 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -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",