test(sdk): snapshot suite over the SDK path; docs for the SDK stack

- examples/jsonrpc-agent gains its first snapshot suite (sdk.snapshot.ts):
  the real dsh-jsonrpc-agent runtime driven through the real dsh-sdk-client,
  keyless llm-replay behind a new cordis.snapshot.yml overlay; three recorded
  scenarios (text turn, bash tool, spawn subagent) pin the notification
  stream, the SDK turn result, and the persisted parent+child session logs.
- Bilingual READMEs for dsh-sdk-protocol / dsh-sdk-client / dsh-subagent-sdk;
  sdk/ and subagent/ group tables extended; dsh-jsonrpc README points at the
  extracted protocol package; Agent Note (en+zh) owns the decision.
- The proposed make-jsonrpc-directional note is updated for the transport's
  new home and its second (client) consumer.
- Model Experience sentence allowlist entries for the two client-side
  packages; module graph + config catalog regenerated; i18n pairings
  recorded. doc-sync passes 24/24.
This commit is contained in:
Tianyi Cui
2026-07-27 04:37:23 +08:00
parent 3e89a73c71
commit 34aabc4183
42 changed files with 1570 additions and 39 deletions

View 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
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: b0a5461b00c76e06a75d9ac4bd8cde9778a26ce8
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 856db82f6d47ee686216c8ef5f91048493a90801

View File

@@ -0,0 +1,47 @@
# Agent Note: TypeScript SDK client and the SDK subagent backend
Status: implemented
English | [中文](2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)
## Problem
The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-jsonrpc`, the [single-exe Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)) had exactly one client: the Python SDK. TypeScript consumers wanting the same drive-a-harness-as-a-subprocess capability — repo tests, automation, and above all a subagent backend whose child is a *complete harness runtime* rather than a generic ACP agent — had nothing to import: the request/notification payload shapes existed only as anonymous object literals inside the server, and the transport class lived inside the server plugin package.
## Decision
Three packages, layered exactly like the existing Python stack, plus one seam registration:
- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/sdk-protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` moves here verbatim from `dsh-jsonrpc` (which now imports it), and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. One behavioral change: an error response now rejects with `JsonRpcResponseError` carrying the wire `code`/`data` (the Python client already preserved these; the old transport threw a bare `Error` with only the message).
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/sdk-client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, `run()` pairing one `session/prompt` with its `session.finished`). Session-tree scoping from `subagent.started` lineage edges is client-side, mirroring `client.py`. Deliberate asymmetries with Python: the launch spec is explicit `command`/`args` (no bundled-runtime resolution — that is a distribution concern with no TS consumer yet); `env` replaces rather than merges (callers own credential policy; `buildChildEnv` is one import away); `TurnResult` carries the structured `reason` (Python exposes only `status`); teardown reuses `disposeChildProcess` instead of hand-rolled terminate/kill.
- **`@deepseek-ai/dsh-subagent-sdk`** (`packages/subagent/subagent-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `provider`/`model` config feeds the child's `initialize`; `env` is where deployments pass the child's own key and `DSH_CORDIS_CONFIG`.
- **`dsh-subagent-subprocess` grows a third shared concern**: child cwd resolution (`assertUsableCwd`/`validateConfiguredCwd`/`resolveChildCwd`), extracted from `subagent-acp` when the SDK backend needed the identical config-override-else-parent-session-cwd policy, prefix-parameterized for diagnostics.
`dsh-jsonrpc` keeps serving unchanged (the wire is byte-identical); `dsh-jsonrpc-agent-pkg` (the Python runtime closure) gains the `dsh-sdk-protocol` dependency line.
## Testing
Four tiers, per [testing policy](../../../../docs/testing.md):
- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-sdk` drives the same fake through the real provider. 100% per-file coverage on all three packages.
- **Keyless Loader composition** — `subagent-sdk/tests/loader-composition.e2e.ts` boots a test-only cordis.yml (`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/`) where the child is a REAL second harness runtime with its own cordis.yml; asserts the parent tool result and the child's own persisted transcript both carry the parent session's cwd. The child launch resolves through `resolveExampleLaunch`, so src/lib modes both hold.
- **Keyless snapshot** — `examples/jsonrpc-agent/tests/sdk.snapshot.ts` is the jsonrpc example's first snapshot suite: the real `dsh-jsonrpc-agent` runtime driven through the real `dsh-sdk-client`, replaying recorded fixtures via `llm-replay` behind the new `cordis.snapshot.yml` overlay (passed explicitly through `DSH_CORDIS_CONFIG`; the jsonrpc bin performs no snapshot config swap of its own). Three scenarios — text turn, bash tool, spawn subagent — each pinning the normalized notification stream, the SDK turn result, and the persisted parent+child logs. This also closes the protocol-tier gap the single-exe note's Python-side snapshot left on the vitest side.
- **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design.
## Alternatives considered
**Import wire types from `dsh-jsonrpc` instead of extracting a protocol package.** Makes every SDK consumer (including `subagent-sdk`, which must not serve JSON-RPC) depend on the server plugin and its `dsh-agent`/`dsh-llm-deepseek` peer set, and leaves the notification payloads anonymous. The capability-seam rule (interface/implementation/consumer as separate packages) already names this shape; the transport is genuinely two-sided.
**Have `subagent-sdk` speak raw JSON-RPC without the client SDK.** Duplicates the request/notification pairing, subscription fan-out, timeout, and teardown logic the SDK exists to own; the user's ask was explicitly a backend that *uses* the SDK, and the layering earns its keep by making the backend ~200 lines of policy over a reusable client.
**Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the shared parts are exactly what `subagent-subprocess` already holds, so that library grew instead.
**Give the TS SDK bundled-runtime resolution parity with Python.** Python's carrier resolution exists to ship wheels to users without Node. A TypeScript consumer definitionally has Node and (in-repo) the workspace; inventing a distribution story with no consumer violates the require-current-need rule. Deferred until a real npm-distribution consumer appears.
**Reuse `dsh-acp-snapshot`'s `runScenario` for the SDK snapshots.** That harness speaks ACP (`ClientSideConnection`, `InputStep` scripts). The SDK suite's whole point is to drive the *SDK client* as the entry surface; it reuses the normalize/refresh library layer (`normalizeSessionLog`, `refreshFixtureReplacements`, …) and leaves the ACP driver alone.
## Consequences
**Bought**: the SDK runtime protocol now has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors and structured turn reasons; the subagent seam gains a harness-native out-of-process backend whose children are full peers (own config, persistence, tools) — the recursive-composition story the seam note anticipated; the jsonrpc example finally has snapshot coverage, through the SDK path itself.
**Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus.

View File

@@ -0,0 +1,47 @@
# Agent Note: TypeScript SDK 客户端与 SDK subagent 后端
Status: implemented
[English](2026-07-27-typescript-sdk-and-sdk-subagent-backend.md) | 中文
## Problem
stdio JSON-RPC 服务表面(`@deepseek-ai/dsh-jsonrpc`,见[单文件可执行 Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)当时只有一个客户端Python SDK。想要同样"把 harness 作为子进程驱动"能力的 TypeScript 消费者——仓库测试、自动化,尤其是一个其子进程是*完整 harness 运行时*(而非通用 ACP 代理)的 subagent 后端——无物可导入:请求/通知载荷形状只以匿名对象字面量存在于服务器内部,传输类也躺在服务器插件包里。
## Decision
三个包,分层与既有 Python 栈完全一致,外加一个接缝注册:
- **`@deepseek-ai/dsh-sdk-protocol`**`packages/sdk/sdk-protocol/`)—— 把线协议做成共享且具名。`JsonRpcLineTransport``dsh-jsonrpc` 原样移入(后者现在导入它),`types.ts` 为服务器所说的每个载荷命名:`InitializeParams/Result``SessionPromptParams/Result`、四个通知载荷,以及 `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` 索引。服务器的 `notify()` 调用点以这些具名载荷标注类型,服务器漂移会先破坏编译而不是破坏客户端。一处行为变化:错误响应现在以携带线上 `code`/`data``JsonRpcResponseError` 拒绝Python 客户端本就保留这些;旧传输只抛携带消息的裸 `Error`)。
- **`@deepseek-ai/dsh-sdk-client`**`packages/sdk/sdk-client/`)—— `python/sdk` 的 TypeScript 孪生:`HarnessClient`(生成、分帧、通知扇出、有类型的错误表面、经共享处置阶梯关闭至静止)之上是 `DeepSeekHarness`/`HarnessSession`(惰性启动、记忆化 `initialize``run()` 把一个 `session/prompt` 与其 `session.finished` 配对)。基于 `subagent.started` 血缘边的会话树范围限定在客户端完成,镜像 `client.py`。与 Python 的刻意不对称:启动规格是显式 `command`/`args`(无捆绑运行时解析——那是尚无 TS 消费者的发行问题);`env` 整体替换而非合并(凭据策略归调用方;`buildChildEnv` 一个 import 即得);`TurnResult` 携带结构化 `reason`Python 只暴露 `status`);拆除复用 `disposeChildProcess` 而不是手写 terminate/kill。
- **`@deepseek-ai/dsh-subagent-sdk`**`packages/subagent/subagent-sdk/`)—— 第二个进程外 `SubagentProvider`,以 `subagent-acp` 的同胞结构组织:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样的经 `onError` 汇把结果压平为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未跑回合就尘埃落定的子进程,都是 `error`)。其 `provider`/`model` 配置喂给子进程的 `initialize``env` 是部署传入子进程自有密钥与 `DSH_CORDIS_CONFIG` 的地方。
- **`dsh-subagent-subprocess` 增长出第三个共享关注点**:子进程 cwd 解析(`assertUsableCwd`/`validateConfiguredCwd`/`resolveChildCwd`),在 SDK 后端需要与 `subagent-acp` 完全相同的"配置覆盖、否则父会话 cwd"策略时从后者提取,以前缀参数化诊断信息。
`dsh-jsonrpc` 的服务不变(线上字节完全一致);`dsh-jsonrpc-agent-pkg`Python 运行时闭包)增加 `dsh-sdk-protocol` 一行依赖。
## Testing
四层,依[测试政策](../../../../docs/testing.md)
- **免密钥单元** —— `sdk-client` 通过真实 stdio 驱动脚本化伪运行时(`tests/fake-runtime.ts`,环境变量脚本化、纯协议——即 Python `test_client.py` 的模式);`subagent-sdk` 经真实 provider 驱动同一伪运行时。三个包全部 100% 逐文件覆盖。
- **免密钥 Loader 组合** —— `subagent-sdk/tests/loader-composition.e2e.ts` 启动仅测试用 cordis.yml`examples/jsonrpc-agent/tests/fixtures/subagent/subagent-sdk/`),其中子进程是真实的第二个 harness 运行时、带自己的 cordis.yml断言父工具结果与子进程自己持久化的转录都携带父会话 cwd。子启动经 `resolveExampleLaunch` 解析src/lib 两种模式都成立。
- **免密钥快照** —— `examples/jsonrpc-agent/tests/sdk.snapshot.ts` 是 jsonrpc 示例的第一个快照套件:真实 `dsh-jsonrpc-agent` 运行时经真实 `dsh-sdk-client` 驱动,在新的 `cordis.snapshot.yml` 覆盖层后经 `llm-replay` 回放已录制夹具(经 `DSH_CORDIS_CONFIG` 显式传入jsonrpc bin 自身不做快照配置切换。三个场景——文本回合、bash 工具、spawn 子代理——各自钉住规范化通知流、SDK 回合结果与持久化的父+子日志。这也补上了单文件可执行 Note 的 Python 侧快照在 vitest 侧留下的协议层缺口。
- **带密钥 e2e** —— 快照套件的 `DSH_SNAPSHOT=record` 模式即真实 API 路径(已提交夹具由它产出);组合 e2e 设计上无需密钥。
## Alternatives considered
**从 `dsh-jsonrpc` 导入线类型而不是提取协议包。** 会让每个 SDK 消费者(包括绝不能提供 JSON-RPC 服务的 `subagent-sdk`)依赖服务器插件及其 `dsh-agent`/`dsh-llm-deepseek` peer 集合,且通知载荷仍然匿名。能力接缝规则(接口/实现/消费者三包分立)已经点名了这种形态;这个传输是货真价实的双边物。
**让 `subagent-sdk` 直说裸 JSON-RPC、绕开客户端 SDK。** 会复制 SDK 存在意义所在的请求/通知配对、订阅扇出、超时与拆除逻辑;用户的要求明确是一个*使用* SDK 的后端,分层的回报是后端成为可复用客户端之上约 200 行的纯策略。
**把 SDK 后端折进 `subagent-acp`、用传输开关区分。** 两个后端共享子进程生命周期但线协议ACP SDK 连接 vs harness JSON-RPC、子进程契约任意 ACP 代理 vs harness 运行时)、结果提取(`agent_message_chunk` 累积 vs 会话事件读取)毫无共享。配置判别子会把两个协议埋进一个包;共享部分恰好就是 `subagent-subprocess` 已持有的,于是让那个库生长。
**给 TS SDK 与 Python 对等的捆绑运行时解析。** Python 的载体解析是为了给没有 Node 的用户发 wheel。TypeScript 消费者定义上就有 Node 且(仓库内)有工作区;为不存在的消费者发明发行故事违反"要求当前需求"规则。推迟到真实 npm 发行消费者出现。
**复用 `dsh-acp-snapshot` 的 `runScenario` 做 SDK 快照。** 那个 harness 说 ACP`ClientSideConnection``InputStep` 脚本。SDK 套件的全部意义就是以 *SDK 客户端*为入口表面;它复用 normalize/refresh 库层(`normalizeSessionLog``refreshFixtureReplacements`……),不动 ACP 驱动器。
## Consequences
**买到**SDK 运行时协议现在拥有服务器与两个客户端 SDK 共享的、编译器校验的具名类型TypeScript 消费者获得与 Python 相同的子进程驱动能力且带类型化错误与结构化回合原因subagent 接缝获得一个 harness 原生的进程外后端,其子进程是完整对等体(自有配置、持久化、工具)——正是接缝 Note 预期的递归组合故事jsonrpc 示例终于有了快照覆盖,而且走的就是 SDK 路径本身。
**付出**`sdk/` 组多了第三个包、subagent 多了第四个要保持最新的后端SDK 后端每个子进程启动完整插件树(单次成本高于 ACP 子进程;池化与 ACP 一样留作未来工作线上仍无取消方法SDK 的 `RequestTimeoutError` 与后端的 dispose 都只在本地定格、服务器侧回合继续跑到进程拆除为止;快照夹具录制于 `deepseek-v4-flash`,与其他录制语料一样随模型行为漂移而重录。