From e0f22aeaad680f0f96c49821a39c851e4e969ed7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:51:03 +0800 Subject: [PATCH 001/146] feat(code-runtime-python): add the fd-3 frame protocol Introduce @deepseek-ai/dsh-code-runtime-python with the versionless JSON-lines protocol between the Node host and the CPython subprocess: the host-side hostile-frame codec (validateChildFrame, encodeJsonPlain, checkDoneValue, hasUnsafeIntegerToken, hasNonLosslessNumber, logTruncationMarker) and the Python-side wire-vocabulary mirror (py/protocol.py). This is the protocol layer of the code-runtime-python stack, split from #436 and based on the multi-language seam extension. The PythonCodeRuntime implementation and its Python JSON codec land in the backend-core PR on top of this branch. Ship the minimal buildable package skeleton (package.json, tsconfig, tsdown, barrel index, invariant companion, bilingual README) because the workspace-constraint, coverage, and invariant-topology gates require the package to exist and build the moment its directory does; the backend-core PR extends those files rather than creating them. Align py/protocol.py with src/protocol.ts (the round-12 review of #436 found LogMessage.truncated, DoneMessage.error.kind, and Namespace.errorClass stale) and guard the two runtime-executed surfaces (PROTOCOL_FD and the log truncation marker) with a real-python3 cross-language mirror e2e test. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 6 + ...-07-31-code-runtime-python-fd3-protocol.md | 43 ++ ...-31-code-runtime-python-fd3-protocol.zh.md | 43 ++ docs/config-catalog.md | 1 + docs/module-graph.md | 4 + knip.json | 10 + .../code-runtime-python/README.i18n.yaml | 6 + .../code-runtime-python/README.md | 24 + .../code-runtime-python/README.zh.md | 24 + .../code-runtime-python/package.json | 39 ++ .../code-runtime-python/py/protocol.py | 126 ++++++ .../code-runtime-python/src/index.ts | 20 + .../code-runtime-python/src/invariant.ts | 30 ++ .../code-runtime-python/src/protocol.ts | 420 ++++++++++++++++++ .../tests/protocol-mirror.e2e.ts | 60 +++ .../tests/protocol.spec.ts | 239 ++++++++++ .../code-runtime-python/tsconfig.json | 21 + .../code-runtime-python/tsdown.config.ts | 16 + pnpm-lock.yaml | 12 + scripts/check-workspace-constraints.ts | 2 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 22 files changed, 1148 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md create mode 100644 packages/code-runtime/code-runtime-python/README.i18n.yaml create mode 100644 packages/code-runtime/code-runtime-python/README.md create mode 100644 packages/code-runtime/code-runtime-python/README.zh.md create mode 100644 packages/code-runtime/code-runtime-python/package.json create mode 100644 packages/code-runtime/code-runtime-python/py/protocol.py create mode 100644 packages/code-runtime/code-runtime-python/src/index.ts create mode 100644 packages/code-runtime/code-runtime-python/src/invariant.ts create mode 100644 packages/code-runtime/code-runtime-python/src/protocol.ts create mode 100644 packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts create mode 100644 packages/code-runtime/code-runtime-python/tests/protocol.spec.ts create mode 100644 packages/code-runtime/code-runtime-python/tsconfig.json create mode 100644 packages/code-runtime/code-runtime-python/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml new file mode 100644 index 0000000000..bd811f506e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.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 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: e7cf551b1dc84656c1eaf49280052c732839942b diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md new file mode 100644 index 0000000000..32cc80278a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -0,0 +1,43 @@ +# Agent Note: the code-runtime-python fd-3 frame protocol + +Status: implemented + +English | [中文](2026-07-31-code-runtime-python-fd3-protocol.zh.md) + +## Problem + +The CPython code-runtime backend (`@deepseek-ai/dsh-code-runtime-python`, arriving across a PR stack) runs each model program in a fresh `python3 -I` subprocess and bridges binding calls and completion values over the child's fd 3. That channel needs a wire protocol both sides agree on, and the host cannot trust it: model code has full access to fd 3 and can forge any frame, so every inbound frame is hostile input the host must validate and rebuild before reading. The protocol also has to carry lossless JSON without the depth limit `JSON.stringify`/`json.dumps` impose, because the seam's `CodeJsonValue` is depth-unbounded. + +This layer of the stack delivers only that protocol, so the large `PythonCodeRuntime` implementation and its real-subprocess integration suite land on a reviewed wire contract instead of arriving fused with it. The parent stack splits [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436) — a 9000-line single PR — into reviewable layers; this is the protocol layer, based on the [seam extension](2026-07-31-code-runtime-portable-identifier-seam.md). + +## Decision + +`src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: + +- **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one bounded walk that rejects an over-budget payload BEFORE enqueuing its children, keeping a forged below-frame-ceiling value from forcing a hundreds-of-megabytes host allocation. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. + +`py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. + +The package skeleton (`package.json`, `tsconfig.json`, `tsdown.config.ts`, `src/index.ts`, `src/invariant.ts`, README triplet) ships here rather than in a later stack layer: `check-workspace-constraints` reads every `packages//` package.json unconditionally, and the coverage and invariant-topology gates require the package to exist and build the moment its directory does. The later backend-core PR extends `src/index.ts` with `PythonCodeRuntime` and grows `package.json`'s dependencies; because it bases on this branch, those are edits, not conflicts. + +## Wire contract + +Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames. + +## Mirror alignment + +Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. Because the declarations are `TypedDict`s (no runtime enforcement on the trusted Python side), an automated guard covers only what both sides execute: `tests/protocol-mirror.e2e.ts` spawns a real `python3`, reads `PROTOCOL_FD` and `log_truncation_marker` from `py/protocol.py`, and asserts they equal the TypeScript constants across several byte budgets. + +## Alternatives considered + +**Move the Python JSON codec (`_encode_json_plain` / `_decode_json_plain`) into `py/protocol.py` for cross-side symmetry with `protocol.ts`.** Rejected. The repository's "prefer symmetry for parallel values" rule points at genuinely parallel values; these are not. The host-side codec in `protocol.ts` validates HOSTILE input and is self-contained. The Python codec produces output on the TRUSTED side and is coupled to bootstrap-internal helpers (`_Emit`, `_dump_scalar`/`_dump_string`/`_dump_float`, `LogBuffer`'s cost accounting, `_check_done_value`, `_lossless_json_violation`); lifting only the two entry points would drag that web into `protocol.py` or create a `bootstrap.py` ↔ `protocol.py` import cycle. The real cross-side parallel is "host validates inbound (`protocol.ts`) ↔ child trusts host and emits (`bootstrap.py`)", and that symmetry is preserved: `protocol.py` stays the pure wire-vocabulary mirror it is on the TS side. The Python codec stays in `bootstrap.py`, delivered by the backend-core PR. + +**Defer the package skeleton to the backend-core PR that "owns" package.json.** Rejected: the workspace-constraint, coverage, and invariant-topology gates fail the instant the `code-runtime-python` directory exists without a buildable package. A stacked split cannot create source files in a package that does not yet compile. + +## Consequences + +Bought: the fd-3 protocol and its hostile-input codec land as a self-contained, fully unit-covered layer, and the py/ts mirror drift the round-12 review found is fixed with an executing guard against its recurrence. The backend-core PR builds on a reviewed wire contract. + +Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The `TypedDict` shapes in `py/protocol.py` beyond the two executed surfaces remain guarded by review plus the backend's real-subprocess suite, not by the mirror e2e test — an inherent limit of comparing type declarations across languages. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md new file mode 100644 index 0000000000..e7cf551b1d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -0,0 +1,43 @@ +# Agent Note: the code-runtime-python fd-3 frame protocol + +Status: implemented + +[English](2026-07-31-code-runtime-python-fd3-protocol.md) | 中文 + +## Problem + +CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 + +本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.zh.md)。 + +## Decision + +`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: + +- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 + +`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 + +包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 + +## Wire contract + +帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。 + +## Mirror alignment + +#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 + +## Alternatives considered + +**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 "prefer symmetry for parallel values" 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 "host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)",这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 + +**把包骨架推迟到"拥有" package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 + +## Consequences + +收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 + +代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 13ebcc5b32..5f2b9ac346 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2574,6 +2574,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index a50b658e2a..3884c4b58b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,6 +184,7 @@ flowchart TD end subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] + pkg_code_runtime_python["code-runtime-python"] pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] @@ -333,6 +334,8 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants + pkg_code_runtime_python --> pkg_code_runtime + pkg_code_runtime_python --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -1147,6 +1150,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/knip.json b/knip.json index 6dc4b56dcd..a09e4b37da 100644 --- a/knip.json +++ b/knip.json @@ -360,6 +360,16 @@ "tests/**/*.ts" ] }, + "packages/code-runtime/code-runtime-python": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/llm/llm-deepseek": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml new file mode 100644 index 0000000000..d13849f8b0 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/README.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 packages/code-runtime/code-runtime-python/README.md +README.md: 8a394f18f8e27addf0f4a7530cbdb31b9d629bb9 +README.zh.md: 1c246c952492eb574b6fc6c6bc6c76fffcabe01e diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md new file mode 100644 index 0000000000..8a394f18f8 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-code-runtime-python + +English | [中文](README.zh.md) + +CPython-subprocess implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam. Companion to [`@deepseek-ai/dsh-code-runtime-worker`](../code-runtime-worker/README.md); trades the Node worker thread for a fresh `python3` subprocess so model code is Python instead of TypeScript. + +This package is built up across the code-runtime-python PR stack. This layer ships the wire protocol; the `PythonCodeRuntime` implementation that drives a `python3 -I` process over it lands on top of it. + +## Wire protocol + +The host and the CPython subprocess exchange a versionless, JSON-lines protocol on the child's fd 3 — one JSON object per line, leaving stdout/stderr free for the program's own output. `src/protocol.ts` is the host side; `py/protocol.py` mirrors its message shapes and the shared truncation-marker text on the Python side. + +- **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. +- **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one bounded traversal that rejects an over-budget payload before enqueuing its children; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. + +## Model Experience + +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this backend's exact completion value when it fits (or an explicit `invalid-output` / `output-limit` failure), plus the exact `[dsh-code-runtime-python] log capture truncated at bytes` log marker, into a retained `run_code` result. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md new file mode 100644 index 0000000000..1c246c9524 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-code-runtime-python + +[English](README.md) | 中文 + +[`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 CPython 子进程实现。与 [`@deepseek-ai/dsh-code-runtime-worker`](../code-runtime-worker/README.md) 配套;以全新的 `python3` 子进程取代 Node worker 线程,让模型代码从 TypeScript 换成 Python。 + +本包分多个 code-runtime-python PR 逐层搭建。本层交付 wire protocol;在其之上驱动 `python3 -I` 进程的 `PythonCodeRuntime` 实现随后落地。 + +## Wire protocol + +host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JSON-lines 协议——每行一个 JSON 对象,让 stdout/stderr 空出给程序自己的输出。`src/protocol.ts` 是 host 侧;`py/protocol.py` 在 Python 侧镜像其帧词汇与共享的截断标记文本。 + +- **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 +- **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次有界遍历中同时计量伪造完成值的字节长度与数字无损性,在把子节点入栈之前就拒绝超预算 payload;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 + +## Model Experience + +Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this backend's exact completion value when it fits (or an explicit `invalid-output` / `output-limit` failure), plus the exact `[dsh-code-runtime-python] log capture truncated at bytes` log marker, into a retained `run_code` result. + +#### KV Cache effect + +No direct invalidation; the named consumer owns any request-prefix changes. diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json new file mode 100644 index 0000000000..dc72d0c749 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-code-runtime-python", + "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "py/**/*.py", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-code-runtime": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/code-runtime/code-runtime-python/py/protocol.py b/packages/code-runtime/code-runtime-python/py/protocol.py new file mode 100644 index 0000000000..0445726cac --- /dev/null +++ b/packages/code-runtime/code-runtime-python/py/protocol.py @@ -0,0 +1,126 @@ +"""Wire protocol vocabulary for the Python side of dsh-code-runtime-python. + +Mirrors ``src/protocol.ts``. Frames travel on fd 3 as JSON-lines (one JSON +object per line). The host validates every inbound frame; this side trusts +host replies. +""" + +from __future__ import annotations + +from typing import Any, Literal, TypedDict, Union + +# The protocol fd from the child's perspective. Node passes +# ``stdio: [pipe, pipe, pipe, pipe]`` so the fourth entry (fd 3) is the +# framed-JSON channel; stdout/stderr stay clear for the program's own output. +PROTOCOL_FD = 3 + + +class BootMessage(TypedDict): + """Host → child, first frame on fd 3. Carries every cap and the namespaces.""" + + type: Literal["boot"] + cpuSeconds: int + addressSpaceBytes: int + maxLogBytes: int + maxValueBytes: int + namespaces: list["Namespace"] + + +class ErrorClass(TypedDict): + """A namespace's program-visible exception class: rejected calls raise its + instances carrying the failed member name on ``memberNameProperty``.""" + + name: str + memberNameProperty: str + + +class Namespace(TypedDict, total=False): + """One binding namespace declaration: the global name, its function names, + and an optional program-visible ``errorClass`` for rejected calls.""" + + global_: str # required; renamed on the wire: JSON field is ``global`` (Python keyword collision) + names: list[str] # required + errorClass: ErrorClass # optional — mirrors the TS `errorClass?` + + +class RunMessage(TypedDict): + """Host → child, sent after ``boot-ack``. Carries only the program body.""" + + type: Literal["run"] + program: str + + +class BootAckMessage(TypedDict): + """Child → host: resource limits applied, ready for the run message.""" + + type: Literal["boot-ack"] + + +class CallMessage(TypedDict): + """Child → host: one bridged binding call from the model program.""" + + type: Literal["call"] + id: int + global_: str # wire field is ``global`` + name: str + args: Any + + +class LogMessage(TypedDict, total=False): + """Child → host: one captured text chunk, streamed eagerly. + + ``truncated`` is set only on the frame that IS the child ledger's truncation + marker (not program output), so the host stops capturing at the same point + the child did — mirrors the TS `truncated?`. + """ + + type: Literal["log"] # required + text: str # required + truncated: bool # optional + + +class DoneErrorField(TypedDict): + """Child → host: the failure carried on a ``done`` frame. ``kind`` is one of + the three the host validates; ``message`` is the traceback or diagnostic.""" + + kind: Literal["exception", "invalid-output", "output-limit"] + message: str + + +class DoneMessage(TypedDict, total=False): + """Child → host: the program settled. ``value`` and ``error`` are optional per the TS mirror.""" + + type: Literal["done"] # required — TypedDict(total=False) allows this via a required subclass in Py 3.11+; MVP keeps it flat + value: Any + error: DoneErrorField + + +ChildToHost = Union[BootAckMessage, CallMessage, LogMessage, DoneMessage] + + +class ReplyOk(TypedDict): + type: Literal["reply"] + id: int + ok: Literal[True] + value: Any + + +class ReplyErr(TypedDict): + type: Literal["reply"] + id: int + ok: Literal[False] + message: str + + +ReplyMessage = Union[ReplyOk, ReplyErr] +HostToChild = ReplyMessage + + +def log_truncation_marker(max_bytes: int) -> str: + """Return the in-band marker for a log ledger that exhausted its budget. + + Byte-identical text on both sides of the wire so a truncated run reads the + same however the cap was hit. + """ + + return f"[dsh-code-runtime-python] log capture truncated at {max_bytes} bytes" diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts new file mode 100644 index 0000000000..625576f220 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -0,0 +1,20 @@ +/** + * CPython subprocess code runtime for the DeepSeek Harness code-execution seam. + * + * This layer of the package ships the versionless fd-3 wire protocol between the + * Node host and the CPython subprocess; the `PythonCodeRuntime` implementation + * that drives a `python3 -I` process over it lands on top of this seam. The + * protocol's host-side codec and hostile-frame validators are re-exported so the + * runtime and its tests share one wire vocabulary. + * @module @deepseek-ai/dsh-code-runtime-python + */ + +export type { BootMessage, ChildToHost, ReplyMessage } from './protocol.ts' +export { + checkDoneValue, + encodeJsonPlain, + hasNonLosslessNumber, + hasUnsafeIntegerToken, + logTruncationMarker, + validateChildFrame, +} from './protocol.ts' diff --git a/packages/code-runtime/code-runtime-python/src/invariant.ts b/packages/code-runtime/code-runtime-python/src/invariant.ts new file mode 100644 index 0000000000..48441ad875 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-code-runtime-python`. + * @module @deepseek-ai/dsh-code-runtime-python/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-python' + +/** Cordis companion plugin name. */ +export const name = 'code-runtime-python-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this process-boundary implementation exposes no same-process event relation; + * the fd-3 protocol and real-subprocess integration tests cover it. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts new file mode 100644 index 0000000000..c935a1153a --- /dev/null +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -0,0 +1,420 @@ +/** + * Versionless, JSON-lines wire protocol between the Node host and the CPython subprocess. Frames + * travel on the child's fd 3 (one JSON object per line), leaving stdout/stderr free for the + * program's own output. Host treats every inbound frame as hostile because model code can post + * anything through the same fd; the Python bootstrap trusts host replies. + * @module @deepseek-ai/dsh-code-runtime-python/src/protocol + */ + +// The protocol channel is fd 3 from the child's perspective — the host pins it +// positionally via `stdio: ['pipe','pipe','pipe','pipe']` (index.ts), and the +// Python bootstrap reads the same constant from its own protocol.py. + +/** + * What the host sends immediately after spawn, as the first line on fd 3. The + * Python bootstrap reads this, applies resource limits, then waits for the + * subsequent run frame. Separated from the run so the run message stays + * pure model input. + */ +export interface BootMessage { + type: 'boot' + /** RLIMIT_CPU seconds; the Python bootstrap sets this on itself before executing model code. */ + cpuSeconds: number + /** RLIMIT_AS bytes; caps address space so a runaway allocation fails cleanly. */ + addressSpaceBytes: number + /** Shared byte budget for captured log text (Python-side ledger). */ + maxLogBytes: number + /** Byte cap for the rendered completion value. */ + maxValueBytes: number + /** + * The namespaces to materialize inside the program (globals + names; + * functions stay host-side). `errorClass` asks the bootstrap to mint a + * program-visible exception class under that global: rejected calls raise + * its instances carrying the member name on `memberNameProperty`. + */ + namespaces: { global: string; names: string[]; errorClass?: { name: string; memberNameProperty: string } }[] +} + +// The run request `{ type: 'run', program }` follows BootMessage once the +// child acknowledges with `boot-ack`; the host sends it as an inline literal +// (it carries only the model's program body — caps and bindings crossed on boot). + +/** Python → host: acknowledges boot completed and resource limits are in place. */ +interface BootAckMessage { + type: 'boot-ack' +} + +/** Python → host: one bridged binding call (`await tools.name(args)` inside the program). */ +interface CallMessage { + type: 'call' + /** Python-issued correlation id; the host answers each id at most once and ignores duplicates. */ + id: number + /** The namespace global the call targets. */ + global: string + /** The function name within the namespace. */ + name: string + /** The JSON-safe argument the model program passed. */ + args: unknown +} + +/** + * Python → host: captured text, streamed eagerly so output survives a + * mid-run termination (RLIMIT_CPU, SIGTERM/SIGKILL, host wall-timeout). + */ +interface LogMessage { + type: 'log' + text: string + /** + * Set when this frame IS the child ledger's truncation marker rather than + * program output. The two ledgers can exhaust at different points — one + * child entry larger than `maxLogBytes` sends only the marker while the host + * ledger is still nearly empty — so the host cannot infer the child's state + * from its own budget, and comparing the text against the marker string + * would also honour a program that printed that string itself. Carrying it + * as a field lets the host stop capturing at the same point the child did + * and keeps exactly one marker in `logs`. + */ + truncated?: boolean +} + +/** + * Python → host: the program settled. `error` carries a program exception + * (traceback text), an `invalid-output` (completion value was not lossless + * JSON), or an `output-limit` (serialized completion exceeded the configured + * cap); wall/CPU budgets, aborts, and substrate death are observed host-side. + * `value` is present only on a clean completion that produced one, and crosses + * as exact lossless JSON — never substituted or truncated. + */ +interface DoneMessage { + type: 'done' + value?: unknown + error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } +} + +/** + * Every message the Python side sends. The member interfaces stay module- + * private: consumers match on the union's discriminant; the host sends the + * boot and run frames as inline literals. + */ +export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage + +/** Host → Python: the answer to one {@link CallMessage}. */ +export type ReplyMessage = + | { type: 'reply'; id: number; ok: true; value: unknown } + | { type: 'reply'; id: number; ok: false; message: string } + +/** + * The in-band marker text announcing that log capture stopped at the byte + * budget. Shared wire vocabulary: the Python-side LogBuffer emits it when ITS + * ledger exhausts, and the host emits identical text when its own ledger drops + * a frame first (forged fd-3 traffic, stray stdout bytes) — a truncated run + * reads the same however the cap was hit. + * @param maxBytes - the configured `maxLogBytes` the marker names. + * @returns the marker line. + */ +export function logTruncationMarker(maxBytes: number): string { + return `[dsh-code-runtime-python] log capture truncated at ${maxBytes} bytes` +} + +/** + * Serialize one JSON-parse-produced value without recursion. `JSON.stringify` + * recurses per nesting level and throws `RangeError` a few thousand levels + * deep, but the seam's `CodeJsonValue` has no depth limit — an honest deep + * completion or binding resolution below the byte budget must cross intact + * (the worker backend's wire is equally stack-safe). Callers must pass a value + * produced by `JSON.parse` (or equally JSON-plain): only `null`, finite + * numbers, booleans, strings, dense arrays, and plain objects — this encoder + * validates nothing. Output is byte-identical to compact `JSON.stringify`. + * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). + * @returns the compact JSON encoding. + */ +export function encodeJsonPlain(value: unknown): string { + type Task = { text: string } | { value: unknown } + const chunks: string[] = [] + const tasks: Task[] = [{ value }] + for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) { + if ('text' in task) { + chunks.push(task.text) + continue + } + const current = task.value + if (typeof current === 'string') { + chunks.push(JSON.stringify(current)) + } else if (Array.isArray(current)) { + chunks.push('[') + tasks.push({ text: ']' }) + for (let index = current.length - 1; index >= 0; index--) { + if (index < current.length - 1) tasks.push({ text: ',' }) + tasks.push({ value: current[index] }) + } + } else if (typeof current === 'object' && current !== null) { + const record = current as Record + chunks.push('{') + tasks.push({ text: '}' }) + const keys = Object.keys(record) + for (let index = keys.length - 1; index >= 0; index--) { + const key = keys[index] as string + if (index < keys.length - 1) tasks.push({ text: ',' }) + tasks.push({ value: record[key] }) + tasks.push({ text: `${JSON.stringify(key)}:` }) + } + } else { + chunks.push(scalarJson(current)) + } + } + return chunks.join('') +} + +/** + * One scalar (null, boolean, finite number) as JSON text. A beyond-safe-range + * integral double needs BigInt digits: `String(2 ** 60)` emits the ROUNDED + * `...847000` form, and echoing that to the child would silently change the + * integer the seam promised to carry losslessly — `BigInt(2 ** 60)` prints the + * exact `...846976` the double actually holds. + * @param current - a JSON-plain scalar (JSON.parse emits nothing else). + * @returns its JSON encoding. + */ +function scalarJson(current: unknown): string { + if (typeof current === 'number' && Number.isInteger(current) && !Number.isSafeInteger(current)) { + return BigInt(current).toString() + } + return String(current) +} + +/** + * Meter a forged done value's compact-JSON byte length AND its number + * losslessness in one bounded traversal, stopping the instant `maxBytes` is + * crossed. A forged `done.value` arrives straight off fd 3 and can sit anywhere + * below the 256 MiB frame ceiling while `maxValueBytes` defaults to 32 KiB. The + * previous split — an unbounded `hasNonLosslessNumber` scan in + * {@link validateChildFrame} followed by a separate byte meter — pushed every + * member of a wide flat payload onto a scan stack before any cap check ran, so + * a below-ceiling forgery could still force a hundreds-of-megabytes host + * allocation. Folding both jobs here rejects over-budget BEFORE enqueuing an + * array's or object's children, keeping the traversal O(cap). A non-lossless + * number (non-finite, negative zero) is caught only when the value fits the + * budget — an over-budget value is rejected regardless, so the distinction is + * moot. Same JSON-plain precondition and traversal shape as + * {@link encodeJsonPlain}; per-scalar encoding delegates to `JSON.stringify`. + * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). + * @param maxBytes - the completion-value budget in bytes. + * @returns `{ ok: true, bytes }` with the exact serialized size, or + * `{ ok: false, reason }` — `over-budget` once the size exceeds `maxBytes`, + * `non-lossless` on a non-finite or negative-zero number. + */ +export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } { + let bytes = 0 + const stack: unknown[] = [value] + while (stack.length > 0) { + const current = stack.pop() + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) return { ok: false, reason: 'non-lossless' } + bytes += Buffer.byteLength(scalarJson(current), 'utf8') + } else if (typeof current === 'string') { + // Lower-bound BEFORE materializing the escaped form: every UTF-16 code + // unit is at least one UTF-8 byte plus the two quotes, so a huge or + // control-heavy forged string (whose escaped copy expands severalfold) + // is rejected without allocating that copy. + if (bytes + current.length + 2 > maxBytes) return { ok: false, reason: 'over-budget' } + bytes += Buffer.byteLength(JSON.stringify(current), 'utf8') + } else if (Array.isArray(current)) { + // Brackets plus one comma per gap; elements add themselves. Reject + // BEFORE enqueuing children: every element serializes to at least one + // byte, so a forged flat array below the frame ceiling but far above + // the budget fails here without growing the host stack by millions of + // entries first. + bytes += 2 + (current.length > 1 ? current.length - 1 : 0) + if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' } + for (const item of current) stack.push(item) + } else if (typeof current === 'object' && current !== null) { + const record = current as Record + // Count own keys WITHOUT Object.entries/Object.keys: either would + // allocate one slot (entries: one pair array) per member before the + // bound below could run, recreating the spike the bound exists to stop. + let count = 0 + for (const key in record) if (Object.hasOwn(record, key)) count += 1 + bytes += 2 + (count > 1 ? count - 1 : 0) + // Same pre-enqueue bound: each entry contributes its quoted key (>= 2 + // bytes), the colon, and a >= 1-byte value. + if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } + for (const key in record) { + if (!Object.hasOwn(record, key)) continue + // The same string lower bound, before escaping the key. + if (bytes + key.length + 3 > maxBytes) return { ok: false, reason: 'over-budget' } + bytes += Buffer.byteLength(JSON.stringify(key), 'utf8') + 1 + stack.push(record[key]) + } + } else { + bytes += Buffer.byteLength(scalarJson(current), 'utf8') + } + if (bytes > maxBytes) return { ok: false, reason: 'over-budget' } + } + return { ok: true, bytes } +} + +/** + * Whether a raw JSON line contains an integer token that would lose precision + * as a JavaScript number. `JSON.parse` silently rounds such a token + * (`9007199254740993` becomes `...992`) BEFORE any validation can see it, so + * the check must read the source text; a beyond-safe-range token whose double + * parse round-trips exactly (`2**53`, `2**60`) is lossless and passes. The scan walks the line skipping string literals (a digit run + * inside a string is data, not a number token) and tests every number token + * in plain integer form — no fraction or exponent, which parse as doubles by + * intent. A reviver cannot do this job: the reviver walk recurses per nesting + * level and would reintroduce the depth limit `encodeJsonPlain` removes. + * @param line - the raw UTF-8 text of one JSON-lines frame. + * @returns true when an unsafe integer token is present outside strings. + */ +export function hasUnsafeIntegerToken(line: string): boolean { + for (let index = 0; index < line.length; index++) { + const char = line[index] + if (char === '"') { + // Skip the string literal, honoring backslash escapes. + for (index++; index < line.length; index++) { + if (line[index] === '\\') index++ + else if (line[index] === '"') break + } + continue + } + if (char === '-' || (char !== undefined && char >= '0' && char <= '9')) { + let end = index + 1 + while (end < line.length) { + const c = line[end] as string + if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') end++ + else break + } + const token = line.slice(index, end) + // Beyond the safe range an integer token is still lossless IFF the + // double parse round-trips exactly (2**53 does; 2**53+1 rounds) — the + // canonical boundary accepts every JS-double-exact value, so only a + // genuinely rounding token marks the frame as forged. + if (/^-?\d+$/.test(token)) { + const parsed = Number(token) + // A token that parses to Infinity is trivially lossy; a finite + // beyond-safe-range one is lossy only when the BigInt round-trip + // disagrees. + if (!Number.isFinite(parsed)) return true + if (!Number.isSafeInteger(parsed) && BigInt(token) !== BigInt(parsed)) return true + } + index = end - 1 + } + } + return false +} + +/** + * Lazily yield one plain object's own enumerable property values. A generator + * (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber} + * traverses breadth it cannot bound: those helpers copy the whole member list + * up front, so a wide forged object would cost a second full-breadth + * allocation before a single value is examined. + * @param record - a JSON-parse-produced object. + * @yields each own enumerable property value, in key order. + */ +function* ownValues(record: object): Generator { + for (const key in record) { + if (Object.hasOwn(record, key)) yield (record as Record)[key] + } +} + +/** + * Whether a JSON.parse-produced value contains a number outside lossless + * JSON: non-finite (`1e400` parses to `Infinity`) or negative zero (`-0.0` + * parses to JS `-0`, whose sign bit a re-serialization drops). The honest + * child's validator rejects these before sending, so a frame carrying one is + * forged. + * + * Runs on `call.args`, which — unlike a completion value — has NO seam byte + * cap, so there is no budget to reject a wide payload against the way + * {@link checkDoneValue} does. The traversal therefore holds ONE cursor per + * NESTING LEVEL (an array or {@link ownValues} iterator) instead of one entry + * per member: a forged flat `args` just below the 256 MiB frame ceiling would + * otherwise push tens of millions of stack entries — and `Object.values` would + * copy each object's full breadth — allocating hundreds of megabytes beyond + * what `JSON.parse` already holds. Iterative either way, so a deep frame + * cannot overflow the host stack. + * @param value - a JSON-parse-produced value from an fd-3 frame. + * @returns true when any contained number is non-finite or negative zero. + */ +export function hasNonLosslessNumber(value: unknown): boolean { + const cursors: Iterator[] = [[value].values()] + while (cursors.length > 0) { + // The loop condition guarantees a top cursor. + const cursor = cursors.at(-1) as Iterator + const step = cursor.next() + if (step.done === true) { + cursors.pop() + continue + } + const current = step.value + if (typeof current === 'number') { + if (!Number.isFinite(current) || Object.is(current, -0)) return true + } else if (Array.isArray(current)) { + cursors.push((current as unknown[]).values()) + } else if (typeof current === 'object' && current !== null) { + cursors.push(ownValues(current)) + } + } + return false +} + +/** + * Runtime shape gate for inbound fd-3 traffic. Model code has full access to + * fd 3 and can post anything — `null`, primitives, poisoned fields — so the + * compile-time union means nothing here: every field is validated and REBUILT + * before the host reads it (forged extras never ride along; a non-number id + * can never be echoed into a reply). Junk returns `undefined` and is dropped + * so a throw in the host's `message` handler cannot crash the host process. + * @param raw - one JSON-parsed frame from fd 3. + * @returns the rebuilt frame, or `undefined` to drop it silently. + */ +export function validateChildFrame(raw: unknown): ChildToHost | undefined { + if (typeof raw !== 'object' || raw === null) return undefined + const m = raw as Record + switch (m.type) { + case 'boot-ack': + return { type: 'boot-ack' } + case 'log': + if (typeof m.text !== 'string') return undefined + // Rebuilt, not passed through: a forged `truncated` of any other type + // would reach the host as a truthy value and silence capture for the + // rest of the run. Only the literal `true` counts. + return { type: 'log', text: m.text, ...m.truncated === true ? { truncated: true } : {} } + case 'call': { + // The id must be a finite number: it is echoed verbatim into the reply + // frame, and a forged `1e400` id (Infinity after JSON.parse) would make + // the reply unencodable as strict JSON. + if (typeof m.id !== 'number' || !Number.isFinite(m.id) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined + // A forged frame can omit `args` entirely; rebuilding it as `undefined` + // would invoke the binding with a non-JSON value, bypassing the + // lossless-JSON argument boundary. Any PRESENT value is JSON-plain by + // construction (the frame came from JSON.parse), so presence is the + // whole check. + if (!Object.hasOwn(m, 'args')) return undefined + // JSON.parse yields Infinity for 1e400 and preserves -0; both are + // outside lossless JSON, and the honest child never sends them. + if (hasNonLosslessNumber(m.args)) return undefined + return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args } + } + case 'done': { + // The value passes through untouched here: scanning it for non-lossless + // numbers would push every member of a wide forged payload before any + // byte cap runs. The done handler's bounded `checkDoneValue` folds the + // losslessness check into the metered traversal, rejecting over-budget + // before it enqueues children. + const err = m.error + if (err === undefined) { + return m.value === undefined ? { type: 'done' } : { type: 'done', value: m.value } + } + if (typeof err !== 'object' || err === null) return undefined + const { kind, message } = err as Record + if (typeof message !== 'string') return undefined + if (kind !== 'exception' && kind !== 'invalid-output' && kind !== 'output-limit') return undefined + return m.value === undefined + ? { type: 'done', error: { kind, message } } + : { type: 'done', value: m.value, error: { kind, message } } + } + default: + return undefined + } +} diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts new file mode 100644 index 0000000000..9ec1091286 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -0,0 +1,60 @@ +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import { logTruncationMarker } from '../src/protocol.ts' + +/** + * Cross-language mirror check for the two protocol surfaces the host and the + * CPython subprocess share at runtime, spawning a real `python3` to read them + * from `py/protocol.py`. `src/protocol.ts` and `py/protocol.py` declare the same + * frame vocabulary on two sides of the wire; the only values both sides EXECUTE + * against are `PROTOCOL_FD` (the fd the channel is pinned to) and the log + * truncation marker text (emitted verbatim by whichever ledger exhausts first), + * so a drift there silently corrupts a live run. Self-skips when no `python3` is + * on PATH — CI provides one; the pure-TS `protocol.spec.ts` covers the host + * codec unconditionally. + */ + +const execFileAsync = promisify(execFile) +const pyDir = fileURLToPath(new URL('../py', import.meta.url)) + +async function hasPython3(): Promise { + try { + await execFileAsync('python3', ['--version']) + return true + } catch { + return false + } +} + +const python3Available = await hasPython3() + +describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', () => { + it('agrees on PROTOCOL_FD and the log truncation marker across byte budgets', async () => { + const budgets = [1, 65536, 1048576] + const probe = [ + 'import json, sys', + `sys.path.insert(0, ${JSON.stringify(pyDir)})`, + 'from protocol import PROTOCOL_FD, log_truncation_marker', + `budgets = ${JSON.stringify(budgets)}`, + 'print(json.dumps({', + ' "fd": PROTOCOL_FD,', + ' "markers": [log_truncation_marker(b) for b in budgets],', + '}))', + ].join('\n') + const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const seen = JSON.parse(stdout) as { fd: number; markers: string[] } + // fd 3 is the wire contract, not a tunable: index.ts pins it positionally. + expect(seen.fd).toBe(3) + expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget))) + }) +}) + +it('names the py/ directory that ships with the package', () => { + // The package.json `files` list ships `py/**/*.py`; the mirror test resolves + // the marker source relative to the built package, so the directory must exist + // beside the tests even when python3 is absent from the runner. + expect(existsSync(pyDir)).toBe(true) +}) diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts new file mode 100644 index 0000000000..d3782b6c95 --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { checkDoneValue, encodeJsonPlain, hasNonLosslessNumber, hasUnsafeIntegerToken, logTruncationMarker, validateChildFrame } from '../src/index.ts' + +describe('logTruncationMarker', () => { + it('names the configured byte budget', () => { + expect(logTruncationMarker(65536)).toBe('[dsh-code-runtime-python] log capture truncated at 65536 bytes') + expect(logTruncationMarker(1)).toBe('[dsh-code-runtime-python] log capture truncated at 1 bytes') + }) +}) + +describe('validateChildFrame', () => { + it('rebuilds boot-ack frames without extra fields', () => { + expect(validateChildFrame({ type: 'boot-ack' })).toEqual({ type: 'boot-ack' }) + // Forged extras never ride along. + expect(validateChildFrame({ type: 'boot-ack', extra: 'x' })).toEqual({ type: 'boot-ack' }) + }) + + it('rebuilds log frames when the text field is a string', () => { + expect(validateChildFrame({ type: 'log', text: 'hi' })).toEqual({ type: 'log', text: 'hi' }) + // Non-string text drops. + expect(validateChildFrame({ type: 'log', text: 42 })).toBeUndefined() + expect(validateChildFrame({ type: 'log' })).toBeUndefined() + }) + + it('rebuilds call frames with a numeric id, string global, and string name', () => { + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } })) + .toEqual({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } }) + // A frame with NO args key drops whole: rebuilding it as `undefined` + // would invoke the binding with a non-JSON value, bypassing the + // lossless-JSON argument boundary. Any present value is JSON-plain by + // construction (frames arrive via JSON.parse), so null passes. + expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo' })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null })) + .toEqual({ type: 'call', id: 2, global: 'tools', name: 'echo', args: null }) + // A missing/mistyped required field drops. + expect(validateChildFrame({ type: 'call', id: '1', global: 'tools', name: 'echo' })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: 1, global: 7, name: 'echo' })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools' })).toBeUndefined() + }) + + it('rebuilds done frames with optional value/error', () => { + expect(validateChildFrame({ type: 'done' })).toEqual({ type: 'done' }) + expect(validateChildFrame({ type: 'done', value: 42 })).toEqual({ type: 'done', value: 42 }) + expect(validateChildFrame({ type: 'done', error: { kind: 'exception', message: 'boom' } })) + .toEqual({ type: 'done', error: { kind: 'exception', message: 'boom' } }) + expect(validateChildFrame({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } })) + .toEqual({ type: 'done', error: { kind: 'invalid-output', message: 'lossy' } }) + expect(validateChildFrame({ type: 'done', error: { kind: 'output-limit', message: 'big' } })) + .toEqual({ type: 'done', error: { kind: 'output-limit', message: 'big' } }) + expect(validateChildFrame({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } })) + .toEqual({ type: 'done', value: 1, error: { kind: 'exception', message: 'boom' } }) + // A `value: undefined` field is dropped (JSON never carries it, but a forged + // shape might; the rebuild coalesces to the absent case). + expect(validateChildFrame({ type: 'done', value: undefined })).toEqual({ type: 'done' }) + // A missing or unrecognized kind drops the frame: the child always sends + // one of the three, so anything else is a forgery. + expect(validateChildFrame({ type: 'done', error: { message: 'boom' } })).toBeUndefined() + expect(validateChildFrame({ type: 'done', error: { kind: 'timeout', message: 'x' } })).toBeUndefined() + }) + + it('rejects malformed done frames', () => { + // error must be an object. + expect(validateChildFrame({ type: 'done', error: 'boom' })).toBeUndefined() + expect(validateChildFrame({ type: 'done', error: null })).toBeUndefined() + // error.message must be a string. + expect(validateChildFrame({ type: 'done', error: {} })).toBeUndefined() + expect(validateChildFrame({ type: 'done', error: { message: 42 } })).toBeUndefined() + }) + + it('drops non-object inputs and unknown types silently', () => { + expect(validateChildFrame(null)).toBeUndefined() + expect(validateChildFrame(undefined)).toBeUndefined() + expect(validateChildFrame(42)).toBeUndefined() + expect(validateChildFrame('str')).toBeUndefined() + expect(validateChildFrame({})).toBeUndefined() + expect(validateChildFrame({ type: 'unknown' })).toBeUndefined() + }) + + it('drops CALL frames whose args are non-finite or negative zero', () => { + // JSON.parse turns 1e400 into Infinity and preserves -0; the honest child + // rejects both before sending, so a call frame carrying one is forged. + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: { n: Infinity } })).toBeUndefined() + expect(validateChildFrame({ type: 'call', id: Infinity, global: 'tools', name: 'x', args: null })).toBeUndefined() + // Plain zero and ordinary floats pass. + expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] })) + .toEqual({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] }) + }) + + it('passes DONE values through untouched — losslessness is metered later', () => { + // validateChildFrame no longer scans done.value: an unbounded scan would + // push every member of a wide forged payload before any byte cap ran. The + // done handler's checkDoneValue folds losslessness into the metered walk. + expect(validateChildFrame({ type: 'done', value: Infinity })).toEqual({ type: 'done', value: Infinity }) + expect(validateChildFrame({ type: 'done', value: [{ x: -0 }] })).toEqual({ type: 'done', value: [{ x: -0 }] }) + expect(validateChildFrame({ type: 'done', value: [0, 1.5] })).toEqual({ type: 'done', value: [0, 1.5] }) + }) +}) + +describe('lossless-number scan', () => { + it('finds non-finite and negative-zero numbers at any depth, iteratively', () => { + expect(hasNonLosslessNumber(Infinity)).toBe(true) + expect(hasNonLosslessNumber(-Infinity)).toBe(true) + expect(hasNonLosslessNumber(NaN)).toBe(true) + expect(hasNonLosslessNumber(-0)).toBe(true) + expect(hasNonLosslessNumber({ a: [1, { b: -0 }] })).toBe(true) + expect(hasNonLosslessNumber({ a: [0, 1.5, 'x', null, true] })).toBe(false) + // Deep nesting must not overflow the stack. + let deep: unknown = 0 + for (let i = 0; i < 100000; i++) deep = [deep] + expect(hasNonLosslessNumber(deep)).toBe(false) + }) + + it('walks wide arrays and objects one member at a time', () => { + // `call.args` carries no seam byte cap, so a wide forged payload has no + // budget to be rejected against — the walk must hold one cursor per + // NESTING LEVEL, not one entry per member, or a flat payload just below + // the 256 MiB frame ceiling would allocate tens of millions of stack + // entries (and `Object.values` a second full-breadth copy). Observable + // through the boundary: a wide payload whose per-member cost the old shape + // would have paid still scans, and a violation ANYWHERE in it is found + // wherever it sits. + const wideArray = new Array(2_000_000).fill(0) as unknown[] + expect(hasNonLosslessNumber(wideArray)).toBe(false) + // Last element, so the cursor must run the whole breadth lazily. + wideArray[wideArray.length - 1] = -0 + expect(hasNonLosslessNumber(wideArray)).toBe(true) + const wideObject: Record = {} + for (let i = 0; i < 200_000; i++) wideObject[`k${i}`] = i + expect(hasNonLosslessNumber(wideObject)).toBe(false) + wideObject.last = Infinity + expect(hasNonLosslessNumber(wideObject)).toBe(true) + // Interleaved nesting: a per-level cursor must resume its parent after a + // child level ends, so a violation after a nested container is still seen. + expect(hasNonLosslessNumber([[1], { a: 2 }, NaN])).toBe(true) + }) + + it('scans only own enumerable properties', () => { + // The per-level cursor filters own keys (a prototype-carrying frame is + // impossible off JSON.parse, but the filter is what keeps the walk equal + // to what the encoder would serialize). + const withProto = Object.create({ inherited: -0 }) as Record + withProto.own = 1 + expect(hasNonLosslessNumber(withProto)).toBe(false) + }) +}) + +describe('unsafe-integer token scan', () => { + it('flags integer tokens outside the safe range, skipping strings and float forms', () => { + expect(hasUnsafeIntegerToken('{"v":9007199254740993}')).toBe(true) + // Exact beyond-safe-range tokens are lossless and pass (2**53, 2**64). + expect(hasUnsafeIntegerToken('{"v":9007199254740992}')).toBe(false) + expect(hasUnsafeIntegerToken('{"v":18446744073709551616}')).toBe(false) + // A token that parses to Infinity is trivially lossy. + expect(hasUnsafeIntegerToken(`{"v":${'9'.repeat(400)}}`)).toBe(true) + expect(hasUnsafeIntegerToken('{"v":-9007199254740993}')).toBe(true) + expect(hasUnsafeIntegerToken('{"v":9007199254740991}')).toBe(false) + expect(hasUnsafeIntegerToken('{"v":"9007199254740993"}')).toBe(false) + expect(hasUnsafeIntegerToken(String.raw`{"v":"esc\"9007199254740993"}`)).toBe(false) + expect(hasUnsafeIntegerToken('{"v":9007199254740993.0}')).toBe(false) + expect(hasUnsafeIntegerToken('{"v":9e99}')).toBe(false) + }) +}) + +describe('checkDoneValue', () => { + it('matches the exact encoded size and rejects one byte over', () => { + const cases: unknown[] = [null, true, false, 0, -1.5, 'a"b\\', [], {}, [1, 'x', null], { a: [1, 2], b: { c: 'd' } }] + for (const value of cases) { + const exact = Buffer.byteLength(JSON.stringify(value), 'utf8') + expect(checkDoneValue(value, exact), JSON.stringify(value)).toEqual({ ok: true, bytes: exact }) + expect(checkDoneValue(value, exact - 1), JSON.stringify(value)).toEqual({ ok: false, reason: 'over-budget' }) + expect(encodeJsonPlain(value)).toBe(JSON.stringify(value)) + } + }) + + it('stops early on a huge value instead of measuring it whole', () => { + const huge = { data: 'x'.repeat(1_000_000), tail: 'y' } + expect(checkDoneValue(huge, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // A forged flat array below the frame ceiling must fail BEFORE its + // elements are enqueued — the pre-enqueue bound keeps the walk O(cap). + const flat = new Array(10_000_000).fill(0) + expect(checkDoneValue(flat, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // Same bound for a wide object: braces+commas fit the cap, but the + // per-entry lower bound (quoted key + colon + value) does not, so it fails + // before any key is metered or any value enqueued. + const wide: Record = {} + for (let i = 0; i < 10; i++) wide[`k${i}`] = i + expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) + }) + + it('rejects an over-budget string on its length before escaping it', () => { + // A control-heavy forged string escapes to ~6x its length; the walk must + // refuse it on the cheap `length + 2` lower bound so the escaped copy is + // never allocated. Observable through the boundary: a string whose LENGTH + // already exceeds the cap fails even though every character is 1 byte. + expect(checkDoneValue(''.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // The bound is a lower bound, never a false rejection: a string that fits + // exactly still passes with its exact escaped size. + expect(checkDoneValue('', 8)).toEqual({ ok: true, bytes: 8 }) + expect(checkDoneValue('', 7)).toEqual({ ok: false, reason: 'over-budget' }) + // Same lower bound for keys, checked before the key is escaped. + expect(checkDoneValue({ [''.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + }) + + it('meters only own enumerable keys', () => { + // The walk counts keys with a `for...in` + hasOwn pass rather than + // Object.keys/entries (which allocate per member before the bound). A + // prototype-carrying forgery is impossible off JSON.parse, but the own-key + // filter is what keeps the count equal to the encoder's. + const withProto = Object.create({ inherited: 'x' }) as Record + withProto.own = 1 + expect(checkDoneValue(withProto, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength('{"own":1}', 'utf8') }) + }) + + it('rejects non-finite and negative-zero numbers at any depth as non-lossless', () => { + expect(checkDoneValue(Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue(-Infinity, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue(NaN, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue(-0, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + expect(checkDoneValue({ a: [1, { b: -0 }] }, 1024)).toEqual({ ok: false, reason: 'non-lossless' }) + // An ordinary finite value within budget passes with its exact byte count. + const clean = { a: [0, 1.5, 'x', null, true] } + expect(checkDoneValue(clean, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength(JSON.stringify(clean), 'utf8') }) + }) + + it('meters deep nesting iteratively without overflowing the stack', () => { + let deep: unknown = 0 + for (let i = 0; i < 100_000; i++) deep = [deep] + // 100000 '[' + '0' + 100000 ']' = 200001 bytes. + expect(checkDoneValue(deep, 1_000_000)).toEqual({ ok: true, bytes: 200_001 }) + }) + + it('emits exact digits for beyond-safe integral doubles', () => { + // String(2**60) prints the ROUNDED ...847000; echoing that to the child + // would change the integer. BigInt digits give the exact ...846976. + const v = JSON.parse('[1152921504606846976]') as unknown + expect(encodeJsonPlain(v)).toBe('[1152921504606846976]') + expect(checkDoneValue(v, 100)).toEqual({ ok: true, bytes: Buffer.byteLength('[1152921504606846976]', 'utf8') }) + }) +}) diff --git a/packages/code-runtime/code-runtime-python/tsconfig.json b/packages/code-runtime/code-runtime-python/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/code-runtime/code-runtime-python/tsdown.config.ts b/packages/code-runtime/code-runtime-python/tsdown.config.ts new file mode 100644 index 0000000000..df5bdeae1e --- /dev/null +++ b/packages/code-runtime/code-runtime-python/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown' + +/** + * Single ESM bundle. The Python-side code is not TypeScript and ships verbatim + * under `py/` (whitelisted in package.json `files`) — no build step needed. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f34b3587b..072d5674a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2337,6 +2337,18 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/code-runtime/code-runtime-python: + devDependencies: + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../code-runtime + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/code-runtime/code-runtime-worker: dependencies: schemastery: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e0b9344cdf..0b0069ffde 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -107,6 +107,8 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], + // The CPython bootstrap ships as source .py files the host spawns by path. + '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 316a4233de..e873b3df78 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, 'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to Code Mode in dsh-tools.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 5772905a9e..8962016791 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -154,6 +154,7 @@ { "path": "./packages/pty/tool-bash-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, + { "path": "./packages/code-runtime/code-runtime-python" }, { "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/llm/llm-deepseek" }, { "path": "./packages/llm/llm-pi-ai" }, From 034e4f2d3f96d4f2e3a2cf338cbd5bc0aef4cc9b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:03:18 +0800 Subject: [PATCH 002/146] fix(code-runtime): match DUNDER_MEMBER to its distinct-pair contract The seam's dunder-member test (added in the base seam PR) asserts `DUNDER_MEMBER.test('____')` is true and `test('__')` is false, but the regex `/^__.+__$/` rejected `____`: the `.+` demanded a non-empty middle, while `____` is two adjacent `__` pairs with an empty middle. Widen to `/^__.*__$/` so a name with distinct leading and trailing `__` pairs matches whether or not it has a middle, and align the JSDoc. Regenerate the cordis service catalog for the merged seam source line. --- docs/cordis-catalog/services.md | 2 +- packages/code-runtime/code-runtime/src/index.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b9a0bc965a..36bbd7e3d4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:104`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:105`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 3428b5c0e4..1f4c1ad287 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -59,10 +59,11 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ ]) /** - * Dunder form (`__x__`, non-empty middle): object-protocol slots in Python, + * Dunder form (`__x__`, distinct leading and trailing `__` pairs, so at least + * four characters; the middle may be empty): object-protocol slots in Python, * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ -export const DUNDER_MEMBER = /^__.+__$/ +export const DUNDER_MEMBER = /^__.*__$/ /** * Reserved words of every portable target language (ECMAScript ∪ Python), From b3e29e7af55da7603f78a5c9718b0dc15a6f23f5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:11:27 +0800 Subject: [PATCH 003/146] fix(code-runtime-python): satisfy static gates for the protocol-only layer - Drop the unused @deepseek-ai/dsh-code-runtime dependency: this layer imports nothing from the seam (protocol.ts has no imports; the invariant companion uses only cordis and dsh-invariants). The backend-core PR re-adds it when PythonCodeRuntime consumes the seam. Fixes knip. - Point the Agent Note's cross-reference to the seam note at the English target on both language sides, per the bilingual-pairing contract (only the language switcher flips to .zh.md). Re-record the sidecar. - Add the Known Limitations section both READMEs require, covering the cross-language guard's scope and the deferred runtime implementation. - Regenerate the module graph for the dropped dependency edge. --- .../2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml | 2 +- .../2026-07-31-code-runtime-python-fd3-protocol.zh.md | 2 +- docs/module-graph.md | 4 ++-- packages/code-runtime/code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 5 +++++ packages/code-runtime/code-runtime-python/README.zh.md | 5 +++++ packages/code-runtime/code-runtime-python/package.json | 2 -- pnpm-lock.yaml | 3 --- 8 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index bd811f506e..b67d7e9349 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md 2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: e7cf551b1dc84656c1eaf49280052c732839942b +2026-07-31-code-runtime-python-fd3-protocol.zh.md: ea8df78826dabf64c0132dc61952c533481e1444 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index e7cf551b1d..ea8df78826 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -8,7 +8,7 @@ Status: implemented CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 -本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.zh.md)。 +本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。 ## Decision diff --git a/docs/module-graph.md b/docs/module-graph.md index 3884c4b58b..1706f229b7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -302,6 +302,7 @@ flowchart TD pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants + pkg_code_runtime_python --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants @@ -334,8 +335,6 @@ flowchart TD pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants - pkg_code_runtime_python --> pkg_code_runtime - pkg_code_runtime_python --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_frontend_static --> pkg_host_webserver @@ -1135,6 +1134,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index d13849f8b0..100b2b8f9f 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/code-runtime/code-runtime-python/README.md -README.md: 8a394f18f8e27addf0f4a7530cbdb31b9d629bb9 -README.zh.md: 1c246c952492eb574b6fc6c6bc6c76fffcabe01e +README.md: f68a45a5420469555eeaf9f88463fbda547d1a1d +README.zh.md: 439d4ef87a26202cc6b537651fa0b768c28637a4 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 8a394f18f8..f68a45a542 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -22,3 +22,8 @@ Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), whic #### KV Cache effect No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **The cross-language guard covers only the two runtime-executed surfaces** — `PROTOCOL_FD` and the log truncation marker. The `TypedDict` frame shapes in `py/protocol.py` mirror `src/protocol.ts` by review, not by an automated check: comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a future shape drift is caught by review plus the backend's real-subprocess suite rather than this package's tests. +- **The `PythonCodeRuntime` implementation and its Python-side JSON codec are not in this layer** — they ship in the backend-core PR on top of this branch; `src/index.ts` re-exports only the protocol vocabulary until then. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 1c246c9524..439d4ef87a 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -22,3 +22,8 @@ Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), whic #### KV Cache effect No direct invalidation; the named consumer owns any request-prefix changes. + +## Known Limitations and Deferred Work + +- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 +- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index dc72d0c749..c94beb2997 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -27,12 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-code-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 072d5674a3..915d150584 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2339,9 +2339,6 @@ importers: packages/code-runtime/code-runtime-python: devDependencies: - '@deepseek-ai/dsh-code-runtime': - specifier: workspace:^ - version: link:../code-runtime '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From f0d669883fb4a498927c7a4c923721ecb50be22c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:20:28 +0800 Subject: [PATCH 004/146] fix(code-runtime-python): close coverage gap and tighten the wire mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cover the log-frame `truncated` rebuild branch: assert a literal-true flag rides along and any other value (1, string, false) is dropped, closing the protocol.ts branch the coverage gate flagged. - Correct encodeJsonPlain's JSDoc: it matches compact JSON.stringify EXCEPT on a beyond-safe-range integral double, where it emits the exact BigInt digits (`...846976`) rather than the rounded `...847000` — the divergence the "emits exact digits" test pins. - Declare py/protocol.py's `global`-bearing frames (Namespace, CallMessage) with functional TypedDict syntax so they carry the real wire key instead of a `global_` attribute the wire never sends, and split optional-field messages (Namespace/LogMessage/DoneMessage) into a required base plus a total=False subclass so `type` and other required fields cannot be dropped. Widen HostToChild to include the boot and run frames the host sends before replies. - Reword the mirror e2e's py/ directory assertion to describe the source-tree layout it actually checks. --- .../code-runtime-python/py/protocol.py | 76 +++++++++++-------- .../code-runtime-python/src/protocol.ts | 6 +- .../tests/protocol-mirror.e2e.ts | 7 +- .../tests/protocol.spec.ts | 12 +++ 4 files changed, 65 insertions(+), 36 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/py/protocol.py b/packages/code-runtime/code-runtime-python/py/protocol.py index 0445726cac..e227cd7c53 100644 --- a/packages/code-runtime/code-runtime-python/py/protocol.py +++ b/packages/code-runtime/code-runtime-python/py/protocol.py @@ -3,6 +3,13 @@ Mirrors ``src/protocol.ts``. Frames travel on fd 3 as JSON-lines (one JSON object per line). The host validates every inbound frame; this side trusts host replies. + +The wire uses the JSON key ``global`` (a Python keyword), so the frame +``TypedDict``s that carry it are declared with the functional syntax rather than +class bodies: a class attribute cannot be named ``global``, and a ``global_`` +attribute would describe a key the wire never sends. Optional-field messages +pair a required base with a ``total=False`` subclass so a required field such as +``type`` cannot be dropped while ``value``/``error``/``truncated`` stay optional. """ from __future__ import annotations @@ -15,17 +22,6 @@ from typing import Any, Literal, TypedDict, Union PROTOCOL_FD = 3 -class BootMessage(TypedDict): - """Host → child, first frame on fd 3. Carries every cap and the namespaces.""" - - type: Literal["boot"] - cpuSeconds: int - addressSpaceBytes: int - maxLogBytes: int - maxValueBytes: int - namespaces: list["Namespace"] - - class ErrorClass(TypedDict): """A namespace's program-visible exception class: rejected calls raise its instances carrying the failed member name on ``memberNameProperty``.""" @@ -34,13 +30,27 @@ class ErrorClass(TypedDict): memberNameProperty: str -class Namespace(TypedDict, total=False): - """One binding namespace declaration: the global name, its function names, - and an optional program-visible ``errorClass`` for rejected calls.""" +# ``global`` is a Python keyword, so the required part is declared functionally +# to hold the real wire key; ``errorClass`` is optional per the TS `errorClass?`. +_NamespaceRequired = TypedDict("_NamespaceRequired", {"global": str, "names": "list[str]"}) - global_: str # required; renamed on the wire: JSON field is ``global`` (Python keyword collision) - names: list[str] # required - errorClass: ErrorClass # optional — mirrors the TS `errorClass?` + +class Namespace(_NamespaceRequired, total=False): + """One binding namespace declaration: the ``global`` name, its function + ``names``, and an optional program-visible ``errorClass`` for rejected calls.""" + + errorClass: ErrorClass + + +class BootMessage(TypedDict): + """Host → child, first frame on fd 3. Carries every cap and the namespaces.""" + + type: Literal["boot"] + cpuSeconds: int + addressSpaceBytes: int + maxLogBytes: int + maxValueBytes: int + namespaces: "list[Namespace]" class RunMessage(TypedDict): @@ -56,17 +66,17 @@ class BootAckMessage(TypedDict): type: Literal["boot-ack"] -class CallMessage(TypedDict): - """Child → host: one bridged binding call from the model program.""" - - type: Literal["call"] - id: int - global_: str # wire field is ``global`` - name: str - args: Any +# ``global`` wire key: whole message declared functionally, all fields required. +CallMessage = TypedDict( + "CallMessage", + {"type": Literal["call"], "id": int, "global": str, "name": str, "args": Any}, +) -class LogMessage(TypedDict, total=False): +_LogMessageRequired = TypedDict("_LogMessageRequired", {"type": Literal["log"], "text": str}) + + +class LogMessage(_LogMessageRequired, total=False): """Child → host: one captured text chunk, streamed eagerly. ``truncated`` is set only on the frame that IS the child ledger's truncation @@ -74,9 +84,7 @@ class LogMessage(TypedDict, total=False): the child did — mirrors the TS `truncated?`. """ - type: Literal["log"] # required - text: str # required - truncated: bool # optional + truncated: bool class DoneErrorField(TypedDict): @@ -87,10 +95,12 @@ class DoneErrorField(TypedDict): message: str -class DoneMessage(TypedDict, total=False): +_DoneMessageRequired = TypedDict("_DoneMessageRequired", {"type": Literal["done"]}) + + +class DoneMessage(_DoneMessageRequired, total=False): """Child → host: the program settled. ``value`` and ``error`` are optional per the TS mirror.""" - type: Literal["done"] # required — TypedDict(total=False) allows this via a required subclass in Py 3.11+; MVP keeps it flat value: Any error: DoneErrorField @@ -113,7 +123,9 @@ class ReplyErr(TypedDict): ReplyMessage = Union[ReplyOk, ReplyErr] -HostToChild = ReplyMessage +# The host sends ``boot`` and ``run`` before any ``reply``, so the child-facing +# inbound union covers all three, not replies alone. +HostToChild = Union[BootMessage, RunMessage, ReplyMessage] def log_truncation_marker(max_bytes: int) -> str: diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index c935a1153a..d73f92bdd6 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -124,7 +124,11 @@ export function logTruncationMarker(maxBytes: number): string { * (the worker backend's wire is equally stack-safe). Callers must pass a value * produced by `JSON.parse` (or equally JSON-plain): only `null`, finite * numbers, booleans, strings, dense arrays, and plain objects — this encoder - * validates nothing. Output is byte-identical to compact `JSON.stringify`. + * validates nothing. Output matches compact `JSON.stringify` byte for byte + * EXCEPT on an integral double beyond the safe range, where {@link scalarJson} + * emits the exact integer's BigInt digits rather than `JSON.stringify`'s rounded + * spelling (`1152921504606846976`, not `...847000`) so the seam's lossless-JSON + * promise holds across the wire. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). * @returns the compact JSON encoding. */ diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 9ec1091286..d79a659c09 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -53,8 +53,9 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', }) it('names the py/ directory that ships with the package', () => { - // The package.json `files` list ships `py/**/*.py`; the mirror test resolves - // the marker source relative to the built package, so the directory must exist - // beside the tests even when python3 is absent from the runner. + // Resolves py/ relative to this test file; the same directory ships in the + // package.json `files` whitelist (`py/**/*.py`). The tests/ directory itself + // is not published — this asserts the source-tree layout the mirror test + // depends on, so it holds even when python3 is absent from the runner. expect(existsSync(pyDir)).toBe(true) }) diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index d3782b6c95..89ad14eae6 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -22,6 +22,18 @@ describe('validateChildFrame', () => { expect(validateChildFrame({ type: 'log' })).toBeUndefined() }) + it('carries a log frame truncation flag only for the literal true', () => { + // The child's own ledger marker sets `truncated: true`; the host rebuilds + // it so it stops capturing at the same point. + expect(validateChildFrame({ type: 'log', text: 'x', truncated: true })) + .toEqual({ type: 'log', text: 'x', truncated: true }) + // Any other truthy or non-boolean value is a forgery and is dropped from + // the rebuild — otherwise it would silence capture for the rest of the run. + expect(validateChildFrame({ type: 'log', text: 'x', truncated: 1 })).toEqual({ type: 'log', text: 'x' }) + expect(validateChildFrame({ type: 'log', text: 'x', truncated: 'yes' })).toEqual({ type: 'log', text: 'x' }) + expect(validateChildFrame({ type: 'log', text: 'x', truncated: false })).toEqual({ type: 'log', text: 'x' }) + }) + it('rebuilds call frames with a numeric id, string global, and string name', () => { expect(validateChildFrame({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } })) .toEqual({ type: 'call', id: 1, global: 'tools', name: 'echo', args: { x: 1 } }) From 98ebe1315d5a264e30989c845373d961dc7e7495 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 19:22:50 +0800 Subject: [PATCH 005/146] fix(code-runtime-python): reject -0 call ids and document done value/error - Drop a CALL frame whose id is negative zero: it passes Number.isFinite but the reply re-serializes it as `0`, colliding with a real call id `0`. The honest child never issues `-0`. - Document that validateChildFrame preserves a forged done frame's value and error together on purpose, so consumers must check error before value. --- .../code-runtime-python/src/protocol.ts | 14 ++++++++++---- .../code-runtime-python/tests/protocol.spec.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index d73f92bdd6..29c790eaec 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -82,8 +82,11 @@ interface LogMessage { * (traceback text), an `invalid-output` (completion value was not lossless * JSON), or an `output-limit` (serialized completion exceeded the configured * cap); wall/CPU budgets, aborts, and substrate death are observed host-side. - * `value` is present only on a clean completion that produced one, and crosses - * as exact lossless JSON — never substituted or truncated. + * From the honest child `value` is present only on a clean completion that + * produced one, and crosses as exact lossless JSON — never substituted or + * truncated. A forged frame CAN carry both `value` and `error`; + * {@link validateChildFrame} preserves both rather than guessing which to drop, + * so a consumer MUST check `error` first and ignore `value` when it is set. */ interface DoneMessage { type: 'done' @@ -387,8 +390,11 @@ export function validateChildFrame(raw: unknown): ChildToHost | undefined { case 'call': { // The id must be a finite number: it is echoed verbatim into the reply // frame, and a forged `1e400` id (Infinity after JSON.parse) would make - // the reply unencodable as strict JSON. - if (typeof m.id !== 'number' || !Number.isFinite(m.id) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined + // the reply unencodable as strict JSON. Negative zero is rejected too: + // it passes `Number.isFinite`, but the reply re-serializes it as `0` + // (`JSON.stringify({id:-0})` is `{"id":0}`), colliding with a real call + // whose id is `0` — the honest child never issues `-0`. + if (typeof m.id !== 'number' || !Number.isFinite(m.id) || Object.is(m.id, -0) || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined // A forged frame can omit `args` entirely; rebuilding it as `undefined` // would invoke the binding with a non-JSON value, bypassing the // lossless-JSON argument boundary. Any PRESENT value is JSON-plain by diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 89ad14eae6..dc0a01d47d 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -98,6 +98,16 @@ describe('validateChildFrame', () => { .toEqual({ type: 'call', id: 1, global: 'tools', name: 'x', args: [0, 1.5] }) }) + it('drops a CALL frame whose id is negative zero', () => { + // `-0` passes Number.isFinite, but the reply re-serializes it as `0` + // (JSON.stringify({id:-0}) === '{"id":0}'), so a forged `-0` id would + // collide with a real call whose id is `0`. The honest child never sends it. + expect(validateChildFrame({ type: 'call', id: -0, global: 'tools', name: 'x', args: null })).toBeUndefined() + // Plain positive zero is a legitimate id and passes. + expect(validateChildFrame({ type: 'call', id: 0, global: 'tools', name: 'x', args: null })) + .toEqual({ type: 'call', id: 0, global: 'tools', name: 'x', args: null }) + }) + it('passes DONE values through untouched — losslessness is metered later', () => { // validateChildFrame no longer scans done.value: an unbounded scan would // push every member of a wide forged payload before any byte cap ran. The From 8a77f201f2e5f27a4da880d77a2b9c26f3d10f03 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 22:56:28 +0800 Subject: [PATCH 006/146] fix(code-runtime): keep the DUNDER_MEMBER fix line-neutral in the seam The base seam branch still carries the buggy /^__.+__$/ (rejects `____`, which its own reserved.spec asserts must match), so this stacked branch must keep the /^__.*__$/ correction to stay green. Reword the JSDoc to the same line count as the base so the CodeRuntime class does not shift, leaving the cordis services catalog anchor identical to the base and confining this branch's footprint on the seam file to the single regex character. --- docs/cordis-catalog/services.md | 2 +- packages/code-runtime/code-runtime/src/index.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 36bbd7e3d4..b9a0bc965a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md) -Source: [`packages/code-runtime/code-runtime/src/index.ts:105`](../../packages/code-runtime/code-runtime/src/index.ts) +Source: [`packages/code-runtime/code-runtime/src/index.ts:104`](../../packages/code-runtime/code-runtime/src/index.ts) ## `ctx.commands` — `CommandService` diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 1f4c1ad287..3555dbfa23 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -59,9 +59,8 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ ]) /** - * Dunder form (`__x__`, distinct leading and trailing `__` pairs, so at least - * four characters; the middle may be empty): object-protocol slots in Python, - * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. + * Dunder form (`__…__`, two `__` pairs with an optionally empty middle): object-protocol + * slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ export const DUNDER_MEMBER = /^__.*__$/ From 31506dec2dcf17b23582abc5c76e43a7ff6379ac Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:04:33 +0800 Subject: [PATCH 007/146] fix(code-runtime-python): correct Chinese translation quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate README.zh.md's Model Experience body and KV Cache line, which were left verbatim in English. - Convert half-width punctuation to full-width across the README.zh.md Known Limitations bullets and the entire Agent Note Chinese side, per docs/i18n translation-rules.md Typography (MUST use ,。:()in Chinese prose). - Re-record both README and Agent Note i18n.yaml pairing hashes. - Reword the workspace-constraints extra-files comment: this layer's py/ ships only the wire-protocol mirror; the spawned bootstrap arrives later. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 26 +++++++++---------- .../code-runtime-python/README.i18n.yaml | 2 +- .../code-runtime-python/README.zh.md | 8 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index b67d7e9349..9ca001afdc 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md 2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: ea8df78826dabf64c0132dc61952c533481e1444 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 24bb9dbb7b8df03c5c82c551449f49b4d306f248 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index ea8df78826..24bb9dbb7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -6,38 +6,38 @@ Status: implemented ## Problem -CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 +CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 PR 落地)在一个全新的 `python3 -I` 子进程里运行每个模型程序,并把 binding 调用和完成值通过子进程的 fd 3 桥接。这条通道需要两侧一致的 wire protocol,而 host 不能信任它:模型代码对 fd 3 有完全访问权、可以伪造任意帧,所以每个入站帧都是 host 必须先校验并重建才能读取的敌意输入。协议还必须承载无深度限制的 lossless JSON,因为 seam 的 `CodeJsonValue` 深度无界,而 `JSON.stringify`/`json.dumps` 都有递归深度限制。 -本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。 +本层只交付这个协议,使得庞大的 `PythonCodeRuntime` 实现及其真子进程集成测试能落在一个已 review 的 wire contract 之上,而不是与它揉在一起到达。父 stack 把 [#436](https://github.com/deepseek-harness/deepseek-harness/pull/436)——一个 9000 行的单一 PR——拆成可 review 的层;本 PR 是协议层,base 是 [seam 扩展](2026-07-31-code-runtime-portable-identifier-seam.md)。 ## Decision -`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: +`src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: -- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 -`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 +`py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 -包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 +包骨架(`package.json`、`tsconfig.json`、`tsdown.config.ts`、`src/index.ts`、`src/invariant.ts`、README 三件套)在此交付,而非放到后续 stack 层:`check-workspace-constraints` 无条件读取每个 `packages//` 的 package.json,coverage 与 invariant-topology gate 也要求包在其目录出现的那一刻即存在且可构建。后续的 backend-core PR 会用 `PythonCodeRuntime` 扩展 `src/index.ts` 并增补 `package.json` 的依赖;因为它 base 在本分支上,那些是编辑,不是冲突。 ## Wire contract -帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。 +帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。 ## Mirror alignment -#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 +#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 ## Alternatives considered -**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 "prefer symmetry for parallel values" 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 "host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)",这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 +**把 Python JSON codec(`_encode_json_plain` / `_decode_json_plain`)挪进 `py/protocol.py` 以与 `protocol.ts` 跨侧对称。** 拒绝。仓库的 “prefer symmetry for parallel values” 规则指向真正平行的值;这两者不是。`protocol.ts` 里的 host 侧 codec 校验的是敌意输入,自包含。Python codec 在受信任侧产出输出,且耦合于 bootstrap 内部 helper(`_Emit`、`_dump_scalar`/`_dump_string`/`_dump_float`、`LogBuffer` 的成本核算、`_check_done_value`、`_lossless_json_violation`);只把两个入口挪过去会把这一整片拖进 `protocol.py`,或制造 `bootstrap.py` ↔ `protocol.py` 的 import 环。真正的跨侧平行是 “host 校验入站(`protocol.ts`) ↔ child 信任 host 并发出(`bootstrap.py`)”,这个对称性被保留:`protocol.py` 保持它在 TS 侧一样的纯 wire-vocabulary 镜像定位。Python codec 留在 `bootstrap.py`,由 backend-core PR 交付。 -**把包骨架推迟到"拥有" package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 +**把包骨架推迟到“拥有” package.json 的 backend-core PR。** 拒绝:workspace-constraint、coverage、invariant-topology gate 会在 `code-runtime-python` 目录一存在而包不可构建时立即失败。stacked 拆分无法在一个尚不能编译的包里创建源文件。 ## Consequences -收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 +收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 -代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 +代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 100b2b8f9f..158140a4cb 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-python/README.md README.md: f68a45a5420469555eeaf9f88463fbda547d1a1d -README.zh.md: 439d4ef87a26202cc6b537651fa0b768c28637a4 +README.zh.md: fe7927e8f8c488ed6a7e6b9e5cdc76bc9dd3609c diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 439d4ef87a..fe7927e8f8 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -17,13 +17,13 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS ## Model Experience -Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this backend's exact completion value when it fits (or an explicit `invalid-output` / `output-limit` failure), plus the exact `[dsh-code-runtime-python] log capture truncated at bytes` log marker, into a retained `run_code` result. +经由 [`dsh-tools`](../../core/tools/README.md) 里的 Code Mode 间接生效:Code Mode 把本后端的精确完成值(放得下时)或一个明确的 `invalid-output` / `output-limit` 失败,连同精确的 `[dsh-code-runtime-python] log capture truncated at bytes` 日志标记,渲染进一个保留的 `run_code` 结果。 #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +无直接失效;具名消费者拥有任何请求前缀的变更。 ## Known Limitations and Deferred Work -- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 -- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 +- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 +- **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 From 104cd5f9755ba30c483077872fbcc5c852c00566 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:16:46 +0800 Subject: [PATCH 008/146] fix(code-runtime-python): bound checkDoneValue object metering in O(cap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The object branch counted every own key before applying the size bound, so a forged done.value with millions of keys and a small cap forced an O(frame) walk — contradicting the O(cap) guarantee the comment promised and able to block the host event loop. Bail mid-count the instant the running minimum encoding (braces + 4 bytes/entry + commas) crosses maxBytes, and drop the now -redundant post-count check the loop subsumes. Add a Proxy-based test proving a 2M-key object enumerates fewer than 1000 keys under a 64-byte cap. Also correct the checkDoneValue JSDoc: per-scalar byte length is measured via scalarJson (exact BigInt digits for beyond-safe integers), not JSON.stringify. --- .../code-runtime-python/src/protocol.ts | 26 +++++++++++++------ .../tests/protocol.spec.ts | 15 +++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 29c790eaec..10d13a211c 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -202,7 +202,10 @@ function scalarJson(current: unknown): string { * number (non-finite, negative zero) is caught only when the value fits the * budget — an over-budget value is rejected regardless, so the distinction is * moot. Same JSON-plain precondition and traversal shape as - * {@link encodeJsonPlain}; per-scalar encoding delegates to `JSON.stringify`. + * {@link encodeJsonPlain}; per-scalar byte length is measured through + * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer + * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and + * `JSON.stringify` for strings. * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). * @param maxBytes - the completion-value budget in bytes. * @returns `{ ok: true, bytes }` with the exact serialized size, or @@ -235,15 +238,22 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by for (const item of current) stack.push(item) } else if (typeof current === 'object' && current !== null) { const record = current as Record - // Count own keys WITHOUT Object.entries/Object.keys: either would - // allocate one slot (entries: one pair array) per member before the - // bound below could run, recreating the spike the bound exists to stop. + // Count own keys WITHOUT Object.entries/Object.keys (either allocates one + // slot per member up front), AND bail mid-count the instant the minimum + // encoding exceeds the budget: braces (+2), each entry a quoted key + // (>= 2 bytes) + colon + >= 1-byte value (>= 4 bytes), and a comma per + // gap. A forged wide object with millions of keys and a small cap must + // fail in O(cap), not walk its whole breadth first. `bytes` still holds + // the pre-object total throughout this loop. let count = 0 - for (const key in record) if (Object.hasOwn(record, key)) count += 1 + for (const key in record) { + if (!Object.hasOwn(record, key)) continue + count += 1 + if (bytes + 2 + count * 4 + (count - 1) > maxBytes) return { ok: false, reason: 'over-budget' } + } + // The loop's final iteration already proved the whole object's lower + // bound fits, so no separate post-count check is needed here. bytes += 2 + (count > 1 ? count - 1 : 0) - // Same pre-enqueue bound: each entry contributes its quoted key (>= 2 - // bytes), the colon, and a >= 1-byte value. - if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } for (const key in record) { if (!Object.hasOwn(record, key)) continue // The same string lower bound, before escaping the key. diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index dc0a01d47d..f0e06af723 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -207,6 +207,21 @@ describe('checkDoneValue', () => { const wide: Record = {} for (let i = 0; i < 10; i++) wide[`k${i}`] = i expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) + // A forged object with millions of keys and a small cap must reject in + // O(cap): the key COUNT loop itself bails once the running minimum encoding + // (braces + 4 bytes/entry + commas) crosses the budget, rather than walking + // the whole breadth before checking. Observable as a bounded key subset: + // build a Proxy whose ownKeys would yield far more than the cap admits and + // assert the metered walk never enumerates past it. + let enumerated = 0 + const millionKeys = new Proxy({}, { + ownKeys() { return Array.from({ length: 2_000_000 }, (_unused, i) => `k${i}`) }, + getOwnPropertyDescriptor() { enumerated += 1; return { enumerable: true, configurable: true, value: 0 } }, + }) + expect(checkDoneValue(millionKeys, 64)).toEqual({ ok: false, reason: 'over-budget' }) + // With cap 64, at most ~16 entries (4 bytes each) can fit before the bound + // trips, so the walk enumerates far fewer than the 2,000,000 declared keys. + expect(enumerated).toBeLessThan(1000) }) it('rejects an over-budget string on its length before escaping it', () => { From 9dc9113ed7665c2b1d65f91b3e2c5d3603eacaab Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:18:17 +0800 Subject: [PATCH 009/146] fix(code-runtime): adopt the base seam's DUNDER_MEMBER resolution The base seam branch resolved its DUNDER_MEMBER inconsistency by keeping /^__.+__$/ and asserting `____` (empty middle between two `__` pairs) does not match. Drop this branch's earlier /^__.*__$/ stopgap so the seam file is byte-identical to its base: the earlier change only existed because the base was self-inconsistent, and the base now owns a coherent decision. --- packages/code-runtime/code-runtime/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 3555dbfa23..3428b5c0e4 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -59,10 +59,10 @@ export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ ]) /** - * Dunder form (`__…__`, two `__` pairs with an optionally empty middle): object-protocol - * slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. + * Dunder form (`__x__`, non-empty middle): object-protocol slots in Python, + * refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ -export const DUNDER_MEMBER = /^__.*__$/ +export const DUNDER_MEMBER = /^__.+__$/ /** * Reserved words of every portable target language (ECMAScript ∪ Python), From ae8070d799e626be71eb2917475df94b5a443402 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:29:32 +0800 Subject: [PATCH 010/146] fix(code-runtime-python): stop overclaiming O(cap) object metering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkDoneValue cannot bound object width sublinearly: JS has no lazy own-key iterator (for...in materializes the key set), and done.value is already JSON.parse'd before the check runs, so the frame's width is paid upstream. The genuine width bound is the host's fixed 256 MiB fd-3 receive buffer (a later stack layer). Reword the JSDoc and branch comments to claim only what holds — the traversal caps the INCREMENTAL allocation the check would add (escaped strings, enqueued children, per-key stringify) and refuses over-budget before those secondary allocations — and drop the mid-count micro-check that JS cannot honor. Replace the Proxy test (whose ownKeys allocated a 2M array, proving nothing) with assertions that an over-budget string/array/object is refused before its escaped copy or child enqueue. --- .../code-runtime-python/src/protocol.ts | 58 +++++++++---------- .../tests/protocol.spec.ts | 31 ++++------ 2 files changed, 37 insertions(+), 52 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 10d13a211c..a2aaecb5be 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -189,20 +189,23 @@ function scalarJson(current: unknown): string { } /** - * Meter a forged done value's compact-JSON byte length AND its number - * losslessness in one bounded traversal, stopping the instant `maxBytes` is - * crossed. A forged `done.value` arrives straight off fd 3 and can sit anywhere - * below the 256 MiB frame ceiling while `maxValueBytes` defaults to 32 KiB. The - * previous split — an unbounded `hasNonLosslessNumber` scan in - * {@link validateChildFrame} followed by a separate byte meter — pushed every - * member of a wide flat payload onto a scan stack before any cap check ran, so - * a below-ceiling forgery could still force a hundreds-of-megabytes host - * allocation. Folding both jobs here rejects over-budget BEFORE enqueuing an - * array's or object's children, keeping the traversal O(cap). A non-lossless - * number (non-finite, negative zero) is caught only when the value fits the - * budget — an over-budget value is rejected regardless, so the distinction is - * moot. Same JSON-plain precondition and traversal shape as - * {@link encodeJsonPlain}; per-scalar byte length is measured through + * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its + * number losslessness in one traversal, stopping the instant `maxBytes` is + * crossed. This bounds the INCREMENTAL allocation the check itself would add on + * top of the already-parsed value — the escaped-string copy, the enqueued + * children, the per-key `JSON.stringify` — not the parse that produced `value`. + * That upstream width is bounded separately: the host reads fd 3 into a fixed + * 256 MiB receive buffer (a later stack layer), so `value` cannot already be + * larger than that when it reaches here, while `maxValueBytes` defaults to + * 32 KiB. The traversal rejects over-budget BEFORE materializing a string's + * escaped form or enqueuing an array's/object's children, so a below-ceiling + * forgery cannot force those secondary allocations. Object key COUNTING is + * unavoidably O(keys) — JS has no lazy own-key iterator, and the parse already + * built the key set — but the check still refuses the per-entry work before the + * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only + * when the value fits the budget — an over-budget value is rejected regardless, + * so the distinction is moot. Same JSON-plain precondition and traversal shape + * as {@link encodeJsonPlain}; per-scalar byte length is measured through * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and * `JSON.stringify` for strings. @@ -230,30 +233,23 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by } else if (Array.isArray(current)) { // Brackets plus one comma per gap; elements add themselves. Reject // BEFORE enqueuing children: every element serializes to at least one - // byte, so a forged flat array below the frame ceiling but far above - // the budget fails here without growing the host stack by millions of - // entries first. + // byte, so a forged flat array far above the budget fails here without + // pushing its elements onto the host stack. (The array itself is already + // materialized by the upstream parse; this only bounds the extra stack.) bytes += 2 + (current.length > 1 ? current.length - 1 : 0) if (bytes + current.length > maxBytes) return { ok: false, reason: 'over-budget' } for (const item of current) stack.push(item) } else if (typeof current === 'object' && current !== null) { const record = current as Record - // Count own keys WITHOUT Object.entries/Object.keys (either allocates one - // slot per member up front), AND bail mid-count the instant the minimum - // encoding exceeds the budget: braces (+2), each entry a quoted key - // (>= 2 bytes) + colon + >= 1-byte value (>= 4 bytes), and a comma per - // gap. A forged wide object with millions of keys and a small cap must - // fail in O(cap), not walk its whole breadth first. `bytes` still holds - // the pre-object total throughout this loop. + // Count own keys with for...in + hasOwn. This IS O(keys) — JS has no lazy + // own-key iterator and the parse already built the key set — so the count + // cannot be sublinear; what the bound below buys is refusing the per-entry + // work (key escaping, value enqueue) before it runs. Each entry costs at + // least a quoted key (>= 2 bytes) + colon + >= 1-byte value. let count = 0 - for (const key in record) { - if (!Object.hasOwn(record, key)) continue - count += 1 - if (bytes + 2 + count * 4 + (count - 1) > maxBytes) return { ok: false, reason: 'over-budget' } - } - // The loop's final iteration already proved the whole object's lower - // bound fits, so no separate post-count check is needed here. + for (const key in record) if (Object.hasOwn(record, key)) count += 1 bytes += 2 + (count > 1 ? count - 1 : 0) + if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } for (const key in record) { if (!Object.hasOwn(record, key)) continue // The same string lower bound, before escaping the key. diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index f0e06af723..b57ae178c6 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -194,34 +194,23 @@ describe('checkDoneValue', () => { } }) - it('stops early on a huge value instead of measuring it whole', () => { + it('rejects an over-budget value before its secondary allocations', () => { + // A huge string is refused on the cheap length lower bound, before its + // escaped copy is built. const huge = { data: 'x'.repeat(1_000_000), tail: 'y' } expect(checkDoneValue(huge, 1024)).toEqual({ ok: false, reason: 'over-budget' }) - // A forged flat array below the frame ceiling must fail BEFORE its - // elements are enqueued — the pre-enqueue bound keeps the walk O(cap). + // A flat array far above the budget fails on the brackets+length bound, + // before its elements are pushed onto the traversal stack. (The array is + // already materialized by the upstream parse; this only avoids the extra + // per-element stack growth.) const flat = new Array(10_000_000).fill(0) expect(checkDoneValue(flat, 1024)).toEqual({ ok: false, reason: 'over-budget' }) - // Same bound for a wide object: braces+commas fit the cap, but the - // per-entry lower bound (quoted key + colon + value) does not, so it fails - // before any key is metered or any value enqueued. + // A wide object: braces+commas fit the cap, but the per-entry lower bound + // (quoted key + colon + value = count*4) does not, so it fails before any + // key is escaped or any value enqueued. const wide: Record = {} for (let i = 0; i < 10; i++) wide[`k${i}`] = i expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) - // A forged object with millions of keys and a small cap must reject in - // O(cap): the key COUNT loop itself bails once the running minimum encoding - // (braces + 4 bytes/entry + commas) crosses the budget, rather than walking - // the whole breadth before checking. Observable as a bounded key subset: - // build a Proxy whose ownKeys would yield far more than the cap admits and - // assert the metered walk never enumerates past it. - let enumerated = 0 - const millionKeys = new Proxy({}, { - ownKeys() { return Array.from({ length: 2_000_000 }, (_unused, i) => `k${i}`) }, - getOwnPropertyDescriptor() { enumerated += 1; return { enumerable: true, configurable: true, value: 0 } }, - }) - expect(checkDoneValue(millionKeys, 64)).toEqual({ ok: false, reason: 'over-budget' }) - // With cap 64, at most ~16 entries (4 bytes each) can fit before the bound - // trips, so the walk enumerates far fewer than the 2,000,000 declared keys. - expect(enumerated).toBeLessThan(1000) }) it('rejects an over-budget string on its length before escaping it', () => { From b4487485c2abca310e6d42a991dc802ac4d46430 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 16:30:34 +0800 Subject: [PATCH 011/146] docs(code-runtime-python): correct ownValues allocation claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ownValues' JSDoc claimed the generator avoids a "second full-breadth allocation before a single value is examined", but for...in still materializes the key-name enumeration when the loop starts — the same JS limitation the checkDoneValue rewrite now acknowledges. What the generator genuinely saves is the extra VALUE array Object.values/Object.entries would copy; state that precisely rather than implying sublinear startup. --- .../code-runtime/code-runtime-python/src/protocol.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index a2aaecb5be..11c87ac7d8 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -318,9 +318,12 @@ export function hasUnsafeIntegerToken(line: string): boolean { /** * Lazily yield one plain object's own enumerable property values. A generator * (not `Object.values`/`Object.entries`) because {@link hasNonLosslessNumber} - * traverses breadth it cannot bound: those helpers copy the whole member list - * up front, so a wide forged object would cost a second full-breadth - * allocation before a single value is examined. + * walks breadth it cannot bound: those helpers copy the whole VALUE (or + * key/value pair) list into a fresh array up front, so a wide object would cost + * that second full-breadth allocation before a single value is examined. The + * `for...in` here does not make the walk sublinear — V8 still materializes the + * key-name enumeration when the loop starts — but it avoids the extra value + * array, yielding each value straight off the already-parsed object. * @param record - a JSON-parse-produced object. * @yields each own enumerable property value, in key order. */ From 69796d214cba38e5512cf140a23f1134da586798 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 16:49:11 +0800 Subject: [PATCH 012/146] fix(code-runtime-python): remove NUL bytes and sync the Agent Note metering claim - Replace four raw U+0000 bytes in protocol.spec.ts string literals with the \0 escape so the source stays plain text (a bare NUL makes text tools treat the file as binary); the runtime value is unchanged, so the bytes:8 NUL-escape assertion still holds. - Sync the Agent Note (both languages) with the corrected checkDoneValue contract: the walk bounds only the incremental allocation it would add, not the frame width, which is already parsed and capped upstream by the host's fd-3 receive buffer. Drop the "prevents a hundreds-of-MB allocation" overclaim that the code JSDoc already retracted. Re-record the note i18n pairing. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 ++-- ...-07-31-code-runtime-python-fd3-protocol.md | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 2 +- .../tests/protocol.spec.ts | 20 ++++++++++--------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 9ca001afdc..33df2bf488 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: 32cc80278af6b5f894c8d972854dae8c92ac63b7 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 24bb9dbb7b8df03c5c82c551449f49b4d306f248 +2026-07-31-code-runtime-python-fd3-protocol.md: 142f6aaf8093ec3e76249fecb40a6fbb13d80500 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 5a371240bb816379d50cc6331f0c6971cf37209a diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index 32cc80278a..142f6aaf80 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -15,7 +15,7 @@ This layer of the stack delivers only that protocol, so the large `PythonCodeRun `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one bounded walk that rejects an over-budget payload BEFORE enqueuing its children, keeping a forged below-frame-ceiling value from forcing a hundreds-of-megabytes host allocation. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the escaped-string copy, the enqueued children, the per-key `JSON.stringify`. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 24bb9dbb7b..5a371240bb 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -15,7 +15,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次有界遍历,在把子节点入栈之前就拒绝超预算 payload,防止一个低于帧上限的伪造值迫使 host 分配数百 MB。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——转义串副本、入栈子节点、逐 key 的 `JSON.stringify`。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index b57ae178c6..b674c8cf49 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -214,17 +214,19 @@ describe('checkDoneValue', () => { }) it('rejects an over-budget string on its length before escaping it', () => { - // A control-heavy forged string escapes to ~6x its length; the walk must - // refuse it on the cheap `length + 2` lower bound so the escaped copy is - // never allocated. Observable through the boundary: a string whose LENGTH - // already exceeds the cap fails even though every character is 1 byte. - expect(checkDoneValue(''.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // A control-heavy forged string escapes to ~6x its length (each NUL becomes + // the 6-character `\u0000`); the walk must refuse it on the cheap + // `length + 2` lower bound so the escaped copy is never allocated. Observable + // through the boundary: a string whose LENGTH already exceeds the cap fails + // even though every source character is one UTF-16 code unit. + expect(checkDoneValue('\0'.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) // The bound is a lower bound, never a false rejection: a string that fits - // exactly still passes with its exact escaped size. - expect(checkDoneValue('', 8)).toEqual({ ok: true, bytes: 8 }) - expect(checkDoneValue('', 7)).toEqual({ ok: false, reason: 'over-budget' }) + // exactly still passes with its exact escaped size — one NUL serializes to + // `"\u0000"`, i.e. two quotes plus the 6-character escape = 8 bytes. + expect(checkDoneValue('\0', 8)).toEqual({ ok: true, bytes: 8 }) + expect(checkDoneValue('\0', 7)).toEqual({ ok: false, reason: 'over-budget' }) // Same lower bound for keys, checked before the key is escaped. - expect(checkDoneValue({ [''.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + expect(checkDoneValue({ ['\0'.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) }) it('meters only own enumerable keys', () => { From 8cf253a470d30f1218ccf7d9c03985976102e530 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:01:56 +0800 Subject: [PATCH 013/146] docs(code-runtime-python): sync README metering claim with code and note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both README sides still described checkDoneValue as "one bounded traversal / 一次有界遍历" — the same overclaim already retracted in the code JSDoc and the Agent Note. Reword both to match: the walk bounds only the incremental allocation it adds (escaped-string copy, enqueued children, per-key stringify); the frame's own width is parsed upstream and capped by the host's fd-3 receive buffer, not re-bounded here. Re-record README.i18n.yaml. --- packages/code-runtime/code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 2 +- packages/code-runtime/code-runtime-python/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 158140a4cb..f096202ad7 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/code-runtime/code-runtime-python/README.md -README.md: f68a45a5420469555eeaf9f88463fbda547d1a1d -README.zh.md: fe7927e8f8c488ed6a7e6b9e5cdc76bc9dd3609c +README.md: 62e899941ac8eadb2046b1bffae0a3c9c6e14308 +README.zh.md: 5a7aa880830bccc4ebd647d7b009ec7e4f0d6307 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index f68a45a542..62e899941a 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -12,7 +12,7 @@ The host and the CPython subprocess exchange a versionless, JSON-lines protocol - **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. - **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). -- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one bounded traversal that rejects an over-budget payload before enqueuing its children; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (escaped-string copy, enqueued children, per-key `JSON.stringify`) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. - **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index fe7927e8f8..5a7aa88083 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -12,7 +12,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 - **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 -- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次有界遍历中同时计量伪造完成值的字节长度与数字无损性,在把子节点入栈之前就拒绝超预算 payload;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(转义串副本、入栈子节点、逐 key 的 `JSON.stringify`)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 ## Model Experience From 146a9d9f61155b517fa840b890a8a8b617f27b46 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:12:52 +0800 Subject: [PATCH 014/146] fix(code-runtime-python): make checkDoneValue over-budget precedence order-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkDoneValue returned non-lossless the instant it hit a non-finite/negative- zero number, before finishing the budget metering. A value that is BOTH over- budget and non-lossless then classified by member order: `["", 1e400]` gave non-lossless while `[1e400, ""]` gave over-budget — the same value, two verdicts — which would drive the consumer to emit invalid-output vs output-limit non-deterministically, contradicting the JSDoc promise that an over-budget value is rejected as over-budget regardless. Record the number violation in a flag and let metering finish; return non-lossless only once the whole value is confirmed within budget. Add a regression test asserting both member orders classify as over-budget. --- .../code-runtime-python/src/protocol.ts | 13 +++++++++++-- .../code-runtime-python/tests/protocol.spec.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 11c87ac7d8..94489b8904 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -217,12 +217,19 @@ function scalarJson(current: unknown): string { */ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; bytes: number } | { ok: false; reason: 'over-budget' | 'non-lossless' } { let bytes = 0 + // A non-lossless number is recorded, not returned on sight: over-budget must + // win regardless of where in the value each violation sits, so the whole + // metering finishes first. Otherwise `["", 1e400]` and `[1e400, + // ""]` — the same over-budget value in two member orders — would + // classify differently (non-lossless vs over-budget), and the JSDoc promises + // an over-budget value is rejected as over-budget regardless. + let nonLossless = false const stack: unknown[] = [value] while (stack.length > 0) { const current = stack.pop() if (typeof current === 'number') { - if (!Number.isFinite(current) || Object.is(current, -0)) return { ok: false, reason: 'non-lossless' } - bytes += Buffer.byteLength(scalarJson(current), 'utf8') + if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true + else bytes += Buffer.byteLength(scalarJson(current), 'utf8') } else if (typeof current === 'string') { // Lower-bound BEFORE materializing the escaped form: every UTF-16 code // unit is at least one UTF-8 byte plus the two quotes, so a huge or @@ -262,6 +269,8 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by } if (bytes > maxBytes) return { ok: false, reason: 'over-budget' } } + // The whole value fit the budget; a recorded number violation is the verdict. + if (nonLossless) return { ok: false, reason: 'non-lossless' } return { ok: true, bytes } } diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index b674c8cf49..a33b9e905b 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -250,6 +250,20 @@ describe('checkDoneValue', () => { expect(checkDoneValue(clean, 1024)).toEqual({ ok: true, bytes: Buffer.byteLength(JSON.stringify(clean), 'utf8') }) }) + it('classifies an over-budget value as over-budget regardless of member order', () => { + // A value that is BOTH over-budget and non-lossless must reject as + // over-budget whichever member the walk reaches first — the non-lossless + // number is recorded and metering finishes, so the two orders below (the + // same value) cannot classify differently. Cap 100 with a 1000-char string. + const big = 'x'.repeat(1000) + expect(checkDoneValue([big, Infinity], 100)).toEqual({ ok: false, reason: 'over-budget' }) + expect(checkDoneValue([Infinity, big], 100)).toEqual({ ok: false, reason: 'over-budget' }) + // A non-lossless number that DOES fit the budget still rejects as + // non-lossless (the recorded violation is the verdict once the whole value + // is confirmed within budget). + expect(checkDoneValue([Infinity], 100)).toEqual({ ok: false, reason: 'non-lossless' }) + }) + it('meters deep nesting iteratively without overflowing the stack', () => { let deep: unknown = 0 for (let i = 0; i < 100_000; i++) deep = [deep] From 8a60b5f0056ec3636742c7c270c52fb3385cb749 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 17:45:11 +0800 Subject: [PATCH 015/146] feat(code-runtime-python): make the TypedDict wire mirror an executable gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two standing review suggestions in this layer rather than deferring them to PR #4: - Extend tests/protocol-mirror.e2e.ts to read each py/protocol.py TypedDict's required/optional key set and assert it against the wire field names src/protocol.ts declares (global included, via functional TypedDict). The round-12 class of drift — a renamed/dropped field, or one side making a field optional the other requires — now fails a test instead of relying on review. Field types remain review-guarded (no mechanical TS/Python equivalent). - Drop the forward references to PR #4's internal mechanisms from this layer's prose: the "256 MiB frame ceiling" figure and the "(index.ts)" fd-3 pinning citation become an abstract "host-side inbound frame-size cap" so the JSDoc, spec, README, and Agent Note describe only what this layer owns. Update both README sides and the Agent Note (both languages) to state the mirror is now executable, and re-record their i18n pairings. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 +- ...-07-31-code-runtime-python-fd3-protocol.md | 4 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 4 +- .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../code-runtime-python/src/protocol.ts | 21 ++++--- .../tests/protocol-mirror.e2e.ts | 60 ++++++++++++++++--- .../tests/protocol.spec.ts | 4 +- 9 files changed, 76 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 33df2bf488..f716091e5b 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: 142f6aaf8093ec3e76249fecb40a6fbb13d80500 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 5a371240bb816379d50cc6331f0c6971cf37209a +2026-07-31-code-runtime-python-fd3-protocol.md: fff3ed7a6e42cfc5372c7c8a3124a33ebcacab32 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: 88a6d493b35b976abf3676dd00182da1270c4fec diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index 142f6aaf80..fff3ed7a6e 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -28,7 +28,7 @@ Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free f ## Mirror alignment -Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. Because the declarations are `TypedDict`s (no runtime enforcement on the trusted Python side), an automated guard covers only what both sides execute: `tests/protocol-mirror.e2e.ts` spawns a real `python3`, reads `PROTOCOL_FD` and `log_truncation_marker` from `py/protocol.py`, and asserts they equal the TypeScript constants across several byte budgets. +Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` in three declarations — `LogMessage` lacked `truncated`, `DoneMessage.error` lacked `kind`, and `Namespace` lacked the optional `errorClass`. This PR aligns all three when lifting the file, so the stale mirror is not carried forward. To keep it aligned, `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`: `PROTOCOL_FD` and `log_truncation_marker` (the two surfaces both sides execute), and each `TypedDict`'s required/optional wire field set — so a renamed or dropped field, or one side making a field optional the other requires (exactly the round-12 drift), fails the test. Field *types* are not compared across the language boundary; that residue stays with review. ## Alternatives considered @@ -40,4 +40,4 @@ Round-12 review of #436 found `py/protocol.py` stale against `src/protocol.ts` i Bought: the fd-3 protocol and its hostile-input codec land as a self-contained, fully unit-covered layer, and the py/ts mirror drift the round-12 review found is fixed with an executing guard against its recurrence. The backend-core PR builds on a reviewed wire contract. -Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The `TypedDict` shapes in `py/protocol.py` beyond the two executed surfaces remain guarded by review plus the backend's real-subprocess suite, not by the mirror e2e test — an inherent limit of comparing type declarations across languages. +Cost: `src/index.ts` and `package.json` are introduced minimally here and edited (not created) by the backend-core PR. The mirror e2e compares field NAMES and required/optional-ness across the two sides but not field TYPES — comparing type declarations across TypeScript and Python has no mechanical equivalent, so that residue stays with review plus the backend's real-subprocess suite. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 5a371240bb..88a6d493b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -28,7 +28,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 ## Mirror alignment -#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。由于这些声明是 `TypedDict`(在受信任的 Python 侧无运行时强制),自动化 guard 只覆盖两侧都会执行的部分:`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,从 `py/protocol.py` 读取 `PROTOCOL_FD` 与 `log_truncation_marker`,并在若干字节预算下断言它们等于 TypeScript 常量。 +#436 的 round-12 review 发现 `py/protocol.py` 相对 `src/protocol.ts` 有三处声明陈旧——`LogMessage` 缺 `truncated`、`DoneMessage.error` 缺 `kind`、`Namespace` 缺可选的 `errorClass`。本 PR 在搬运该文件时对齐了这三处,不把陈旧镜像带过来。为持续保持对齐,`tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言:`PROTOCOL_FD` 与 `log_truncation_marker`(两侧都会执行的面),以及每个 `TypedDict` 的必填/可选 wire 字段集——于是字段被重命名或删除、或一侧把另一侧要求的字段改成可选(正是 round-12 那类漂移),测试即失败。字段的*类型*不跨语言边界比较,那部分残留留给 review。 ## Alternatives considered @@ -40,4 +40,4 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 收获:fd-3 协议及其敌意输入 codec 作为自包含、unit 全覆盖的一层落地,round-12 review 发现的 py/ts 镜像漂移被修复,并有一个执行中的 guard 防其复发。backend-core PR 建立在已 review 的 wire contract 之上。 -代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。`py/protocol.py` 中两个可执行面之外的 `TypedDict` 形状仍由 review 加后端真子进程套件守护,而非 mirror e2e 测试——这是跨语言比较类型声明的固有局限。 +代价:`src/index.ts` 与 `package.json` 在此以最小形态引入,并由 backend-core PR 编辑(而非创建)。mirror e2e 比较两侧的字段名与必填/可选性,但不比较字段类型——跨 TypeScript 与 Python 比较类型声明无机械等价物,那部分残留留给 review 加后端真子进程套件。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index f096202ad7..4d7725dafc 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/code-runtime/code-runtime-python/README.md -README.md: 62e899941ac8eadb2046b1bffae0a3c9c6e14308 -README.zh.md: 5a7aa880830bccc4ebd647d7b009ec7e4f0d6307 +README.md: d0491c478d04a8436199bc23fd79917e8c019b1c +README.zh.md: 63d7d38ee05b561e60a3adf387c5c1292c37a7a2 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 62e899941a..d0491c478d 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -25,5 +25,5 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **The cross-language guard covers only the two runtime-executed surfaces** — `PROTOCOL_FD` and the log truncation marker. The `TypedDict` frame shapes in `py/protocol.py` mirror `src/protocol.ts` by review, not by an automated check: comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a future shape drift is caught by review plus the backend's real-subprocess suite rather than this package's tests. +- **The cross-language guard covers the runtime-executed surfaces and the frame field shapes** — `tests/protocol-mirror.e2e.ts` spawns a real `python3` and asserts, against `src/protocol.ts`, both `PROTOCOL_FD` / the log truncation marker text AND each `TypedDict`'s required/optional wire field set in `py/protocol.py`. What it does not compare is the field *types* (e.g. that `cpuSeconds` is an `int` on both sides): comparing type declarations across TypeScript and Python has no mechanical equivalent here, so a type-level drift is still caught by review plus the backend's real-subprocess suite rather than this package's tests. - **The `PythonCodeRuntime` implementation and its Python-side JSON codec are not in this layer** — they ship in the backend-core PR on top of this branch; `src/index.ts` re-exports only the protocol vocabulary until then. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 5a7aa88083..63d7d38ee0 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -25,5 +25,5 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS ## Known Limitations and Deferred Work -- **跨语言 guard 只覆盖两个运行时执行的面** —— `PROTOCOL_FD` 与日志截断标记。`py/protocol.py` 中的 `TypedDict` 帧形状靠 review 而非自动化检查来镜像 `src/protocol.ts`:跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故未来的形状漂移由 review 加后端真子进程套件捕获,而非本包的测试。 +- **跨语言 guard 覆盖运行时执行的面与帧字段形状** —— `tests/protocol-mirror.e2e.ts` 启动一个真实 `python3`,对照 `src/protocol.ts` 断言 `PROTOCOL_FD` / 日志截断标记文本,以及 `py/protocol.py` 中每个 `TypedDict` 的必填/可选 wire 字段集。它不比较字段的*类型*(例如 `cpuSeconds` 两侧都是 `int`):跨 TypeScript 与 Python 比较类型声明在此无机械等价物,故类型级漂移仍由 review 加后端真子进程套件捕获,而非本包的测试。 - **`PythonCodeRuntime` 实现与 Python 侧 JSON codec 不在本层** —— 它们在基于本分支的 backend-core PR 中交付;在那之前 `src/index.ts` 只 re-export 协议词汇。 diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 94489b8904..80eda4ab8d 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -7,8 +7,9 @@ */ // The protocol channel is fd 3 from the child's perspective — the host pins it -// positionally via `stdio: ['pipe','pipe','pipe','pipe']` (index.ts), and the -// Python bootstrap reads the same constant from its own protocol.py. +// positionally via `stdio: ['pipe','pipe','pipe','pipe']` when it spawns the +// child, and the Python bootstrap reads the same constant from its own +// protocol.py. /** * What the host sends immediately after spawn, as the first line on fd 3. The @@ -194,12 +195,13 @@ function scalarJson(current: unknown): string { * crossed. This bounds the INCREMENTAL allocation the check itself would add on * top of the already-parsed value — the escaped-string copy, the enqueued * children, the per-key `JSON.stringify` — not the parse that produced `value`. - * That upstream width is bounded separately: the host reads fd 3 into a fixed - * 256 MiB receive buffer (a later stack layer), so `value` cannot already be - * larger than that when it reaches here, while `maxValueBytes` defaults to - * 32 KiB. The traversal rejects over-budget BEFORE materializing a string's - * escaped form or enqueuing an array's/object's children, so a below-ceiling - * forgery cannot force those secondary allocations. Object key COUNTING is + * That upstream width is bounded separately, by the host-side cap on inbound + * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the + * channel), so `value` cannot be arbitrarily large when it reaches here, while + * `maxValueBytes` defaults to 32 KiB. The traversal rejects over-budget BEFORE + * materializing a string's escaped form or enqueuing an array's/object's + * children, so a forgery within that frame cap cannot force those secondary + * allocations. Object key COUNTING is * unavoidably O(keys) — JS has no lazy own-key iterator, and the parse already * built the key set — but the check still refuses the per-entry work before the * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only @@ -353,7 +355,8 @@ function* ownValues(record: object): Generator { * cap, so there is no budget to reject a wide payload against the way * {@link checkDoneValue} does. The traversal therefore holds ONE cursor per * NESTING LEVEL (an array or {@link ownValues} iterator) instead of one entry - * per member: a forged flat `args` just below the 256 MiB frame ceiling would + * per member: a forged flat `args` at the top of the host's inbound frame-size + * cap would * otherwise push tens of millions of stack entries — and `Object.values` would * copy each object's full breadth — allocating hundreds of megabytes beyond * what `JSON.parse` already holds. Iterative either way, so a deep frame diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index d79a659c09..3a191ddbb2 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -6,13 +6,14 @@ import { describe, expect, it } from 'vitest' import { logTruncationMarker } from '../src/protocol.ts' /** - * Cross-language mirror check for the two protocol surfaces the host and the - * CPython subprocess share at runtime, spawning a real `python3` to read them - * from `py/protocol.py`. `src/protocol.ts` and `py/protocol.py` declare the same - * frame vocabulary on two sides of the wire; the only values both sides EXECUTE - * against are `PROTOCOL_FD` (the fd the channel is pinned to) and the log - * truncation marker text (emitted verbatim by whichever ledger exhausts first), - * so a drift there silently corrupts a live run. Self-skips when no `python3` is + * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`, + * spawning a real `python3` to read the Python side. Two things are asserted: + * the runtime surfaces both sides EXECUTE against — `PROTOCOL_FD` and the log + * truncation marker text, where a drift silently corrupts a live run — and the + * per-frame wire field sets (required/optional keys of each `TypedDict`), which + * turns the otherwise review-only shape mirror into an executable check that + * catches the round-12 kind of drift (a renamed/dropped field, or one side + * making a field optional the other requires). Self-skips when no `python3` is * on PATH — CI provides one; the pure-TS `protocol.spec.ts` covers the host * codec unconditionally. */ @@ -46,10 +47,53 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } - // fd 3 is the wire contract, not a tunable: index.ts pins it positionally. + // fd 3 is the wire contract, not a tunable: the host pins it positionally + // when it spawns the child. expect(seen.fd).toBe(3) expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget))) }) + + it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => { + // Turn the TypedDict mirror from a review-only obligation into an executable + // check: read each Python TypedDict's required/optional key sets and assert + // them against the wire field names the TS side declares. `global` is the + // reserved-keyword key the Python side carries via functional TypedDict — + // catching exactly the round-12 kind of drift (a renamed/dropped field, an + // optional field the other side made required). + const probe = [ + 'import json, sys', + `sys.path.insert(0, ${JSON.stringify(pyDir)})`, + 'import protocol as p', + 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}', + 'print(json.dumps({', + ' "BootMessage": keys(p.BootMessage),', + ' "Namespace": keys(p.Namespace),', + ' "RunMessage": keys(p.RunMessage),', + ' "BootAckMessage": keys(p.BootAckMessage),', + ' "CallMessage": keys(p.CallMessage),', + ' "LogMessage": keys(p.LogMessage),', + ' "DoneErrorField": keys(p.DoneErrorField),', + ' "DoneMessage": keys(p.DoneMessage),', + ' "ErrorClass": keys(p.ErrorClass),', + '}))', + ].join('\n') + const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const seen = JSON.parse(stdout) as Record + // The wire field sets each frame carries, mirroring src/protocol.ts. `global` + // is the JSON key `CallMessage`/`Namespace` send (a Python keyword, declared + // functionally on the Python side). + expect(seen).toEqual({ + BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, + Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, + RunMessage: { required: ['program', 'type'], optional: [] }, + BootAckMessage: { required: ['type'], optional: [] }, + CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, + LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, + DoneErrorField: { required: ['kind', 'message'], optional: [] }, + DoneMessage: { required: ['type'], optional: ['error', 'value'] }, + ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, + }) + }) }) it('names the py/ directory that ships with the package', () => { diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index a33b9e905b..465aa06a21 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -135,8 +135,8 @@ describe('lossless-number scan', () => { it('walks wide arrays and objects one member at a time', () => { // `call.args` carries no seam byte cap, so a wide forged payload has no // budget to be rejected against — the walk must hold one cursor per - // NESTING LEVEL, not one entry per member, or a flat payload just below - // the 256 MiB frame ceiling would allocate tens of millions of stack + // NESTING LEVEL, not one entry per member, or a flat payload at the top of + // the host's inbound frame-size cap would allocate tens of millions of stack // entries (and `Object.values` a second full-breadth copy). Observable // through the boundary: a wide payload whose per-member cost the old shape // would have paid still scans, and a violation ANYWHERE in it is found From be839a8e53c92f06a27f0b48be07cea5c710b87d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 18:02:42 +0800 Subject: [PATCH 016/146] fix(code-runtime-python): count non-lossless bytes and bind the mirror gate to TS types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from the previous round's fixes: - checkDoneValue flagged a non-lossless number but skipped counting its encoded bytes, so a value over budget ONLY through that number classified as non-lossless instead of over-budget (e.g. [Infinity] at cap 3, whose encoding is 10 bytes). Count the scalar's bytes even when flagging, so the budget check wins as the JSDoc promises. Add cap-3 regression cases. - The mirror e2e compared the Python TypedDict keys against a hand-written constant, so a field change on the TS side alone would not fail it, and the reply frames were not probed at all. Introduce WIRE_FRAME_FIELDS in protocol.ts, bound to each frame interface's key set via `satisfies` (a renamed/removed field breaks typecheck — verified), and drive the mirror test from it, now covering ReplyOk/ReplyErr too. The test therefore fails on one-sided drift from either language. --- .../code-runtime-python/src/protocol.ts | 65 ++++++++++++++++++- .../tests/protocol-mirror.e2e.ts | 49 ++++++-------- .../tests/protocol.spec.ts | 6 ++ 3 files changed, 89 insertions(+), 31 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 80eda4ab8d..c53f2c9dd3 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -107,6 +107,64 @@ export type ReplyMessage = | { type: 'reply'; id: number; ok: true; value: unknown } | { type: 'reply'; id: number; ok: false; message: string } +/** + * Shape of one {@link WIRE_FRAME_FIELDS} entry, parameterised by that frame's + * key union `K`. `required` and `optional` are arrays of `K`, so listing a name + * no frame declares — a typo or a renamed field — fails typecheck. (A field + * ADDED to an interface but omitted here is caught at runtime instead: the + * mirror test asserts the Python `TypedDict` keys equal these exact sets, and + * the Python side would carry the new field.) `K` is `PropertyKey` so a bare + * `keyof Interface` binds without narrowing. + */ +type FrameFields = { + required: readonly K[] + optional: readonly K[] +} + +/** + * The wire field names of each frame, split into required and optional keys, as + * a RUNTIME value the cross-language mirror test asserts `py/protocol.py`'s + * `TypedDict`s against. The `satisfies` clause binds each entry to its frame + * interface's own key set, so listing a name no frame declares fails + * typecheck — the mirror test therefore depends on the TS declarations above, + * not a hand-copied list. `global` is the JSON key {@link CallMessage} and the + * namespace declaration send (a reserved word the Python side carries via a + * functional `TypedDict`); inline sub-shapes (the namespace entry in + * {@link BootMessage}, the error field in {@link DoneMessage}, the reply + * variants) list their keys literally. + */ +export const WIRE_FRAME_FIELDS = { + BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, + Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, + RunMessage: { required: ['program', 'type'], optional: [] }, + BootAckMessage: { required: ['type'], optional: [] }, + CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, + LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, + DoneErrorField: { required: ['kind', 'message'], optional: [] }, + DoneMessage: { required: ['type'], optional: ['error', 'value'] }, + ErrorClass: { required: ['name', 'memberNameProperty'], optional: [] }, + ReplyOk: { required: ['id', 'ok', 'type', 'value'], optional: [] }, + ReplyErr: { required: ['id', 'message', 'ok', 'type'], optional: [] }, +} satisfies { + // Frames with a top-level interface bind to its keys; `global` is already the + // member name on the TS side of `CallMessage`. Frames sent as inline literals + // or nested shapes (the run frame, the namespace entry, the done error field, + // ErrorClass, and the two reply variants) have no standalone interface, so + // their keys are listed literally. + BootMessage: FrameFields + Namespace: FrameFields<'global' | 'names' | 'errorClass'> + RunMessage: FrameFields<'type' | 'program'> + BootAckMessage: FrameFields + CallMessage: FrameFields + LogMessage: FrameFields + DoneErrorField: FrameFields<'kind' | 'message'> + DoneMessage: FrameFields + ErrorClass: FrameFields<'name' | 'memberNameProperty'> + ReplyOk: FrameFields<'type' | 'id' | 'ok' | 'value'> + ReplyErr: FrameFields<'type' | 'id' | 'ok' | 'message'> +} + + /** * The in-band marker text announcing that log capture stopped at the byte * budget. Shared wire vocabulary: the Python-side LogBuffer emits it when ITS @@ -230,8 +288,13 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by while (stack.length > 0) { const current = stack.pop() if (typeof current === 'number') { + // Flag a non-lossless number but keep counting its encoded bytes: a value + // that is BOTH non-lossless and over-budget must classify as over-budget + // (the loop's byte check below wins), so the byte count cannot skip the + // offending number. `scalarJson` gives the same spelling a legit scalar + // would meter. if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true - else bytes += Buffer.byteLength(scalarJson(current), 'utf8') + bytes += Buffer.byteLength(scalarJson(current), 'utf8') } else if (typeof current === 'string') { // Lower-bound BEFORE materializing the escaped form: every UTF-16 code // unit is at least one UTF-8 byte plus the two quotes, so a huge or diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 3a191ddbb2..985581a601 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { logTruncationMarker } from '../src/protocol.ts' +import { logTruncationMarker, WIRE_FRAME_FIELDS } from '../src/protocol.ts' /** * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`, @@ -56,43 +56,32 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => { // Turn the TypedDict mirror from a review-only obligation into an executable // check: read each Python TypedDict's required/optional key sets and assert - // them against the wire field names the TS side declares. `global` is the - // reserved-keyword key the Python side carries via functional TypedDict — - // catching exactly the round-12 kind of drift (a renamed/dropped field, an - // optional field the other side made required). + // them against WIRE_FRAME_FIELDS — the TS-side source of truth bound to the + // frame interfaces by `satisfies` in protocol.ts, so a rename or a removed + // field on the TS side breaks typecheck and an added field breaks this + // comparison (the Python side would carry it). Covers the reply frames too. + // `global` is the reserved-keyword wire key the Python side carries via a + // functional TypedDict. This catches the round-12 kind of drift on EITHER + // side of the wire. + const pyNames = Object.keys(WIRE_FRAME_FIELDS) const probe = [ 'import json, sys', `sys.path.insert(0, ${JSON.stringify(pyDir)})`, 'import protocol as p', 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}', - 'print(json.dumps({', - ' "BootMessage": keys(p.BootMessage),', - ' "Namespace": keys(p.Namespace),', - ' "RunMessage": keys(p.RunMessage),', - ' "BootAckMessage": keys(p.BootAckMessage),', - ' "CallMessage": keys(p.CallMessage),', - ' "LogMessage": keys(p.LogMessage),', - ' "DoneErrorField": keys(p.DoneErrorField),', - ' "DoneMessage": keys(p.DoneMessage),', - ' "ErrorClass": keys(p.ErrorClass),', - '}))', + `names = ${JSON.stringify(pyNames)}`, + 'print(json.dumps({n: keys(getattr(p, n)) for n in names}))', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as Record - // The wire field sets each frame carries, mirroring src/protocol.ts. `global` - // is the JSON key `CallMessage`/`Namespace` send (a Python keyword, declared - // functionally on the Python side). - expect(seen).toEqual({ - BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, - Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, - RunMessage: { required: ['program', 'type'], optional: [] }, - BootAckMessage: { required: ['type'], optional: [] }, - CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, - LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, - DoneErrorField: { required: ['kind', 'message'], optional: [] }, - DoneMessage: { required: ['type'], optional: ['error', 'value'] }, - ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, - }) + // Normalize the TS source of truth to the same sorted shape Python reports. + const expected = Object.fromEntries( + Object.entries(WIRE_FRAME_FIELDS).map(([name, sets]) => [ + name, + { required: [...sets.required].sort(), optional: [...sets.optional].sort() }, + ]), + ) + expect(seen).toEqual(expected) }) }) diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 465aa06a21..2459a15e87 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -262,6 +262,12 @@ describe('checkDoneValue', () => { // non-lossless (the recorded violation is the verdict once the whole value // is confirmed within budget). expect(checkDoneValue([Infinity], 100)).toEqual({ ok: false, reason: 'non-lossless' }) + // The non-lossless number's OWN encoded bytes still count toward the budget, + // so a value whose only over-budget contribution is the non-lossless number + // itself is classified over-budget, not non-lossless. `[Infinity]` encodes + // as the 10-byte `[Infinity]`; at cap 3 the byte check wins. + expect(checkDoneValue([Infinity], 3)).toEqual({ ok: false, reason: 'over-budget' }) + expect(checkDoneValue(Infinity, 3)).toEqual({ ok: false, reason: 'over-budget' }) }) it('meters deep nesting iteratively without overflowing the stack', () => { From 4d49406bd5f7f6af0f73bf62079d6895653a6c96 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 18:24:41 +0800 Subject: [PATCH 017/146] fix(code-runtime-python): bind the wire-field mirror to TS required/optional keys The previous mirror binding (FrameFields) only checked membership: it could not see a TS-side optionality flip (truncated? -> truncated leaves keyof unchanged) or a field added on one side, so the "depends on the TS declaration" claim was overstated. - Promote the inline frame shapes (Namespace, ErrorClass, DoneErrorField, RunMessage, and the two Reply variants) to named interfaces so every frame binds uniformly. - Derive FrameFields from RequiredKeys/OptionalKeys, so `required` and `optional` each accept only that side's keys. An optionality flip or a rename now fails typecheck (verified: flipping LogMessage.truncated to required errors at the constant). - Enumerate EVERY public TypedDict in py/protocol.py in the mirror e2e (not a name list taken from the TS side) and assert both the frame roster and each frame's required/optional sets by exact equality, so a frame or field present on only one side of the wire fails the test. --- .../code-runtime-python/src/protocol.ts | 132 ++++++++++++------ .../tests/protocol-mirror.e2e.ts | 27 ++-- 2 files changed, 103 insertions(+), 56 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index c53f2c9dd3..62966f9ba0 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -11,6 +11,26 @@ // child, and the Python bootstrap reads the same constant from its own // protocol.py. +/** + * One binding namespace declaration inside a {@link BootMessage}. `global` is + * the program-visible name the namespace is materialized under; `errorClass`, + * when present, asks the bootstrap to mint a program-visible exception class. + */ +export interface Namespace { + global: string + names: string[] + errorClass?: ErrorClass +} + +/** + * A namespace's program-visible exception class: rejected calls raise its + * instances carrying the failed member name on `memberNameProperty`. + */ +export interface ErrorClass { + name: string + memberNameProperty: string +} + /** * What the host sends immediately after spawn, as the first line on fd 3. The * Python bootstrap reads this, applies resource limits, then waits for the @@ -29,16 +49,16 @@ export interface BootMessage { maxValueBytes: number /** * The namespaces to materialize inside the program (globals + names; - * functions stay host-side). `errorClass` asks the bootstrap to mint a - * program-visible exception class under that global: rejected calls raise - * its instances carrying the member name on `memberNameProperty`. + * functions stay host-side). See {@link Namespace}. */ - namespaces: { global: string; names: string[]; errorClass?: { name: string; memberNameProperty: string } }[] + namespaces: Namespace[] } -// The run request `{ type: 'run', program }` follows BootMessage once the -// child acknowledges with `boot-ack`; the host sends it as an inline literal -// (it carries only the model's program body — caps and bindings crossed on boot). +/** Host → Python: sent after `boot-ack`; carries only the model's program body. */ +export interface RunMessage { + type: 'run' + program: string +} /** Python → host: acknowledges boot completed and resource limits are in place. */ interface BootAckMessage { @@ -78,6 +98,12 @@ interface LogMessage { truncated?: boolean } +/** The failure carried on a {@link DoneMessage}: one of three kinds plus text. */ +export interface DoneErrorField { + kind: 'exception' | 'invalid-output' | 'output-limit' + message: string +} + /** * Python → host: the program settled. `error` carries a program exception * (traceback text), an `invalid-output` (completion value was not lossless @@ -92,7 +118,7 @@ interface LogMessage { interface DoneMessage { type: 'done' value?: unknown - error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } + error?: DoneErrorField } /** @@ -102,36 +128,57 @@ interface DoneMessage { */ export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage +/** Host → Python: successful answer to one {@link CallMessage}. */ +export interface ReplyOk { + type: 'reply' + id: number + ok: true + value: unknown +} + +/** Host → Python: failed answer to one {@link CallMessage}. */ +export interface ReplyErr { + type: 'reply' + id: number + ok: false + message: string +} + /** Host → Python: the answer to one {@link CallMessage}. */ -export type ReplyMessage = - | { type: 'reply'; id: number; ok: true; value: unknown } - | { type: 'reply'; id: number; ok: false; message: string } +export type ReplyMessage = ReplyOk | ReplyErr + +/** The required (non-optional) keys of `T`, as string literals. */ +type RequiredKeys = { [K in keyof T]-?: object extends Pick ? never : K }[keyof T] & string +/** The optional keys of `T`, as string literals. */ +type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never }[keyof T] & string /** - * Shape of one {@link WIRE_FRAME_FIELDS} entry, parameterised by that frame's - * key union `K`. `required` and `optional` are arrays of `K`, so listing a name - * no frame declares — a typo or a renamed field — fails typecheck. (A field - * ADDED to an interface but omitted here is caught at runtime instead: the - * mirror test asserts the Python `TypedDict` keys equal these exact sets, and - * the Python side would carry the new field.) `K` is `PropertyKey` so a bare - * `keyof Interface` binds without narrowing. + * Shape of one {@link WIRE_FRAME_FIELDS} entry, derived from frame interface + * `T`. Every element of `required` must be one of `T`'s required keys and every + * element of `optional` one of `T`'s optional keys — so a renamed field, or an + * optionality flip (`truncated?` → `truncated`, which moves the name between the + * two arrays' element types), fails typecheck. Completeness in the other + * direction (every declared key actually appears, and no frame exists on only + * one side of the wire) is enforced at runtime by the mirror test, which + * compares these arrays to the Python `TypedDict`'s + * `__required_keys__`/`__optional_keys__` by exact set equality over the full + * frame roster. */ -type FrameFields = { - required: readonly K[] - optional: readonly K[] +type FrameFields = { + required: readonly RequiredKeys[] + optional: readonly OptionalKeys[] } /** * The wire field names of each frame, split into required and optional keys, as * a RUNTIME value the cross-language mirror test asserts `py/protocol.py`'s * `TypedDict`s against. The `satisfies` clause binds each entry to its frame - * interface's own key set, so listing a name no frame declares fails - * typecheck — the mirror test therefore depends on the TS declarations above, - * not a hand-copied list. `global` is the JSON key {@link CallMessage} and the - * namespace declaration send (a reserved word the Python side carries via a - * functional `TypedDict`); inline sub-shapes (the namespace entry in - * {@link BootMessage}, the error field in {@link DoneMessage}, the reply - * variants) list their keys literally. + * interface via {@link FrameFields}, which derives the required/optional key + * sets FROM the interface — so a renamed, removed, or optionality-flipped field + * on the TS side fails typecheck, and the mirror test catches a Python-side + * divergence at runtime. `global` is the JSON key {@link CallMessage} and + * {@link Namespace} send (a reserved word the Python side carries via a + * functional `TypedDict`). */ export const WIRE_FRAME_FIELDS = { BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, @@ -142,26 +189,21 @@ export const WIRE_FRAME_FIELDS = { LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, DoneErrorField: { required: ['kind', 'message'], optional: [] }, DoneMessage: { required: ['type'], optional: ['error', 'value'] }, - ErrorClass: { required: ['name', 'memberNameProperty'], optional: [] }, + ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, ReplyOk: { required: ['id', 'ok', 'type', 'value'], optional: [] }, ReplyErr: { required: ['id', 'message', 'ok', 'type'], optional: [] }, } satisfies { - // Frames with a top-level interface bind to its keys; `global` is already the - // member name on the TS side of `CallMessage`. Frames sent as inline literals - // or nested shapes (the run frame, the namespace entry, the done error field, - // ErrorClass, and the two reply variants) have no standalone interface, so - // their keys are listed literally. - BootMessage: FrameFields - Namespace: FrameFields<'global' | 'names' | 'errorClass'> - RunMessage: FrameFields<'type' | 'program'> - BootAckMessage: FrameFields - CallMessage: FrameFields - LogMessage: FrameFields - DoneErrorField: FrameFields<'kind' | 'message'> - DoneMessage: FrameFields - ErrorClass: FrameFields<'name' | 'memberNameProperty'> - ReplyOk: FrameFields<'type' | 'id' | 'ok' | 'value'> - ReplyErr: FrameFields<'type' | 'id' | 'ok' | 'message'> + BootMessage: FrameFields + Namespace: FrameFields + RunMessage: FrameFields + BootAckMessage: FrameFields + CallMessage: FrameFields + LogMessage: FrameFields + DoneErrorField: FrameFields + DoneMessage: FrameFields + ErrorClass: FrameFields + ReplyOk: FrameFields + ReplyErr: FrameFields } diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 985581a601..9eb0c4f742 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -55,22 +55,24 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', it('agrees on every frame type\'s wire field set between the TS and Python declarations', async () => { // Turn the TypedDict mirror from a review-only obligation into an executable - // check: read each Python TypedDict's required/optional key sets and assert - // them against WIRE_FRAME_FIELDS — the TS-side source of truth bound to the - // frame interfaces by `satisfies` in protocol.ts, so a rename or a removed - // field on the TS side breaks typecheck and an added field breaks this - // comparison (the Python side would carry it). Covers the reply frames too. - // `global` is the reserved-keyword wire key the Python side carries via a - // functional TypedDict. This catches the round-12 kind of drift on EITHER - // side of the wire. - const pyNames = Object.keys(WIRE_FRAME_FIELDS) + // check: enumerate EVERY TypedDict in py/protocol.py (public names carrying + // __required_keys__) and assert both the frame roster and each frame's + // required/optional key sets against WIRE_FRAME_FIELDS — the TS-side source + // of truth bound to the frame interfaces by `satisfies` in protocol.ts. + // Together this catches drift on EITHER side of the wire: a TS rename or + // optionality flip breaks typecheck; a Python frame added, removed, or with + // a changed field set breaks this comparison. `global` is the reserved- + // keyword wire key the Python side carries via a functional TypedDict. const probe = [ 'import json, sys', `sys.path.insert(0, ${JSON.stringify(pyDir)})`, 'import protocol as p', 'def keys(td): return {"required": sorted(td.__required_keys__), "optional": sorted(td.__optional_keys__)}', - `names = ${JSON.stringify(pyNames)}`, - 'print(json.dumps({n: keys(getattr(p, n)) for n in names}))', + // Every public TypedDict in the module — not a name list from the TS side, + // so a Python-only extra frame is visible here. + 'frames = {n: keys(v) for n, v in vars(p).items()' + + ' if not n.startswith("_") and hasattr(v, "__required_keys__")}', + 'print(json.dumps(frames))', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as Record @@ -81,6 +83,9 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', { required: [...sets.required].sort(), optional: [...sets.optional].sort() }, ]), ) + // Same frame roster on both sides (catches a frame present on only one), + // then identical field sets per frame. + expect(Object.keys(seen).sort()).toEqual(Object.keys(expected).sort()) expect(seen).toEqual(expected) }) }) From adba2e305a9e8fb4f250601cc28d6c7c4d566c7b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 18:49:07 +0800 Subject: [PATCH 018/146] fix(code-runtime-python): make the wire-field binding exhaustive over interface keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The array-based FrameFields only checked that listed names were members of the frame's keys, so a field added to a TS interface (e.g. LogMessage.seq?) left the existing arrays a valid subset — typecheck passed, and since the constant and Python both lacked the field the mirror test passed too. The JSDoc's claim that runtime covered this was false. Replace it with WIRE_FRAME_FIELD_ROLES, a per-frame map keyed by field name (`Record, 'required'> & Record, 'optional'>`), so every interface key MUST appear with a matching required/optional tag: an added field, a removed field, a rename, or an optionality flip all fail typecheck at the roles map (verified). WIRE_FRAME_FIELDS is projected from it as the sorted arrays the mirror test still compares to the Python TypedDicts. Also drop the `export` added to the promoted frame interfaces (Namespace, ErrorClass, RunMessage, DoneErrorField, ReplyOk, ReplyErr) — nothing outside protocol.ts imports them, so the barrel surface is unchanged and knip stays clean. --- .../code-runtime-python/src/protocol.ts | 118 ++++++++++-------- 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 62966f9ba0..5d8bd2d555 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -16,7 +16,7 @@ * the program-visible name the namespace is materialized under; `errorClass`, * when present, asks the bootstrap to mint a program-visible exception class. */ -export interface Namespace { +interface Namespace { global: string names: string[] errorClass?: ErrorClass @@ -26,7 +26,7 @@ export interface Namespace { * A namespace's program-visible exception class: rejected calls raise its * instances carrying the failed member name on `memberNameProperty`. */ -export interface ErrorClass { +interface ErrorClass { name: string memberNameProperty: string } @@ -55,7 +55,7 @@ export interface BootMessage { } /** Host → Python: sent after `boot-ack`; carries only the model's program body. */ -export interface RunMessage { +interface RunMessage { type: 'run' program: string } @@ -99,7 +99,7 @@ interface LogMessage { } /** The failure carried on a {@link DoneMessage}: one of three kinds plus text. */ -export interface DoneErrorField { +interface DoneErrorField { kind: 'exception' | 'invalid-output' | 'output-limit' message: string } @@ -129,7 +129,7 @@ interface DoneMessage { export type ChildToHost = BootAckMessage | CallMessage | LogMessage | DoneMessage /** Host → Python: successful answer to one {@link CallMessage}. */ -export interface ReplyOk { +interface ReplyOk { type: 'reply' id: number ok: true @@ -137,7 +137,7 @@ export interface ReplyOk { } /** Host → Python: failed answer to one {@link CallMessage}. */ -export interface ReplyErr { +interface ReplyErr { type: 'reply' id: number ok: false @@ -153,58 +153,70 @@ type RequiredKeys = { [K in keyof T]-?: object extends Pick ? never : K type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never }[keyof T] & string /** - * Shape of one {@link WIRE_FRAME_FIELDS} entry, derived from frame interface - * `T`. Every element of `required` must be one of `T`'s required keys and every - * element of `optional` one of `T`'s optional keys — so a renamed field, or an - * optionality flip (`truncated?` → `truncated`, which moves the name between the - * two arrays' element types), fails typecheck. Completeness in the other - * direction (every declared key actually appears, and no frame exists on only - * one side of the wire) is enforced at runtime by the mirror test, which - * compares these arrays to the Python `TypedDict`'s - * `__required_keys__`/`__optional_keys__` by exact set equality over the full - * frame roster. + * Whether each key of frame `T` is a `'required'` or `'optional'` wire field. + * Because it is `Record`, an entry MUST list every key — a field + * added to the interface without a corresponding entry fails typecheck — and + * `keyof T`-typed keys reject a name no frame declares. The `'required'` / + * `'optional'` tag must match the field's actual optionality (checked by + * {@link WIRE_FRAME_FIELDS}'s per-entry assertions), so an optionality flip is + * caught too. This is the exhaustive counterpart the array form could not + * express (a subset array satisfied it silently). */ -type FrameFields = { - required: readonly RequiredKeys[] - optional: readonly OptionalKeys[] +type FrameFieldRoles = Record, 'required'> & Record, 'optional'> + +/** + * Each frame's wire fields tagged by required/optional, keyed by field name so + * the mapping is exhaustive over the frame interface (see + * {@link FrameFieldRoles}). Bound to the interfaces by `satisfies` below, this + * is the single source of truth the cross-language mirror test derives its + * expectations from; {@link WIRE_FRAME_FIELDS} projects it to sorted + * required/optional arrays for the comparison. `global` is the JSON key + * {@link CallMessage} and {@link Namespace} send (a reserved word the Python + * side carries via a functional `TypedDict`). + */ +const WIRE_FRAME_FIELD_ROLES = { + BootMessage: { type: 'required', cpuSeconds: 'required', addressSpaceBytes: 'required', maxLogBytes: 'required', maxValueBytes: 'required', namespaces: 'required' }, + Namespace: { global: 'required', names: 'required', errorClass: 'optional' }, + RunMessage: { type: 'required', program: 'required' }, + BootAckMessage: { type: 'required' }, + CallMessage: { type: 'required', id: 'required', global: 'required', name: 'required', args: 'required' }, + LogMessage: { type: 'required', text: 'required', truncated: 'optional' }, + DoneErrorField: { kind: 'required', message: 'required' }, + DoneMessage: { type: 'required', value: 'optional', error: 'optional' }, + ErrorClass: { name: 'required', memberNameProperty: 'required' }, + ReplyOk: { type: 'required', id: 'required', ok: 'required', value: 'required' }, + ReplyErr: { type: 'required', id: 'required', ok: 'required', message: 'required' }, +} as const satisfies { + BootMessage: FrameFieldRoles + Namespace: FrameFieldRoles + RunMessage: FrameFieldRoles + BootAckMessage: FrameFieldRoles + CallMessage: FrameFieldRoles + LogMessage: FrameFieldRoles + DoneErrorField: FrameFieldRoles + DoneMessage: FrameFieldRoles + ErrorClass: FrameFieldRoles + ReplyOk: FrameFieldRoles + ReplyErr: FrameFieldRoles } /** - * The wire field names of each frame, split into required and optional keys, as - * a RUNTIME value the cross-language mirror test asserts `py/protocol.py`'s - * `TypedDict`s against. The `satisfies` clause binds each entry to its frame - * interface via {@link FrameFields}, which derives the required/optional key - * sets FROM the interface — so a renamed, removed, or optionality-flipped field - * on the TS side fails typecheck, and the mirror test catches a Python-side - * divergence at runtime. `global` is the JSON key {@link CallMessage} and - * {@link Namespace} send (a reserved word the Python side carries via a - * functional `TypedDict`). + * The wire field names of each frame, split into sorted required and optional + * key arrays — the shape the cross-language mirror test compares against + * `py/protocol.py`'s `TypedDict` `__required_keys__`/`__optional_keys__`. + * Projected from {@link WIRE_FRAME_FIELD_ROLES}, so it inherits that mapping's + * exhaustive, optionality-checked binding to the frame interfaces: a TS-side + * field add, remove, rename, or optionality flip fails typecheck at the roles + * map, and a Python-side divergence fails the mirror test at runtime. */ -export const WIRE_FRAME_FIELDS = { - BootMessage: { required: ['addressSpaceBytes', 'cpuSeconds', 'maxLogBytes', 'maxValueBytes', 'namespaces', 'type'], optional: [] }, - Namespace: { required: ['global', 'names'], optional: ['errorClass'] }, - RunMessage: { required: ['program', 'type'], optional: [] }, - BootAckMessage: { required: ['type'], optional: [] }, - CallMessage: { required: ['args', 'global', 'id', 'name', 'type'], optional: [] }, - LogMessage: { required: ['text', 'type'], optional: ['truncated'] }, - DoneErrorField: { required: ['kind', 'message'], optional: [] }, - DoneMessage: { required: ['type'], optional: ['error', 'value'] }, - ErrorClass: { required: ['memberNameProperty', 'name'], optional: [] }, - ReplyOk: { required: ['id', 'ok', 'type', 'value'], optional: [] }, - ReplyErr: { required: ['id', 'message', 'ok', 'type'], optional: [] }, -} satisfies { - BootMessage: FrameFields - Namespace: FrameFields - RunMessage: FrameFields - BootAckMessage: FrameFields - CallMessage: FrameFields - LogMessage: FrameFields - DoneErrorField: FrameFields - DoneMessage: FrameFields - ErrorClass: FrameFields - ReplyOk: FrameFields - ReplyErr: FrameFields -} +export const WIRE_FRAME_FIELDS: Record = + Object.fromEntries( + Object.entries(WIRE_FRAME_FIELD_ROLES).map(([frame, roles]) => { + const required = Object.keys(roles).filter(key => (roles as Record)[key] === 'required').sort() + const optional = Object.keys(roles).filter(key => (roles as Record)[key] === 'optional').sort() + return [frame, { required, optional }] + }), + ) as Record /** From 4de8c914c9e4c7f11d3f370c2f006be0d9f655d1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 19:10:12 +0800 Subject: [PATCH 019/146] refactor(code-runtime-python): export PROTOCOL_FD and tidy the mirror binding Address the remaining review findings on the wire-mirror layer: - Export PROTOCOL_FD from protocol.ts as the TS-side source of truth the host wires, and assert the Python constant against it in the mirror e2e instead of a bare literal 3, so an fd drift on either side is caught. - Correct the FrameFieldRoles JSDoc to point at the actual assertion site (WIRE_FRAME_FIELD_ROLES's satisfies clause, not WIRE_FRAME_FIELDS). - Drop the redundant explicit type annotation on WIRE_FRAME_FIELDS (the trailing `as` cast already types it; Object.fromEntries returns an index signature). - Refresh the mirror-test comment to describe the roles-map binding (a TS-side add/remove/rename/optionality-flip fails typecheck; a Python-side change fails the comparison). --- .../code-runtime-python/src/protocol.ts | 24 ++++++++++++------- .../tests/protocol-mirror.e2e.ts | 21 ++++++++-------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 5d8bd2d555..01d73d04e8 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -6,10 +6,16 @@ * @module @deepseek-ai/dsh-code-runtime-python/src/protocol */ -// The protocol channel is fd 3 from the child's perspective — the host pins it -// positionally via `stdio: ['pipe','pipe','pipe','pipe']` when it spawns the -// child, and the Python bootstrap reads the same constant from its own -// protocol.py. +/** + * The framed-JSON channel's file descriptor from the child's perspective. The + * host pins it positionally when it spawns the child (`stdio` index 3, i.e. + * `['pipe','pipe','pipe','pipe']`), and the Python bootstrap reads the same + * number from its own `protocol.py`. Exported as the single TS-side source of + * truth: the host wiring uses it, and the cross-language mirror test asserts the + * Python constant equals it, so a drift on either side breaks the boot channel + * loudly rather than silently. + */ +export const PROTOCOL_FD = 3 /** * One binding namespace declaration inside a {@link BootMessage}. `global` is @@ -157,10 +163,10 @@ type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never * Because it is `Record`, an entry MUST list every key — a field * added to the interface without a corresponding entry fails typecheck — and * `keyof T`-typed keys reject a name no frame declares. The `'required'` / - * `'optional'` tag must match the field's actual optionality (checked by - * {@link WIRE_FRAME_FIELDS}'s per-entry assertions), so an optionality flip is - * caught too. This is the exhaustive counterpart the array form could not - * express (a subset array satisfied it silently). + * `'optional'` tag must match the field's actual optionality (checked by the + * `satisfies FrameFieldRoles<…>` clause on {@link WIRE_FRAME_FIELD_ROLES}), so + * an optionality flip is caught too. This is the exhaustive counterpart the + * array form could not express (a subset array satisfied it silently). */ type FrameFieldRoles = Record, 'required'> & Record, 'optional'> @@ -209,7 +215,7 @@ const WIRE_FRAME_FIELD_ROLES = { * field add, remove, rename, or optionality flip fails typecheck at the roles * map, and a Python-side divergence fails the mirror test at runtime. */ -export const WIRE_FRAME_FIELDS: Record = +export const WIRE_FRAME_FIELDS = Object.fromEntries( Object.entries(WIRE_FRAME_FIELD_ROLES).map(([frame, roles]) => { const required = Object.keys(roles).filter(key => (roles as Record)[key] === 'required').sort() diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index 9eb0c4f742..ca28feb8ee 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { describe, expect, it } from 'vitest' -import { logTruncationMarker, WIRE_FRAME_FIELDS } from '../src/protocol.ts' +import { logTruncationMarker, PROTOCOL_FD, WIRE_FRAME_FIELDS } from '../src/protocol.ts' /** * Cross-language mirror check between `src/protocol.ts` and `py/protocol.py`, @@ -47,9 +47,9 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ].join('\n') const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } - // fd 3 is the wire contract, not a tunable: the host pins it positionally - // when it spawns the child. - expect(seen.fd).toBe(3) + // Assert against the TS-side PROTOCOL_FD export (the value the host wires), + // not a bare literal, so a drift on either side of the wire is caught here. + expect(seen.fd).toBe(PROTOCOL_FD) expect(seen.markers).toEqual(budgets.map(budget => logTruncationMarker(budget))) }) @@ -57,12 +57,13 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', // Turn the TypedDict mirror from a review-only obligation into an executable // check: enumerate EVERY TypedDict in py/protocol.py (public names carrying // __required_keys__) and assert both the frame roster and each frame's - // required/optional key sets against WIRE_FRAME_FIELDS — the TS-side source - // of truth bound to the frame interfaces by `satisfies` in protocol.ts. - // Together this catches drift on EITHER side of the wire: a TS rename or - // optionality flip breaks typecheck; a Python frame added, removed, or with - // a changed field set breaks this comparison. `global` is the reserved- - // keyword wire key the Python side carries via a functional TypedDict. + // required/optional key sets against WIRE_FRAME_FIELDS — projected from the + // WIRE_FRAME_FIELD_ROLES map that `satisfies` binds exhaustively to the + // frame interfaces in protocol.ts. Together this catches drift on EITHER + // side of the wire: a TS-side field add, remove, rename, or optionality flip + // fails typecheck at the roles map; a Python frame added, removed, or with a + // changed field set fails this comparison. `global` is the reserved-keyword + // wire key the Python side carries via a functional TypedDict. const probe = [ 'import json, sys', `sys.path.insert(0, ${JSON.stringify(pyDir)})`, From f9ab1edc68516b6db4a78def334bcf6b5525f5ce Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 11:07:58 +0800 Subject: [PATCH 020/146] fix(code-runtime-python): meter escaped string bytes without allocating, bind frame roster to the unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on checkDoneValue's metering and the wire-mirror binding: - The string/key byte check used a decoded-length lower bound and then called JSON.stringify, which materializes the ~6x escaped copy before the over-budget check — the hundreds-of-MB spike the metered walk exists to avoid. Add jsonStringBytesUpTo, a non-allocating scan that computes the exact escaped UTF-8 size (matching JSON.stringify byte for byte, including surrogate pairs vs lone surrogates) and bails the instant it crosses the remaining budget; use it for both string values and object keys. - The frame roster in WIRE_FRAME_FIELD_ROLES was hand-written, so a frame added to ChildToHost/ReplyMessage without a roster entry slipped past. Introduce WireFrameShapes (name -> interface) as the canonical roster the roles map is bound against, plus a WireFrameShapesCoverUnions compile-time assertion that every message-union member appears in it (verified: adding a frame to a union without a WireFrameShapes entry fails typecheck). --- .../code-runtime-python/src/protocol.ts | 130 +++++++++++++----- .../tests/protocol.spec.ts | 37 +++-- 2 files changed, 124 insertions(+), 43 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index 01d73d04e8..d5b1ffe4c5 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -170,15 +170,44 @@ type OptionalKeys = { [K in keyof T]-?: object extends Pick ? K : never */ type FrameFieldRoles = Record, 'required'> & Record, 'optional'> +interface WireFrameShapes { + BootMessage: BootMessage + Namespace: Namespace + RunMessage: RunMessage + BootAckMessage: BootAckMessage + CallMessage: CallMessage + LogMessage: LogMessage + DoneErrorField: DoneErrorField + DoneMessage: DoneMessage + ErrorClass: ErrorClass + ReplyOk: ReplyOk + ReplyErr: ReplyErr +} + +/** + * Compile-time proof that {@link WireFrameShapes} lists every frame carried on a + * message union: the union of the frame types (`ChildToHost`, the reply + * variants, and the host-to-child boot/run frames) must be assignable to the + * union of the roster's value types. Adding a frame to a union without a + * `WireFrameShapes` entry makes this alias `false`, so the assignment below + * fails to compile — closing the whole-frame drift the field-level binding + * alone could not see. Nested shapes (`Namespace`, `ErrorClass`, + * `DoneErrorField`) are not union members; they are covered by the roles + * `satisfies` and the mirror e2e's roster comparison. + */ +type WireFrameShapesCoverUnions = + [ChildToHost | ReplyMessage | BootMessage | RunMessage] extends [WireFrameShapes[keyof WireFrameShapes]] ? true : false +const _wireFrameShapesCoverUnions: WireFrameShapesCoverUnions = true +void _wireFrameShapesCoverUnions + /** * Each frame's wire fields tagged by required/optional, keyed by field name so - * the mapping is exhaustive over the frame interface (see - * {@link FrameFieldRoles}). Bound to the interfaces by `satisfies` below, this - * is the single source of truth the cross-language mirror test derives its - * expectations from; {@link WIRE_FRAME_FIELDS} projects it to sorted - * required/optional arrays for the comparison. `global` is the JSON key - * {@link CallMessage} and {@link Namespace} send (a reserved word the Python - * side carries via a functional `TypedDict`). + * the mapping is exhaustive over the frame interface (see {@link FrameFieldRoles}) + * across the whole {@link WireFrameShapes} roster. Bound to the interfaces by + * `satisfies` below; {@link WIRE_FRAME_FIELDS} projects it to sorted + * required/optional arrays for the cross-language mirror comparison. `global` is + * the JSON key {@link CallMessage} and {@link Namespace} send (a reserved word + * the Python side carries via a functional `TypedDict`). */ const WIRE_FRAME_FIELD_ROLES = { BootMessage: { type: 'required', cpuSeconds: 'required', addressSpaceBytes: 'required', maxLogBytes: 'required', maxValueBytes: 'required', namespaces: 'required' }, @@ -192,19 +221,7 @@ const WIRE_FRAME_FIELD_ROLES = { ErrorClass: { name: 'required', memberNameProperty: 'required' }, ReplyOk: { type: 'required', id: 'required', ok: 'required', value: 'required' }, ReplyErr: { type: 'required', id: 'required', ok: 'required', message: 'required' }, -} as const satisfies { - BootMessage: FrameFieldRoles - Namespace: FrameFieldRoles - RunMessage: FrameFieldRoles - BootAckMessage: FrameFieldRoles - CallMessage: FrameFieldRoles - LogMessage: FrameFieldRoles - DoneErrorField: FrameFieldRoles - DoneMessage: FrameFieldRoles - ErrorClass: FrameFieldRoles - ReplyOk: FrameFieldRoles - ReplyErr: FrameFieldRoles -} +} as const satisfies { [K in keyof WireFrameShapes]: FrameFieldRoles } /** * The wire field names of each frame, split into sorted required and optional @@ -307,6 +324,53 @@ function scalarJson(current: unknown): string { return String(current) } +/** + * Exact UTF-8 byte length of one string's compact JSON form (quotes + escapes), + * computed by a single non-allocating scan that stops the instant the running + * total exceeds `maxBytes`. Used instead of `Buffer.byteLength(JSON.stringify(s))` + * so a control-heavy forged string — whose escaped copy expands up to ~6x — is + * rejected BEFORE that copy is materialized: `JSON.stringify` would allocate the + * full escaped form first, the very hundreds-of-MB spike the metered traversal + * exists to avoid. Mirrors `JSON.stringify`'s escaping byte-for-byte: `"` and + * `\` and the five short C0 escapes cost 2, other C0 controls `\uXXXX` cost 6, a + * valid surrogate pair is one astral code point emitted as raw 4-byte UTF-8, a + * LONE surrogate becomes `\uXXXX` at 6, and any other code point costs its raw + * UTF-8 width. + * @param text - the string to meter. + * @param maxBytes - largest serialized size the caller can still admit. + * @returns the exact serialized byte length, or `undefined` once it exceeds `maxBytes`. + */ +function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined { + let bytes = 2 // the two quotes + if (bytes > maxBytes) return undefined + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index) + if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { + bytes += 2 // `\"` `\\` `\b` `\t` `\n` `\f` `\r` + } else if (code < 0x20) { + bytes += 6 // other C0 controls: `\uXXXX` + } else if (code < 0x80) { + bytes += 1 + } else if (code < 0x800) { + bytes += 2 + } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) { + const next = text.charCodeAt(index + 1) + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4 // valid high+low pair: one astral code point, raw 4-byte UTF-8 + index++ + } else { + bytes += 6 // lone high surrogate: `\uXXXX` + } + } else if (code >= 0xd800 && code <= 0xdfff) { + bytes += 6 // lone surrogate (unpaired high at end, or any low): `\uXXXX` + } else { + bytes += 3 // other BMP code point + } + if (bytes > maxBytes) return undefined + } + return bytes +} + /** * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its * number losslessness in one traversal, stopping the instant `maxBytes` is @@ -325,10 +389,11 @@ function scalarJson(current: unknown): string { * enqueue loop. A non-lossless number (non-finite, negative zero) is caught only * when the value fits the budget — an over-budget value is rejected regardless, * so the distinction is moot. Same JSON-plain precondition and traversal shape - * as {@link encodeJsonPlain}; per-scalar byte length is measured through + * as {@link encodeJsonPlain}; a number's byte length is measured through * {@link scalarJson} (matching the encoder, so a beyond-safe-range integer * meters its exact BigInt digits, not `JSON.stringify`'s rounded spelling) and - * `JSON.stringify` for strings. + * a string's/key's through {@link jsonStringBytesUpTo} (the exact escaped size, + * scanned without allocating the escaped copy). * @param value - a JSON-plain value (e.g. straight from `JSON.parse`). * @param maxBytes - the completion-value budget in bytes. * @returns `{ ok: true, bytes }` with the exact serialized size, or @@ -356,12 +421,13 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by if (!Number.isFinite(current) || Object.is(current, -0)) nonLossless = true bytes += Buffer.byteLength(scalarJson(current), 'utf8') } else if (typeof current === 'string') { - // Lower-bound BEFORE materializing the escaped form: every UTF-16 code - // unit is at least one UTF-8 byte plus the two quotes, so a huge or - // control-heavy forged string (whose escaped copy expands severalfold) - // is rejected without allocating that copy. - if (bytes + current.length + 2 > maxBytes) return { ok: false, reason: 'over-budget' } - bytes += Buffer.byteLength(JSON.stringify(current), 'utf8') + // Meter the escaped form WITHOUT allocating it: jsonStringBytesUpTo scans + // and bails the instant the running cost crosses the remaining budget, so + // a control-heavy forgery (escaped copy up to ~6x) never materializes that + // copy the way `JSON.stringify` would. + const stringBytes = jsonStringBytesUpTo(current, maxBytes - bytes) + if (stringBytes === undefined) return { ok: false, reason: 'over-budget' } + bytes += stringBytes } else if (Array.isArray(current)) { // Brackets plus one comma per gap; elements add themselves. Reject // BEFORE enqueuing children: every element serializes to at least one @@ -384,9 +450,11 @@ export function checkDoneValue(value: unknown, maxBytes: number): { ok: true; by if (bytes + count * 4 > maxBytes) return { ok: false, reason: 'over-budget' } for (const key in record) { if (!Object.hasOwn(record, key)) continue - // The same string lower bound, before escaping the key. - if (bytes + key.length + 3 > maxBytes) return { ok: false, reason: 'over-budget' } - bytes += Buffer.byteLength(JSON.stringify(key), 'utf8') + 1 + // Meter the key's escaped form without allocating it (same reason as the + // string branch), then add the colon separator. `+ 1` for the `:`. + const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes) + if (keyBytes === undefined) return { ok: false, reason: 'over-budget' } + bytes += keyBytes + 1 stack.push(record[key]) } } else { diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 2459a15e87..98715ef030 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -213,20 +213,33 @@ describe('checkDoneValue', () => { expect(checkDoneValue(wide, 12)).toEqual({ ok: false, reason: 'over-budget' }) }) - it('rejects an over-budget string on its length before escaping it', () => { - // A control-heavy forged string escapes to ~6x its length (each NUL becomes - // the 6-character `\u0000`); the walk must refuse it on the cheap - // `length + 2` lower bound so the escaped copy is never allocated. Observable - // through the boundary: a string whose LENGTH already exceeds the cap fails - // even though every source character is one UTF-16 code unit. - expect(checkDoneValue('\0'.repeat(4096), 1024)).toEqual({ ok: false, reason: 'over-budget' }) - // The bound is a lower bound, never a false rejection: a string that fits - // exactly still passes with its exact escaped size — one NUL serializes to - // `"\u0000"`, i.e. two quotes plus the 6-character escape = 8 bytes. + it('meters a string\'s exact escaped size without allocating it', () => { + // A control-heavy string that fits by DECODED length but not once escaped + // must still reject: 200 NULs are 200 UTF-16 units (would pass a naive + // length bound against cap 1024) but escape to 200*6 + 2 = 1202 bytes. + // jsonStringBytesUpTo scans and bails before the escaped copy is built. + expect(checkDoneValue('\0'.repeat(200), 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // Exact-size acceptance, no false rejection: one NUL serializes to a + // 6-char \\uXXXX escape, so with the two quotes = 8 bytes. expect(checkDoneValue('\0', 8)).toEqual({ ok: true, bytes: 8 }) expect(checkDoneValue('\0', 7)).toEqual({ ok: false, reason: 'over-budget' }) - // Same lower bound for keys, checked before the key is escaped. - expect(checkDoneValue({ ['\0'.repeat(4096)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // Multi-byte and astral characters meter at their raw UTF-8 width (a valid + // surrogate pair is 4 bytes, matching JSON.stringify), not a 6-byte escape. + expect(checkDoneValue('\u00e9', 4)).toEqual({ ok: true, bytes: 4 }) // 2 quotes + 2-byte UTF-8 + expect(checkDoneValue('\u{1f600}', 6)).toEqual({ ok: true, bytes: 6 }) // 2 quotes + 4-byte UTF-8 + expect(checkDoneValue('\u{1f600}', 5)).toEqual({ ok: false, reason: 'over-budget' }) + // A lone surrogate escapes to \\uXXXX = 6, so with quotes = 8. + expect(checkDoneValue('\ud800', 8)).toEqual({ ok: true, bytes: 8 }) + // A high surrogate followed by a NON-low character is a lone surrogate (6-byte + // escape) plus that character: `\ud800` + `a` = 2 quotes + 6 + 1 = 9. + expect(checkDoneValue('\ud800a', 9)).toEqual({ ok: true, bytes: 9 }) + // A BMP 3-byte code point (CJK) meters at its raw UTF-8 width: 2 quotes + 3. + expect(checkDoneValue('中', 5)).toEqual({ ok: true, bytes: 5 }) + // Same non-allocating meter for object keys, before the value is enqueued. + expect(checkDoneValue({ ['\0'.repeat(200)]: 1 }, 1024)).toEqual({ ok: false, reason: 'over-budget' }) + // A string reached with less than the two quotes' worth of budget is refused + // immediately (even the empty escaped form does not fit). + expect(checkDoneValue('x', 1)).toEqual({ ok: false, reason: 'over-budget' }) }) it('meters only own enumerable keys', () => { From 4674d8fa92748bb7296d207746176477361ab291 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 11:53:14 +0800 Subject: [PATCH 021/146] fix(code-runtime-python): verify union<->roster both ways, stop pycache writes, refresh metering prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the latest review round: - WireFrameShapesCoverUnions checked only union ⊆ roster, so removing a frame from a message union (e.g. dropping ReplyErr from ReplyMessage) left the check true while the public TS union diverged from the wire. Replace it with a bidirectional equivalence between MessageFrames and the roster's message-frame value types (nested Namespace/ErrorClass/DoneErrorField excluded): both a frame added to a union without a roster entry and a frame removed from a union now fail typecheck (both verified). - The mirror e2e's python3 probes imported protocol.py without -B, writing py/__pycache__/*.pyc into the (un-ignored) source tree. Add -B to both. - Refresh the metering prose (checkDoneValue JSDoc + README both sides + Agent Note both sides): the incremental-work list no longer says "per-key JSON.stringify" now that jsonStringBytesUpTo scans without stringifying; re-record the README and Agent Note i18n pairings. --- ...code-runtime-python-fd3-protocol.i18n.yaml | 4 +- ...-07-31-code-runtime-python-fd3-protocol.md | 2 +- ...-31-code-runtime-python-fd3-protocol.zh.md | 2 +- .../code-runtime-python/README.i18n.yaml | 4 +- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../code-runtime-python/src/protocol.ts | 43 ++++++++++++------- .../tests/protocol-mirror.e2e.ts | 4 +- 8 files changed, 38 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index f716091e5b..1e886e4c31 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: fff3ed7a6e42cfc5372c7c8a3124a33ebcacab32 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: 88a6d493b35b976abf3676dd00182da1270c4fec +2026-07-31-code-runtime-python-fd3-protocol.md: c497ebdcd26f174f3b8bfa325ef404b057d83bb8 +2026-07-31-code-runtime-python-fd3-protocol.zh.md: b4747199a584d62ee4b61df98e487d60b0028d78 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index fff3ed7a6e..c497ebdcd2 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -15,7 +15,7 @@ This layer of the stack delivers only that protocol, so the large `PythonCodeRun `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the escaped-string copy, the enqueued children, the per-key `JSON.stringify`. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — a non-allocating escaped-size scan (`jsonStringBytesUpTo`) and the enqueued children. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index 88a6d493b3..b4747199a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -15,7 +15,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——转义串副本、入栈子节点、逐 key 的 `JSON.stringify`。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——先做非分配的转义尺寸扫描(`jsonStringBytesUpTo`),再入栈子节点。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index 4d7725dafc..ec194b5b9a 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/code-runtime/code-runtime-python/README.md -README.md: d0491c478d04a8436199bc23fd79917e8c019b1c -README.zh.md: 63d7d38ee05b561e60a3adf387c5c1292c37a7a2 +README.md: 606d153eb899925ae274133b3728d317607a17e2 +README.zh.md: f4387d6b20994781b8e64d31da7319badb56aacd diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index d0491c478d..606d153eb8 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -12,7 +12,7 @@ The host and the CPython subprocess exchange a versionless, JSON-lines protocol - **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. - **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). -- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (escaped-string copy, enqueued children, per-key `JSON.stringify`) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (a non-allocating escaped-size scan, then enqueued children) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. - **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 63d7d38ee0..f4387d6b20 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -12,7 +12,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 - **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 -- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(转义串副本、入栈子节点、逐 key 的 `JSON.stringify`)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(先做非分配的转义尺寸扫描,再入栈子节点)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index d5b1ffe4c5..b9caca620c 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -185,20 +185,32 @@ interface WireFrameShapes { } /** - * Compile-time proof that {@link WireFrameShapes} lists every frame carried on a - * message union: the union of the frame types (`ChildToHost`, the reply - * variants, and the host-to-child boot/run frames) must be assignable to the - * union of the roster's value types. Adding a frame to a union without a - * `WireFrameShapes` entry makes this alias `false`, so the assignment below - * fails to compile — closing the whole-frame drift the field-level binding - * alone could not see. Nested shapes (`Namespace`, `ErrorClass`, - * `DoneErrorField`) are not union members; they are covered by the roles - * `satisfies` and the mirror e2e's roster comparison. + * The frames carried on a message union: everything the host and child send as + * a top-level frame (`ChildToHost`, the two reply variants, and the host→child + * boot/run frames). The nested shapes `Namespace`, `ErrorClass`, and + * `DoneErrorField` are fields of other frames, not frames themselves, so they + * are excluded here and covered only by the roles `satisfies` and the mirror e2e. */ -type WireFrameShapesCoverUnions = - [ChildToHost | ReplyMessage | BootMessage | RunMessage] extends [WireFrameShapes[keyof WireFrameShapes]] ? true : false -const _wireFrameShapesCoverUnions: WireFrameShapesCoverUnions = true -void _wireFrameShapesCoverUnions +type MessageFrames = ChildToHost | ReplyMessage | BootMessage | RunMessage +/** The roster's value types minus the three nested (non-frame) shapes. */ +type RosterMessageFrames = Exclude + +/** + * Compile-time proof that {@link WireFrameShapes}'s message-frame entries are + * EXACTLY the frames on the message unions — checked BOTH directions. Forward + * (`MessageFrames extends RosterMessageFrames`) catches a frame added to a union + * without a roster entry; reverse (`RosterMessageFrames extends MessageFrames`) + * catches a frame removed from a union while the roster still lists it (e.g. + * dropping `ReplyErr` from `ReplyMessage`). Either divergence makes an alias + * `false`, failing the assignment below. Type-only; the `const`s emit nothing + * meaningful at runtime. + */ +type UnionCoversRoster = [MessageFrames] extends [RosterMessageFrames] ? true : false +type RosterCoversUnion = [RosterMessageFrames] extends [MessageFrames] ? true : false +const _unionCoversRoster: UnionCoversRoster = true +const _rosterCoversUnion: RosterCoversUnion = true +void _unionCoversRoster +void _rosterCoversUnion /** * Each frame's wire fields tagged by required/optional, keyed by field name so @@ -375,8 +387,9 @@ function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its * number losslessness in one traversal, stopping the instant `maxBytes` is * crossed. This bounds the INCREMENTAL allocation the check itself would add on - * top of the already-parsed value — the escaped-string copy, the enqueued - * children, the per-key `JSON.stringify` — not the parse that produced `value`. + * top of the already-parsed value — the enqueued children (and, in the previous + * implementation, an escaped-string copy that {@link jsonStringBytesUpTo} now + * avoids) — not the parse that produced `value`. * That upstream width is bounded separately, by the host-side cap on inbound * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the * channel), so `value` cannot be arbitrarily large when it reaches here, while diff --git a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts index ca28feb8ee..be1a822a3b 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol-mirror.e2e.ts @@ -45,7 +45,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ' "markers": [log_truncation_marker(b) for b in budgets],', '}))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } // Assert against the TS-side PROTOCOL_FD export (the value the host wires), // not a bare literal, so a drift on either side of the wire is caught here. @@ -75,7 +75,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', + ' if not n.startswith("_") and hasattr(v, "__required_keys__")}', 'print(json.dumps(frames))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-c', probe]) + const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) const seen = JSON.parse(stdout) as Record // Normalize the TS source of truth to the same sorted shape Python reports. const expected = Object.fromEntries( From 203bfca0ea3683bc41b70b32c4ec8c61ef98f8e5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 14:58:52 +0800 Subject: [PATCH 022/146] docs(code-runtime-python): trim metering prose and cover encodeJsonPlain depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the review-history narrative from checkDoneValue's JSDoc (the "in the previous implementation … now avoids" clause); state the current contract only. - Assert encodeJsonPlain on the same 100k-deep value the metering test uses: its headline contract is stack-safety (JSON.stringify would throw), but no test exercised the encoder on a deep value. --- packages/code-runtime/code-runtime-python/src/protocol.ts | 6 +++--- .../code-runtime/code-runtime-python/tests/protocol.spec.ts | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index b9caca620c..a164750a64 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -387,9 +387,9 @@ function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined * Meter a `JSON.parse`-produced done value's compact-JSON byte length AND its * number losslessness in one traversal, stopping the instant `maxBytes` is * crossed. This bounds the INCREMENTAL allocation the check itself would add on - * top of the already-parsed value — the enqueued children (and, in the previous - * implementation, an escaped-string copy that {@link jsonStringBytesUpTo} now - * avoids) — not the parse that produced `value`. + * top of the already-parsed value — the enqueued children; strings and keys are + * metered by {@link jsonStringBytesUpTo} without allocating an escaped copy — + * not the parse that produced `value`. * That upstream width is bounded separately, by the host-side cap on inbound * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the * channel), so `value` cannot be arbitrarily large when it reaches here, while diff --git a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts index 98715ef030..7f50f6df1c 100644 --- a/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts +++ b/packages/code-runtime/code-runtime-python/tests/protocol.spec.ts @@ -283,11 +283,15 @@ describe('checkDoneValue', () => { expect(checkDoneValue(Infinity, 3)).toEqual({ ok: false, reason: 'over-budget' }) }) - it('meters deep nesting iteratively without overflowing the stack', () => { + it('meters and encodes deep nesting iteratively without overflowing the stack', () => { let deep: unknown = 0 for (let i = 0; i < 100_000; i++) deep = [deep] // 100000 '[' + '0' + 100000 ']' = 200001 bytes. expect(checkDoneValue(deep, 1_000_000)).toEqual({ ok: true, bytes: 200_001 }) + // encodeJsonPlain's headline contract is the same stack-safety (JSON.stringify + // recurses per level and throws RangeError a few thousand deep), so exercise + // it on the same 100k-deep value — JSON.stringify would throw here. + expect(encodeJsonPlain(deep)).toBe(`${'['.repeat(100_000)}0${']'.repeat(100_000)}`) }) it('emits exact digits for beyond-safe integral doubles', () => { From 32d6444a2cc0cd152657685e840654f1c17c7cbf Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 5 Aug 2026 15:41:36 +0800 Subject: [PATCH 023/146] fix(code-runtime-python): align package files with the publication gate --- packages/code-runtime/code-runtime-python/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index c94beb2997..8649dca00b 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -21,9 +21,7 @@ "lib/index.js", "lib/invariant.js", "py/**/*.py", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" + "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { From 5dad49f4db9a48550bea4bff92c2ee39423c1845 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 7 Aug 2026 15:37:19 +0800 Subject: [PATCH 024/146] docs(code-runtime-python): name the roster aliases by subset direction, align metering prose Rename UnionCoversRoster/RosterCoversUnion to UnionSubsetOfRoster/RosterSubsetOfUnion so the names read in the same direction as their extends clauses, share the python3 -I -B flags between the two mirror probes, and align the README and Agent Note prose with the checkDoneValue JSDoc: the escaped-size scan is the metering itself, not deferred work. Regenerate docs/module-graph.md, which listed code-runtime-python twice. --- ...-07-31-code-runtime-python-fd3-protocol.i18n.yaml | 4 ++-- .../2026-07-31-code-runtime-python-fd3-protocol.md | 2 +- ...2026-07-31-code-runtime-python-fd3-protocol.zh.md | 2 +- docs/module-graph.md | 1 - .../code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 2 +- .../code-runtime/code-runtime-python/README.zh.md | 2 +- .../code-runtime/code-runtime-python/src/protocol.ts | 12 ++++++------ .../code-runtime-python/tests/protocol-mirror.e2e.ts | 7 +++++-- 9 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml index 1e886e4c31..aa04585e3a 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md -2026-07-31-code-runtime-python-fd3-protocol.md: c497ebdcd26f174f3b8bfa325ef404b057d83bb8 -2026-07-31-code-runtime-python-fd3-protocol.zh.md: b4747199a584d62ee4b61df98e487d60b0028d78 +2026-07-31-code-runtime-python-fd3-protocol.md: 5f9600a3f658df907d68ae695d42154009947fbd +2026-07-31-code-runtime-python-fd3-protocol.zh.md: dc3ae7cdfe1daf6e2ab1326e353c1bdbf9833175 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md index c497ebdcd2..5f9600a3f6 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md @@ -15,7 +15,7 @@ This layer of the stack delivers only that protocol, so the large `PythonCodeRun `src/protocol.ts` is the host side of the wire vocabulary and its hostile-frame codec: - **`validateChildFrame`** shape-validates and REBUILDS every inbound frame. The compile-time union means nothing on fd 3 — a forged frame can carry `null`, poisoned fields, or omit required ones — so each accepted frame is reconstructed field by field: forged extras never ride along, a non-finite call id can never be echoed into a reply, and junk returns `undefined` to be dropped rather than throwing in the host's message handler. -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — a non-allocating escaped-size scan (`jsonStringBytesUpTo`) and the enqueued children. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** are the lossless-JSON codec and meters. They traverse iteratively (an explicit stack, not recursion) so a deep value below the byte budget crosses intact; `checkDoneValue` folds byte-metering and number-losslessness into one walk that rejects an over-budget payload before the INCREMENTAL work it would otherwise add — the enqueued children; strings and keys are metered by a non-allocating escaped-size scan (`jsonStringBytesUpTo`), so the escaped copy is never materialized. It does not re-bound the frame's own width: `done.value` is already `JSON.parse`'d when the check runs, so the payload's size is paid upstream and capped there by the host's fixed fd-3 receive buffer (a later stack layer), not here. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not `String()`'s rounded form. - **`logTruncationMarker`** produces the in-band marker text a log ledger emits when it exhausts its byte budget. `py/protocol.py` mirrors the message shapes as `TypedDict`s and re-declares the two surfaces both sides EXECUTE against — `PROTOCOL_FD = 3` and `log_truncation_marker` — with byte-identical text. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md index b4747199a5..dc3ae7cdfe 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md @@ -15,7 +15,7 @@ CPython code-runtime 后端(`@deepseek-ai/dsh-code-runtime-python`,分多个 `src/protocol.ts` 是 wire vocabulary 的 host 侧及其敌意帧编解码: - **`validateChildFrame`** 对每个入站帧做形状校验并重建。编译期 union 在 fd 3 上毫无意义——伪造帧可携带 `null`、被污染的字段,或省略必需字段——所以每个被接受的帧都逐字段重建:伪造的额外字段绝不随行,非有限的 call id 绝不会被回显进 reply,垃圾返回 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。 -- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——先做非分配的转义尺寸扫描(`jsonStringBytesUpTo`),再入栈子节点。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **`encodeJsonPlain` / `checkDoneValue` / `hasUnsafeIntegerToken` / `hasNonLosslessNumber`** 是 lossless-JSON 编解码器与计量器。它们迭代遍历(显式栈,非递归),使低于字节预算的深层值能完整穿越;`checkDoneValue` 把字节计量和数字无损性折进一次遍历,在它本会新增的 INCREMENTAL 工作之前就拒绝超预算 payload——即入栈子节点;字符串与 key 由非分配的转义尺寸扫描(`jsonStringBytesUpTo`)计量,从不物化转义副本。它不会重新约束帧自身的宽度:`done.value` 在检查运行时已被 `JSON.parse`,故 payload 的尺寸是上游代价,由 host 固定的 fd-3 接收缓冲(后续 stack 层)在那里封顶,而非本函数。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **`logTruncationMarker`** 产出日志 ledger 耗尽字节预算时发出的带内标记文本。 `py/protocol.py` 用 `TypedDict` 镜像消息形状,并重新声明两侧都会 EXECUTE 的两个面——`PROTOCOL_FD = 3` 与 `log_truncation_marker`——文本逐字节一致。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 1706f229b7..1795ec431b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1150,7 +1150,6 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | -| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index ec194b5b9a..72754b0fc6 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/code-runtime/code-runtime-python/README.md -README.md: 606d153eb899925ae274133b3728d317607a17e2 -README.zh.md: f4387d6b20994781b8e64d31da7319badb56aacd +README.md: e7ca08e3e42d368e1b46f4dd52ae7dc1040e6d76 +README.zh.md: df0488b469daca6f33254ce2233b10e0c7138ed5 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 606d153eb8..e7ca08e3e4 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -12,7 +12,7 @@ The host and the CPython subprocess exchange a versionless, JSON-lines protocol - **fd 3, not stdout** — Node pins the channel positionally with `stdio: ['pipe','pipe','pipe','pipe']`; the Python bootstrap reads the same `PROTOCOL_FD` constant. JSON-lines framing. - **Host treats every inbound frame as hostile** — model code has full access to fd 3 and can post anything through it, so `validateChildFrame` shape-validates and REBUILDS each frame before the host reads it: forged extra fields never ride along, a non-number call id can never be echoed into a reply, and junk drops to `undefined` rather than throwing in the host's message handler. The Python side trusts host replies (the host is not model-controlled). -- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (a non-allocating escaped-size scan, then enqueued children) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. +- **Lossless-JSON crossing** — completion values and binding arguments cross as exact JSON. `encodeJsonPlain` serializes a `JSON.parse`-produced value without recursion, so a deep value below the byte budget crosses intact instead of dying on `JSON.stringify`'s stack limit; `checkDoneValue` meters a forged completion value's byte length AND number losslessness in one traversal that rejects an over-budget payload before the incremental work it would add (the enqueued children; strings and keys are metered by a non-allocating escaped-size scan, so the escaped copy is never materialized) — the frame's own width is already parsed and capped upstream by the host's fd-3 receive buffer, not re-bounded here; `hasUnsafeIntegerToken` reads the raw frame text to catch an integer token that `JSON.parse` would silently round; `hasNonLosslessNumber` rejects a non-finite or negative-zero number in unbounded `call.args`. Beyond-safe-range integral doubles serialize through `BigInt` digits so the exact integer crosses, not the rounded `String()` form. - **Shared truncation marker** — `logTruncationMarker(maxBytes)` produces byte-identical text on both sides, so a truncated log run reads the same however the cap was hit. The `log` frame's `truncated` flag distinguishes the child ledger's own marker from program output. ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index f4387d6b20..df0488b469 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -12,7 +12,7 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS - **fd 3,而非 stdout** —— Node 通过 `stdio: ['pipe','pipe','pipe','pipe']` 按位置钉住通道;Python bootstrap 读取相同的 `PROTOCOL_FD` 常量。JSON-lines 帧。 - **host 把每个入站帧当作敌意输入** —— 模型代码对 fd 3 有完全访问权、可通过它发送任意内容,所以 `validateChildFrame` 在 host 读取前对每个帧做形状校验并重建:伪造的额外字段绝不随行,非数字的 call id 绝不会被回显进 reply,垃圾降为 `undefined` 被丢弃,而不是在 host 的 message handler 里抛错。Python 侧信任 host 回复(host 不受模型控制)。 -- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(先做非分配的转义尺寸扫描,再入栈子节点)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 +- **lossless-JSON 穿越** —— 完成值与 binding 参数以精确 JSON 穿越。`encodeJsonPlain` 无递归地序列化一个 `JSON.parse` 产出的值,使低于字节预算的深层值能完整穿越,而不是死在 `JSON.stringify` 的栈限制上;`checkDoneValue` 在一次遍历中同时计量伪造完成值的字节长度与数字无损性,在它本会新增的增量工作之前就拒绝超预算 payload(即入栈子节点;字符串与 key 由非分配的转义尺寸扫描计量,从不物化转义副本)——帧自身的宽度已被上游 `JSON.parse` 支付、由 host 的 fd-3 接收缓冲封顶,并非在此重新约束;`hasUnsafeIntegerToken` 读取原始帧文本,捕获 `JSON.parse` 会静默舍入的整数 token;`hasNonLosslessNumber` 拒绝无字节上限的 `call.args` 中的非有限数或负零。超出安全范围的整数型 double 通过 `BigInt` 数字序列化,穿越的是精确整数而非 `String()` 的舍入形式。 - **共享截断标记** —— `logTruncationMarker(maxBytes)` 在两侧产出逐字节一致的文本,使被截断的日志运行无论从哪侧触达上限都读起来一致。`log` 帧的 `truncated` 标志把子进程 ledger 自身的标记与程序输出区分开。 ## Model Experience diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index a164750a64..ede2274c32 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -205,12 +205,12 @@ type RosterMessageFrames = Exclude { try { @@ -45,7 +48,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', ' "markers": [log_truncation_marker(b) for b in budgets],', '}))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) + const { stdout } = await execFileAsync('python3', [...python3Flags, '-c', probe]) const seen = JSON.parse(stdout) as { fd: number; markers: string[] } // Assert against the TS-side PROTOCOL_FD export (the value the host wires), // not a bare literal, so a drift on either side of the wire is caught here. @@ -75,7 +78,7 @@ describe.skipIf(!python3Available)('protocol.py mirrors protocol.ts at runtime', + ' if not n.startswith("_") and hasattr(v, "__required_keys__")}', 'print(json.dumps(frames))', ].join('\n') - const { stdout } = await execFileAsync('python3', ['-I', '-B', '-c', probe]) + const { stdout } = await execFileAsync('python3', [...python3Flags, '-c', probe]) const seen = JSON.parse(stdout) as Record // Normalize the TS source of truth to the same sorted shape Python reports. const expected = Object.fromEntries( From ea1b4946142831810c47de68be7b96a46c3e9b98 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 14:44:39 +0800 Subject: [PATCH 025/146] docs(code-runtime-python): drop forward references this layer does not own Two comments described facts that belong to later layers of the stack: - The workspace-constraints whitelist comment described a bootstrap the host spawns by path. This layer's py/ holds only protocol.py, the wire-vocabulary mirror, and nothing here spawns it. State what the whitelist entry actually covers: the Python source ships as-is rather than built. - checkDoneValue's JSDoc claimed maxValueBytes "defaults to 32 KiB". This package defines no config and no default; maxValueBytes is a required boot frame field. Name it as the budget instead, so the prose cannot drift when the owning implementation picks a default. Comment-only; the bound argument is unchanged. --- packages/code-runtime/code-runtime-python/src/protocol.ts | 5 +++-- scripts/check-workspace-constraints.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/protocol.ts b/packages/code-runtime/code-runtime-python/src/protocol.ts index ede2274c32..049bb38cc0 100644 --- a/packages/code-runtime/code-runtime-python/src/protocol.ts +++ b/packages/code-runtime/code-runtime-python/src/protocol.ts @@ -392,8 +392,9 @@ function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined * not the parse that produced `value`. * That upstream width is bounded separately, by the host-side cap on inbound * fd-3 frame size before `JSON.parse` runs (owned by the runtime that reads the - * channel), so `value` cannot be arbitrarily large when it reaches here, while - * `maxValueBytes` defaults to 32 KiB. The traversal rejects over-budget BEFORE + * channel), so `value` cannot be arbitrarily large when it reaches here. The + * budget is the `maxValueBytes` the boot frame carries — a required wire field + * with no default at this layer. The traversal rejects over-budget BEFORE * materializing a string's escaped form or enqueuing an array's/object's * children, so a forgery within that frame cap cannot force those secondary * allocations. Object key COUNTING is diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0b0069ffde..b575b48860 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -107,7 +107,7 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], - // The CPython bootstrap ships as source .py files the host spawns by path. + // The CPython side ships as source .py files, published as-is rather than built. '@deepseek-ai/dsh-code-runtime-python': ['py/**/*.py'], '@deepseek-ai/dsh-helper': ['lib/assets'], '@deepseek-ai/dsh-pty-local': ['scripts/ensure-spawn-helper.mjs'], From 26bcff8ab490ca321f031a961b92bcfb432480d2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 15:46:24 +0800 Subject: [PATCH 026/146] docs(pre-push-checks): diagnose absent CI runs as a merge conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CONFLICTING PR gets no pull_request workflow runs, so `gh pr checks` reports "no checks reported" and the runs API returns total_count 0. That looks like a dropped GitHub event, and the reflex fixes for one — empty commits, draft/ready toggles, revert-and-restore bounces — all leave the count at zero while adding junk history to the branch. Record the mergeability check as the first diagnostic step, name the conflict as the cause, and point at `git merge-tree` for the conflicting paths. --- .agents/skills/dsh-pre-push-checks/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index dd04cf9b33..2bc000431e 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -112,4 +112,12 @@ gh pr checks Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. +When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event: + +```sh +gh pr view --json mergeable,mergeStateStatus +``` + +GitHub creates no `pull_request` workflow runs while a PR is `CONFLICTING`/`DIRTY`, so the absent signal is the conflict, not infrastructure. Resolving the conflict is the only fix; empty commits, `--allow-empty` pushes, draft/ready toggles, and revert-and-restore bounces all leave `total_count` at zero and add junk history. Confirm the conflicting paths with `git merge-tree --write-tree HEAD origin/` when the branch cannot be merged locally yet. + For `gh stack sync`, use the post-sync validation sequence instead of pretending the ordinary order was possible. From 3deb60a13c4b24a1db166b5872ea7ee5e9466472 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 16:04:34 +0800 Subject: [PATCH 027/146] docs: carry the new package into the generated docs' Chinese pairs Regenerating docs/module-graph.md and docs/config-catalog.md during the master merge added code-runtime-python entries to the English sides only, leaving both pairs out of sync with their recorded consistent state. Add the matching Chinese entries and re-record the pairing. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.zh.md | 3 +++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 098c91c804..9452d4ea29 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 911255077833354351b08bd2800f2116510ca3c0 -config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc +config-catalog.md: 7b661a0b2c2c42fa5cd84514669ae09b8dacd63f +config-catalog.zh.md: 51d80eb7006f1bb6789393c0a99b0c75126bf61a diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d3141ab389..51d80eb700 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2822,6 +2822,7 @@ export interface Config { - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) +- `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f14bf8cea2..f410a5221e 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/module-graph.md -module-graph.md: 59e22a8b82a210dd66f6e2186f0b827a541e00cc -module-graph.zh.md: 00a433ebaeabba4ce0e39919c3e0fe817608e718 +module-graph.md: 8e320f9a9b58db1b7731695d3e1eb1de6d9fcbb4 +module-graph.zh.md: 007342ee7481a7ffcf0bd875eedca70fdb17d26b diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 00a433ebae..007342ee74 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -179,6 +179,7 @@ flowchart TD end subgraph group_code_runtime["packages/code-runtime"] pkg_code_runtime["code-runtime"] + pkg_code_runtime_python["code-runtime-python"] pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] @@ -327,6 +328,7 @@ flowchart TD pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants pkg_code_runtime --> pkg_invariants + pkg_code_runtime_python --> pkg_invariants pkg_e2b --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_directory_picker --> pkg_invariants @@ -1290,6 +1292,7 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | +| [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | From a9117995e130948882b934b859c1fe61bd4a5415 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 02:13:54 +0800 Subject: [PATCH 028/146] fix(code-runtime-python): match the released root version master cut 0.0.1-rc.2 while this branch was open. The version bump touched every existing package but not this new one, so check-workspace-constraints rejected the mismatch and took the required "all checks passed" job down with it. --- packages/code-runtime/code-runtime-python/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 00d60c7c50..f20771c1b0 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, From 219d2a1fb965ba0d67c0abc73d4152401eb52722 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:33:51 +0800 Subject: [PATCH 029/146] feat(attachment): add ordered image batch admission --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 29 ++++-- ...-image-input-and-durable-attachments.zh.md | 29 ++++-- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 12 ++- docs/subsystems/attachment.zh.md | 12 ++- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 4 +- packages/attachment/attachment/src/index.ts | 30 ++++++ .../attachment/attachment/tests/index.spec.ts | 95 +++++++++++++++++++ packages/host/apiproxy/src/api-proxy.ts | 29 ++---- .../apiproxy/tests/api-proxy-models.spec.ts | 9 +- .../tool-cordis/src/api-catalog.ts | 4 + 14 files changed, 222 insertions(+), 47 deletions(-) create mode 100644 packages/attachment/attachment/tests/index.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index f1c614708b..aed6fb0e63 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 8639a1ab638c85fc01a29083a1b81eacdc2152d4 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 1090edd06a62d70a736c85eb1c7d9e6edba187c8 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: c12821f9d01be12117e987a2612f3953a8eaae20 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 17626bc7696e8e3b6cad01c7fec3b5f0a718921a diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 8639a1ab63..c12821f9d0 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -16,7 +16,7 @@ Peer products converge on an attachment rail above the editor, but their storage ## Decision -Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. +Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. Every rich-content intake adapter decodes its wire blocks, proves route capability, and delegates the complete image batch to the attachment service before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on a single click (display and interaction specifics superseded in part by the [attachment-display alignment note](2026-08-11-web-attachment-display-alignment.md)). File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. @@ -114,7 +114,7 @@ type PromptInputPart = } ``` -Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, the declared MIME against a fully decoded raster, intrinsic dimensions, and decoded-pixel count. It awaits the seam's storage-free `validateImage` for every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the host appends no user event, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure exposes no attachment path or raw bytes. +Base64 crosses a wire boundary once and is discarded after persistence. Each front door validates canonical base64 and declared MIME shape, then calls `AttachmentStore.saveImages()` with the whole decoded batch. The service owns image count, aggregate bytes, individual bytes, fully decoded raster/MIME agreement, intrinsic dimensions, and decoded-pixel count; it validates every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the caller appends no model-visible event and receives no partial references, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does the front door call the agent with normalized text and durable image blocks in wire order. A failure exposes no attachment path or raw bytes. `session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache. @@ -128,7 +128,7 @@ The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. -Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol API gains native image support rather than silently omitting the block. +Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; Code Mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. @@ -148,22 +148,24 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | Surface | Responsibility | | --- | --- | -| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | +| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and single/batch admission through `ctx.attachments`. | | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | | `packages/llm/llm-deepseek` | Reject image content explicitly. | | `packages/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | -| `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits and model preflight, plus default profile composition. | +| `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, routed-model preflight, delegation to shared batch admission, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | | `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | -| `packages/acp/acp` | Explicit fallback rendering for image blocks. | +| `packages/acp/acp` | Conditional native image capability, atomic inline-image admission, and verified assistant-image delivery. | +| `packages/mcp/mcp-client` | Lossless canonical MCP results plus capability-gated durable image projection and explicit diagnostics for unsupported rich blocks. | +| `packages/core/tools` | Generic Code Mode forwarding of settled image-bearing sub-results after the outer result. | The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. ### Implementation -The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web coverage. +The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -193,12 +195,25 @@ Composer presentation can use a generic attachment rail, but provider semantics UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback. +### Add a generic RichContent service above the core content vocabulary + +Rejected because the core already has the role-neutral `ContentBlock` vocabulary and attachment references. A second generic service would duplicate ordering, capability, logging, and lifetime semantics while still requiring each wire adapter to parse its own protocol. Narrow image adapters around the existing core preserve ownership and leave audio/resources to earn their own lifecycle contracts. + +### Normalize MCP results into core content as the canonical tool value + +Rejected because Code Mode and programmatic callers need the complete MCP JSON blocks and optional `structuredContent`; replacing that value with a Native projection would make the bridge lossy. MCP retains the protocol value and prepares a separate model projection, with final post-execute policy remaining authoritative. + +### Perform attachment reads and writes inside synchronous output renderers + +Rejected because tool renderers are pure, synchronous, and replayable. MCP prepares image projection during async execution and installs it only at the registry's finalization boundary; ACP performs async admission and output conversion in its transport lifecycle. Code Mode forwarding observes the already settled final content instead of giving individual image tools private parent-token behavior. + ## Testing - Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. - Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection. +- Attachment, MCP, ACP, and Code Mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. - A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. - The current production adapter set has no certified image-output route; output-provider certification remains outside version one. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 1090edd06a..17626bc769 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 +粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。每个丰富内容接入适配器都会解码自身协议块、证明路由能力,并在追加消息事件前把完整图片批次委托给附件服务。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持单击预览原图(展示与交互细节部分由[附件展示对齐 Note](2026-08-11-web-attachment-display-alignment.md)取代)。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 @@ -114,7 +114,7 @@ type PromptInputPart = } ``` -Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前,等待服务边界上不触碰存储的 `validateImage` 完成对每个批次成员的校验,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,宿主不会追加用户事件,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 +Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都会校验规范 base64 与声明的 MIME 形状,再用完整解码批次调用 `AttachmentStore.saveImages()`。服务负责图片数量、总字节数、单张图片字节数、声明 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数;它会在保存任何成员之前校验每个批次成员,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,调用方不会追加模型可见事件,也不会收到部分引用,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,入口才会用规范化文本和按协议顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 `session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。 @@ -128,7 +128,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 -提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 +提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;Code Mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 @@ -148,22 +148,24 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme | 接口 | 职责 | | --- | --- | -| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | +| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误,以及通过 `ctx.attachments` 提供的单张/批量准入。 | | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | | `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | | `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | -| `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查,以及默认 profile 组合。 | +| `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、路由模型前置检查、委托共享批量准入、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | | `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | -| `packages/acp/acp` | 图片块的明确兜底渲染。 | +| `packages/acp/acp` | 条件式原生图片能力、原子内联图片准入,以及经过校验的助手图片交付。 | +| `packages/mcp/mcp-client` | 无损规范 MCP 结果、经能力门禁的持久图片投影,以及针对不受支持丰富块的明确诊断。 | +| `packages/core/tools` | 在外层结果之后通用转发已经结算且含图片的 Code Mode 子结果。 | 附件包(package)构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 ### 实现 -已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 +已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -193,12 +195,25 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。 +### 在核心内容词汇之上添加通用 RichContent 服务 + +不予采用,因为核心已经拥有角色无关的 `ContentBlock` 词汇与附件引用。第二套通用服务会重复顺序、能力、日志和生命周期语义,同时每个协议适配器仍需解析自身协议。围绕现有核心构建范围狭窄的图片适配器,可以保持归属清晰,并让音频/资源在确有需要时建立自己的生命周期契约。 + +### 把 MCP 结果规范化为核心内容,并将其作为规范工具值 + +不予采用,因为 Code Mode 和程序化调用方需要完整 MCP JSON 块及可选 `structuredContent`;用 Native 投影替换该值会让桥接有损。MCP 保留协议值,并另行准备模型投影;最终 post-execute 策略仍具有权威性。 + +### 在同步输出渲染器中执行附件读写 + +不予采用,因为工具渲染器必须纯净、同步且可回放。MCP 在异步执行期间准备图片投影,只在注册表最终化边界安装;ACP 在自己的传输生命周期中执行异步准入和输出转换。Code Mode 转发观察已经结算的最终内容,而不是让各图片工具各自处理私有父 token 行为。 + ## 测试 - 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 - 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 附件、MCP、ACP 与 Code Mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 - 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 330f2db253..c2438874b3 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/attachment.md -attachment.md: ff7f14ceae8d4f8055d5cfd4367373729dc5ecbc -attachment.zh.md: d7a9527788588d5504fdeffd8ae7849b0f8b1378 +attachment.md: c769d9e608b9e1ab12a5960ca2629a297853bf26 +attachment.zh.md: d07ea722656fafd93793850b8dd268cb14e6856b diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index ff7f14ceae..c769d9e608 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -94,6 +94,16 @@ Immutable binary attachment service. Implementations validate bytes before publi */ abstract validateImage(input: SaveImageAttachment): Promise +/** + * Validate one ordered image batch before committing any member. + * Validation failures start no writes; storage failures return no partial + * references, although already published content-addressed objects may stay + * unreachable until a future retention policy collects them. + * @param inputs - encoded images in their owning message order. + * @returns durable references in the exact input order. + */ +async saveImages(inputs: readonly SaveImageAttachment[]): Promise + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. @@ -111,5 +121,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:30`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index d7a9527788..d07ea72265 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -94,6 +94,16 @@ Immutable binary attachment service. Implementations validate bytes before publi */ abstract validateImage(input: SaveImageAttachment): Promise +/** + * Validate one ordered image batch before committing any member. + * Validation failures start no writes; storage failures return no partial + * references, although already published content-addressed objects may stay + * unreachable until a future retention policy collects them. + * @param inputs - encoded images in their owning message order. + * @returns durable references in the exact input order. + */ +async saveImages(inputs: readonly SaveImageAttachment[]): Promise + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. @@ -111,5 +121,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:30`](../../packages/attachment/attachment/src/index.ts) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index bebd5ee4e7..cef3af3a62 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/attachment/attachment/README.md -README.md: baeeca0cf939f1a3d4608769b362d532507b90f5 -README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e +README.md: c0a86d324da8c27ec386103f40ac50534c2483d7 +README.zh.md: 562c8af0df20634ac2072c4a8d422b4e0b4b47dd diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index baeeca0cf9..c0a86d324d 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 238b90794c..562c8af0df 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 ## 模型体验 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1bfb1ea119..72e680f010 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,6 +1,7 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ import { Context, Service } from '@deepseek-ai/cordis' +import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, ImageAttachmentRef, @@ -42,6 +43,35 @@ export abstract class AttachmentStore extends Service { */ abstract validateImage(input: SaveImageAttachment): Promise + /** + * Validate one ordered image batch before committing any member. + * Validation failures start no writes; storage failures return no partial + * references, although already published content-addressed objects may stay + * unreachable until a future retention policy collects them. + * @param inputs - encoded images in their owning message order. + * @returns durable references in the exact input order. + */ + async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits + if (inputs.length > maxImagesPerMessage) { + throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + } + const totalBytes = inputs.reduce((sum, input) => sum + input.data.byteLength, 0) + if (totalBytes > maxMessageImageBytes) { + throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + for (const input of inputs) { + if (!mediaTypes.includes(input.mediaType)) { + throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE') + } + } + for (const input of inputs) await this.validateImage(input) + + const refs: ImageAttachmentRef[] = [] + for (const input of inputs) refs.push(await this.saveImage(input)) + return refs + } + /** * Validate and durably commit one image before its owning session event is appended. * @param input - encoded bytes, declared media type, and optional display name. diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts new file mode 100644 index 0000000000..5a75c24dc4 --- /dev/null +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -0,0 +1,95 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import AttachmentStore, { + AttachmentId, + type ImageAttachmentRef, + type ImageMediaType, + type SaveImageAttachment, + type StoredImageAttachment, +} from '../src/index.ts' + +const LIMITS = { + maxImageBytes: 4, + maxImagesPerMessage: 2, + maxMessageImageBytes: 5, + maxImagePixels: 4, + mediaTypes: ['image/png'] as const, +} + +class RecordingStore extends AttachmentStore { + readonly imageLimits = LIMITS + readonly calls: string[] = [] + rejectValidationAt: number | undefined + rejectSaveAt: number | undefined + + async validateImage(input: SaveImageAttachment): Promise { + const value = input.data[0] ?? 0 + this.calls.push(`validate:${value}`) + if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`) + } + + async saveImage(input: SaveImageAttachment): Promise { + const value = input.data[0] ?? 0 + this.calls.push(`save:${value}`) + if (value === this.rejectSaveAt) throw new Error(`write:${value}`) + return { + attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + } + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('not used') + } +} + +function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment { + return { data: Uint8Array.of(value), mediaType, name: `${value}.png` } +} + +describe('AttachmentStore.saveImages', () => { + it('validates the complete batch before saving in input order', async () => { + const store = new RecordingStore(new Context()) + + const refs = await store.saveImages([image(1), image(2)]) + + expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2']) + expect(refs.map(ref => ref.name)).toEqual(['1.png', '2.png']) + }) + + it('rejects count, aggregate bytes, and deployment media types before validation', async () => { + const store = new RecordingStore(new Context()) + + await expect(store.saveImages([image(1), image(2), image(3)])) + .rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' }) + await expect(store.saveImages([ + { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }, + { data: Uint8Array.of(4, 5, 6), mediaType: 'image/png' }, + ])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' }) + await expect(store.saveImages([image(1, 'image/jpeg')])) + .rejects.toMatchObject({ code: 'UNSUPPORTED_IMAGE_TYPE' }) + expect(store.calls).toEqual([]) + }) + + it('starts no writes when any member fails validation', async () => { + const store = new RecordingStore(new Context()) + store.rejectValidationAt = 2 + + await expect(store.saveImages([image(1), image(2)])) + .rejects.toThrow('invalid:2') + expect(store.calls).toEqual(['validate:1', 'validate:2']) + }) + + it('returns no partial references when storage fails after an earlier commit', async () => { + const store = new RecordingStore(new Context()) + store.rejectSaveAt = 2 + + await expect(store.saveImages([image(1), image(2)])) + .rejects.toThrow('write:2') + expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2']) + }) +}) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 9f4114c811..6eb760d675 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -145,36 +145,25 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten if (content.every(part => part.type === 'text')) { return content.map(part => ({ type: 'text', text: part.text })) } - const limits = ctx.attachments.imageLimits - if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) { - throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') - } const prepared = content.map(part => part.type === 'text' ? part : { part, data: decodeBase64(part.data) }) const images = prepared.filter((part): part is Extract => 'data' in part) - const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) - if (totalBytes > limits.maxMessageImageBytes) { - throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') - } - for (const image of images) { - await ctx.attachments.validateImage({ - data: image.data, - mediaType: image.part.mediaType, - ...image.part.name === undefined ? {} : { name: image.part.name }, - }) - } + const refs = await ctx.attachments.saveImages(images.map(image => ({ + data: image.data, + mediaType: image.part.mediaType, + ...image.part.name === undefined ? {} : { name: image.part.name }, + }))) const blocks: ContentBlock[] = [] + let imageIndex = 0 for (const item of prepared) { if (!('data' in item)) { blocks.push({ type: 'text', text: item.text }) continue } - const attachment = await ctx.attachments.saveImage({ - data: item.data, - mediaType: item.part.mediaType, - ...item.part.name === undefined ? {} : { name: item.part.name }, - }) + const attachment = refs[imageIndex++] + /* v8 ignore next -- each prepared image supplied exactly one saveImages input and therefore one ordered ref. */ + if (attachment === undefined) throw new Error('attachment batch result did not preserve input cardinality') blocks.push({ type: 'image', attachment }) } return blocks diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 335b8b795f..2a8678e259 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import AttachmentStore from '@deepseek-ai/dsh-attachment' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, @@ -140,7 +141,7 @@ describe('Web session model selection', () => { height: 1, ...input.name === undefined ? {} : { name: input.name }, })) - ctx.provide('attachments', { + const attachments = { imageLimits: { maxImageBytes: 4, maxImagesPerMessage: 2, @@ -150,6 +151,12 @@ describe('Web session model selection', () => { }, validateImage, saveImage, + } + ctx.provide('attachments', { + ...attachments, + saveImages(inputs: readonly Parameters[0][]) { + return AttachmentStore.prototype.saveImages.call(attachments, inputs) + }, } as never) const followup = vi.fn() Object.assign(agent, { followup }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 6170c9a196..3e0d824259 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -232,6 +232,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract validateImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns completion after the encoded raster has been fully decoded.\n */', }, + { + signature: 'async saveImages(inputs: readonly SaveImageAttachment[]): Promise', + jsDoc: '/**\n * Validate one ordered image batch before committing any member.\n * Validation failures start no writes; storage failures return no partial\n * references, although already published content-addressed objects may stay\n * unreachable until a future retention policy collects them.\n * @param inputs - encoded images in their owning message order.\n * @returns durable references in the exact input order.\n */', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', From e00146be738bcff67cb67d7839cd3a2ad767ad30 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:34:13 +0800 Subject: [PATCH 030/146] fix(tools): forward nested image results in code mode --- ...-20-code-mode-typed-tool-returns.i18n.yaml | 4 +- ...2026-07-20-code-mode-typed-tool-returns.md | 14 ++-- ...6-07-20-code-mode-typed-tool-returns.zh.md | 14 ++-- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 2 +- docs/tool-catalog.zh.md | 2 +- .../system-prompt.expected.md | 2 +- .../tool-schemas.expected.json | 2 +- .../both-mode-turn/tool-schemas.expected.json | 2 +- .../code-mode-turn/system-prompt.expected.md | 2 +- .../code-mode-turn/tool-schemas.expected.json | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 6 +- packages/core/tools/README.zh.md | 6 +- packages/core/tools/src/code-mode.ts | 15 +++-- packages/core/tools/src/py-types.ts | 2 +- packages/core/tools/src/ts-types.ts | 2 +- packages/core/tools/tests/code-mode.spec.ts | 67 +++++++++++++++++++ packages/fs/tool-fs/src/read-image.ts | 7 -- 19 files changed, 117 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml index 611e8bd949..d4f79090ae 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md -2026-07-20-code-mode-typed-tool-returns.md: 6b31dbca21a22a24f2bbb0ef61097622884165c3 -2026-07-20-code-mode-typed-tool-returns.zh.md: 1bb88d29ffd96d65ce06033202499d2780814abd +2026-07-20-code-mode-typed-tool-returns.md: 6bb4ccdeb81172ec9102a8656d380dfed09b63ae +2026-07-20-code-mode-typed-tool-returns.zh.md: 49084d6104ce2f733810126e4bb7cf78e92740e5 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md index 6b31dbca21..6bb4ccdeb8 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md @@ -14,7 +14,7 @@ The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-o ## Decision -Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. Only the outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and the model-facing spill pipeline. +Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. The outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and model-facing spill pipeline; a successfully settled sub-call whose final Native content contains an image additionally defers that complete ordered content through the parent result as logged, source-attributed context. This note owns the return and failure contract layered on the original [Code Mode foundation](2026-06-15-code-mode.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note. @@ -49,7 +49,7 @@ declare const tools: { ### Binding values and failures -Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program. +Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program. Image-bearing final content is not a second binding value: the bridge ferries it after the outer result so the next model request can see the durable image, while post-execute block/content replacement remains authoritative and text-only results are not duplicated. Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime Service Definition treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. @@ -73,13 +73,13 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu ### Persistence, metadata, and spill -Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values. +Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values. The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`. ## Testing -Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, and exotic names. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. +Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, exotic names, and assembled Code Mode image forwarding. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; generic image-bearing context deferral plus post-execute replacement/block precedence; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its task id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `task_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text. @@ -93,6 +93,10 @@ Keyless real-worker integration tests pin the two handle workflows that prose re **Silently inspect or truncate an oversized completion.** Rejected because changing a JSON value into a string is lossy and type-incorrect. The explicit `output-limit` failure lets the model choose a smaller result, while the retained logs and diagnostic can still use normal outer spill. +**Require each rich leaf tool to inspect `exec.parent` and defer itself.** Rejected because it couples leaf tools to Code Mode internals, duplicates policy handling, and misses future rich tools. The dispatch bridge owns generic forwarding from the already settled final result. + +**Expose Native rich content as part of every binding's canonical value.** Rejected because a canonical value is lossless JSON and tool-specific; attachment blocks are a model projection with durable lifecycle semantics. Keeping the value and projection separate preserves typed programs without dropping images from later model context. + ## Consequences Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and UI presentation, while Code Mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer. @@ -107,6 +111,6 @@ The worker performs bounded-depth flat-wire transport and lossless validation bu - Intermediate values have no byte cap and can exhaust process or worker memory through retention, flat-wire copies, or structured-clone cost. - The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap. - Provider or executor acquisition limits may already have discarded source data before a canonical value reaches Code Mode. -- Unsupported MCP output schemas fall back to `JsonValue`; richer Native multimedia projection is deferred. +- Unsupported MCP output schemas fall back to `JsonValue`; admitted MCP images use the generic deferred projection, while audio and embedded-resource payloads remain diagnostic-only. - There is one result card per outer `run_code`, never per nested call. - Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union. diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md index 1bb88d29ff..49084d6104 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md @@ -14,7 +14,7 @@ Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投 ## 决策 -Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。只有外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线。 +Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线;如果成功结算的子调用最终 Native 内容包含图片,其完整有序内容还会经父结果延后为写入日志且带来源归属的上下文。 本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.md)之上的返回值与失败约定。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。 @@ -49,7 +49,7 @@ declare const tools: { ### 绑定值与失败 -分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。 +分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。含图片的最终内容不是第二份绑定值:桥接层会在外层结果之后转运它,使下一次模型请求可以看到持久图片;post-execute 阻止/内容替换仍具有权威性,纯文本结果不会重复。 Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 Service Definition 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把约定承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常约定,而不是供程序分类的失败联合。 @@ -73,13 +73,13 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper ### 持久化、元数据与输出落盘 -嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。 +嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。 不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的输出落盘投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能将 post-policy 处理后的最终展示写入落盘文件;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。 ## 测试 -编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围以及特殊名称。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 +编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围、特殊名称,以及组装后的 Code Mode 图片转发。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;通用含图片上下文延后以及 post-execute 替换/阻止优先级;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 task id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且取消归 `task_kill` 所有。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。 @@ -93,6 +93,10 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper **静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层输出落盘机制。 +**要求每个丰富叶子工具检查 `exec.parent` 并自行延后。** 不予采用,因为这会把叶子工具与 Code Mode 内部机制耦合、重复策略处理,并遗漏未来丰富工具。分发桥接层负责从已经结算的最终结果通用转发。 + +**把 Native 丰富内容暴露为每个绑定规范值的一部分。** 不予采用,因为规范值是无损 JSON 且由工具定义;附件块是具有持久生命周期语义的模型投影。保持值与投影分离,既能保留类型化程序,也不会从后续模型上下文中丢弃图片。 + ## 后果 Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 @@ -107,6 +111,6 @@ worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损 - 中间值没有字节上限,可能因值的保留、扁平协议格式副本或结构化克隆开销而耗尽进程或 worker 内存。 - 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;输出落盘无法恢复超出该上限后被拒绝的字节。 - 提供方或执行器的采集上限可能在规范值到达 Code Mode 前就已丢弃部分源数据。 -- 不支持的 MCP 输出 schema 会回退为 `JsonValue`;更丰富的 Native 多媒体投影留待后续实现。 +- 不支持的 MCP 输出 schema 会回退为 `JsonValue`;已准入的 MCP 图片使用通用延后投影,而音频和嵌入资源载荷仍只提供诊断。 - 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。 - Code Mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index c9726f4ec5..a461c1ef91 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/tool-catalog.md -tool-catalog.md: 898c5700eddfe49083b2ce0e3e04761298b28bbb -tool-catalog.zh.md: 3b17cf4e3b1b74b0735783cfe899c9c693146c38 +tool-catalog.md: 135020e4fbf41f010e32f21647502d57494bd3c4 +tool-catalog.zh.md: 9d1c7c0fa20e45c1a447b915a2d34adbd3551b38 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 898c5700ed..135020e4fb 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -116,7 +116,7 @@ ask_user_question pauses the tool call until the active UI provider returns a hu ### `run_code` -Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it. +Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output; image-bearing subtool results are attached after the run. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 3b17cf4e3b..9d1c7c0fa2 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -118,7 +118,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 ### `run_code` -针对可用工具执行 TypeScript 程序。请编写异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`),并根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的内容会传回,请谨慎筛选。 +针对可用工具执行 TypeScript 程序。请编写异步函数的**函数体**(仅使用可擦除语法;支持顶层 `await` 和 `return`),并根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。只有打印或返回的值属于程序输出;含图片的子工具结果会在运行结束后附加。 ```json { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 3349deeb59..8050a35a42 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -30,7 +30,7 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. The available tools: diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index c5821f832b..7cf2e75010 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -251,7 +251,7 @@ }, { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", + "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output; image-bearing subtool results are attached after the run.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index df0be9cab8..9978fba341 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -194,7 +194,7 @@ }, { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", + "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output; image-bearing subtool results are attached after the run.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index f3994dc95b..8e1128c6a0 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -32,7 +32,7 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. The available tools: diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json index a9ee29aa7a..2582a5d35b 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json @@ -2,7 +2,7 @@ "initial": [ { "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", + "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output; image-bearing subtool results are attached after the run.", "parameters": { "type": "object", "properties": { diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 5841f76969..1b8f6bb0d5 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/core/tools/README.md -README.md: 44eb25b79436a75f08406102fc1e3734e59b1001 -README.zh.md: 35142d8186b21b2930ccc40386bed8cc677d77c3 +README.md: 88fe6660f169f69a70e5630f853104a7c83a5b3c +README.zh.md: b65892caa86c64971bf4483c21d5fc765183c6e6 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 44eb25b794..88fe6660f1 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -115,12 +115,12 @@ Returning `undefined` selects generic fallback. Presenters depend only on their ### Code Mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). Only the program's outer logs and return value re-enter model context. The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. +Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic SDK for the current scope, generated in the loaded runtime's language — the registry selects the renderer by `ctx.codeRuntime.language` (`typescript` → the TypeScript SDK below, `python` → the Python SDK). The SDK declares exact per-tool argument and canonical-output types for every visible tool (`ToolArgsMap`/`ToolOutputMap` in TypeScript, named `TypedDict`s in Python), and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. The program's outer logs and return value re-enter model context; when a successfully settled sub-call's final Native content contains an image, the bridge also defers that complete ordered content through the parent result so the image is not lost behind the JSON-only binding. Final post-execute blocking or content replacement is authoritative. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. Under `code` — not `both` — the transport is also the only entry the model may use: a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` at execution creation, before `tools/pre-execute`, approval `ask`, and guards, so nothing observes or approves a call that can only fail. The denial names the route back (`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`), because the same prompt declares that tool and a bare `unknown tool` reads as a broken deployment. SDK sub-dispatches carry the outer execution's `parent` token and are exempt, so programs keep every binding the SDK declared. See the [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md), the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`. - **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating the language-appropriate SDK text at each assembly. In the TypeScript flavor it emits `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions; the Python flavor (`ctx.codeRuntime.language === 'python'`) emits the equivalent named `TypedDict`s and a `tools` object with matching usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). Both codegens are exported and never throw during prompt assembly: `jsonSchemaToTs` handles every unified schema construct and degrades unsupported raw constructs to `unknown`; `jsonSchemaToPy` does the same, degrading to `Any` (and a whole object to `dict[str, Any]` when a field name is not a legal `TypedDict` attribute, or whenever it is called outside the SDK render, which supplies the naming context a `TypedDict` declaration needs). -- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `:code:`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails. +- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `:code:`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry and every successful final content sequence containing an image is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and source attribution even when the program later fails. - **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from. - **Result size**: intermediate binding values cross the worker process whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that limit. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill. @@ -177,7 +177,7 @@ Prefix-stable while the Code Mode selection, generated SDK, transport schema, an #### What the model sees -The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. Code Mode returns only the outer program's printed lines and rendered return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only; post-execute listeners may append source-attributed context after the result. +The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. Code Mode renders the outer program's printed lines and return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only, while a successful image-bearing sub-result is appended after the outer result as source-attributed context; post-execute listeners may append other source-attributed context at the same boundary. #### Token effect diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 35142d8186..b65892caa8 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -115,12 +115,12 @@ ctx.tools.register(defineTool({ ### Code Mode -在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。 +在 `code` 或 `both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和按所加载运行时语言生成的确定性 SDK——注册表按 `ctx.codeRuntime.language` 选择渲染器(`typescript` → 下方的 TypeScript SDK,`python` → Python SDK)。SDK 为每个可见工具声明精确的参数与规范输出类型(TypeScript 为 `ToolArgsMap`/`ToolOutputMap`,Python 为具名 `TypedDict`),每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度约定下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName` 和 `message`;Native 内容和内部错误码留在 Code 约定之外。程序的外层日志与返回值会重新进入模型上下文;当成功结算的子调用最终 Native 内容包含图片时,桥接层还会经父结果延后完整有序内容,避免图片被 JSON 专用绑定遮蔽。最终 post-execute 阻止或内容替换具有权威性。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。 在 `code`(而非 `both`)下,该传输同时也是模型唯一可用的入口:模型直呼其他任何可见工具名,都会在创建执行时、早于 `tools/pre-execute`、审批 `ask` 和 guards 解析为 `UNKNOWN_TOOL`,因此没有任何一方会观察或批准一个注定失败的调用。拒绝信息会给出正确路径(`only \`run_code\` is callable directly — call \`\` from inside a \`run_code\` program instead`),因为同一份提示词刚刚声明过那个工具,只说 `unknown tool` 会被读成部署损坏。SDK 子分发携带外层执行的 `parent` token,不受此限制,因此程序保留 SDK 声明的全部绑定。参见[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md)、[Code Mode 基础](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)、[类型化返回约定](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md)和[代码运行时 seam](../../code-runtime/README.md)。可以运行 `pnpm run demo:code-mode` 试用。 - **SDK 段**(`tools:sdk`,顺序 150):一个惰性提示词段,每次组装时都会重新生成与所加载运行时语言相符的 SDK 文本。TypeScript 形态发出 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap`、`ToolName`、`ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明;Python 形态(`ctx.codeRuntime.language === 'python'`)发出等价的具名 `TypedDict` 与一个带相同用法说明的 `tools` 对象。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache)。两个代码生成器都已导出,且绝不会在提示词组装期间抛出:`jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`;`jsonSchemaToPy` 同理,降级为 `Any`(当某字段名不是合法的 `TypedDict` 属性时,或在 SDK 渲染之外被调用时——`TypedDict` 声明所需的命名上下文由该渲染提供——整个对象降级为 `dict[str, Any]`)。 -- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系,并且即使程序后来失败,也会保留各自的来源/元数据。 +- **分发桥接层**(`run_code` 的 execute):每个绑定调用都会在分发前快照为无损 JSON(`undefined`、`BigInt`、循环、稀疏数组、`-0` 和特殊对象会使该次调用被拒绝),经由每次运行独有、复用原生并发约定的池调度——调用严格按提交顺序启动,连续的 `isConcurrencySafe` 调用最多可重叠经校验的 `maxParallelSubCalls` 配置个(默认 10;设为 `1` 即恢复串行分发),被分类为独占的调用先排空池、单独运行并阻挡其后的调用——以外层执行的不透明 token 作为 `parent`,并经过完整的 pre-execute → guards → execute → post-execute → result 流水线。成功会返回策略处理后的最终规范值;失败以一条消息到达 worker,并成为 `ToolCallError(toolName, message)`。每个已启动的子调用在进入流水线时记录一条 `tool/code-dispatch-start` 事件(确定性 id `:code:`,按提交顺序编号),并以一条携带完整模型可见 `content`/`isError` 结果的 `tool/code-dispatch` 事件完结(采用 `tool/result` 词汇,因此 UI 会沿原生路径呈现子调用——这对事件的 `time` 字段承载每个子调用的计时);因 run 结算而被放弃的排队调用两者都不记录。`deriveMessages()` 既不公开这两个事件,也不持久化规范值。token 关联让以提交为语义的观察器能够把内部成功延迟到最终 `run_code` 结果,而无需公开实时外层执行;普通工具副作用不会回滚。每个子调用的 `additionalContexts` 条目以及每份包含图片的成功最终内容序列都会按分发顺序通过外层 `ToolRunContext` 延迟;循环只在父级 `run_code` 结果之后追加这些上下文,从而保持相邻关系和来源归属,即使程序后来失败也不例外。 - **结算纪律**:桥接层拥有一个运行作用域的中止机制;该中止会跟随传入的外层信号,并在运行因任何原因结算时触发,因此预算耗尽会中止正在运行的子工具,而不会将其遗留。桥接层随后会在返回之前排空队列,使每个 `tool/code-dispatch` 都落在仍打开的轮次内。失败的运行会抛出 `CodeRunFailedError`(`code: 'CODE_RUN_FAILED'`,message = 失败类型 + 已捕获日志),流水线会将其转换为模型可据以自我修正的结构化 `isError`。 - **结果大小**:中间绑定值会完整传入 worker 进程,且没有逐绑定字节上限。`run_code` 返回规范的 `{ logs: string[], result?: JsonValue }`;字符串原样呈现,其他所有存在的 JSON 根都通过栈安全的美化 JSON 遍历呈现,总缩进最多为 10 个字符(更深的子树保持紧凑),`null` 保持显式,而缺少 `result` 表示程序返回 `undefined`。worker 可配置的 `maxOutputBytes`(默认 64 MiB)只应用于组合序列化后的外层日志数组、完成值或失败消息载荷;固定的结果 envelope 语法和呈现空白不计入该上限。无效和超限的完成会明确失败,只有此外层结果可以使用普通 spill。 @@ -177,7 +177,7 @@ The available tools: #### 模型看到的内容 -循环会保留模型发出的参数和注册表的最终内容。任何抛出或被拒绝的调用都会恰好变为 `Error: `。Code Mode 只返回外层程序打印的行和呈现后的返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (): `,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中;后置执行监听器可以在结果之后追加带来源归属的上下文。 +循环会保留模型发出的参数和注册表的最终内容。任何抛出或被拒绝的调用都会恰好变为 `Error: `。Code Mode 会渲染外层程序打印的行和返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (): `,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中,而成功且含图片的子结果会在外层结果之后作为带来源归属的上下文追加;后置执行监听器也可以在同一边界追加其他带来源归属的上下文。 #### Token 影响 diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 42c55ece03..c7ddb1c88c 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tools/src/code-mode */ -import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { CodeBindingFunction, CodeRunResult, CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' @@ -48,7 +48,7 @@ const TYPESCRIPT_FLAVOR: RunCodeFlavor = { 'Execute a TypeScript program against the available tools. Write the BODY of an ' + 'async function (erasable syntax only; top-level `await` and `return` work) and ' + 'call tools as `await tools.name(args)` per the declarations in the system prompt. ' - + 'Only what you print or return comes back — curate it.', + + 'Only what you print or return is program output; image-bearing subtool results are attached after the run.', codeDescription: 'The program: the body of an async TypeScript function.', } @@ -61,8 +61,9 @@ const PYTHON_FLAVOR: RunCodeFlavor = { description: 'Execute a Python program against the available tools. Write the BODY of an ' + 'async function (top-level `await` and `return` work) and call tools as ' - + '`await tools.name(args)` per the declarations in the system prompt. Answer ' - + 'with `print(...)` and/or `return ` — only that comes back, so curate it.', + + '`await tools.name(args)` per the declarations in the system prompt. Use ' + + '`print(...)` and/or `return ` for program output; image-bearing ' + + 'subtool results attach after the run.', codeDescription: 'The program: the body of an async Python function.', } @@ -557,6 +558,12 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge const result = parked.kind === 'post-result' ? await scheduler.finalize(parked.exec, parked.result) : scheduler.finish(parked.exec, parked.result) + if (!result.isError && result.content.some(block => block.type === 'image')) { + exec.deferContext(createUserMessage({ + content: result.content, + source: { kind: 'plugin', plugin: 'tools-code-mode' }, + })) + } for (const context of result.additionalContexts ?? []) { exec.deferContext(context) } diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 4898ec80e1..854ec20501 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -738,7 +738,7 @@ Pass \`run_code\` the body of an async Python function (top-level \`await\` and - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON. - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue. - Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`. -- Emit the run's answer with \`print(...)\` and/or a top-level \`return \`; the returned value must be lossless JSON. ONLY what you print and the returned value come back — intermediate tool results never enter the conversation, so extract just what you need. +- Emit the run's answer with \`print(...)\` and/or a top-level \`return \`; the returned value must be lossless JSON. Only what you print and return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. The available tools:` diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 9b0d096a22..ffd33101f0 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -254,7 +254,7 @@ Pass \`run_code\` the body of an async TypeScript function (erasable syntax only - Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue. - Independent read-only calls MAY overlap under \`Promise.all\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`. -- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. +- Emit results with \`return\` and/or \`console.log(...)\`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. The available tools:` diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 4379f7e0b2..cec43f9053 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1082,6 +1082,73 @@ describe('the run_code dispatch bridge', () => { ]) }) + it('defers image-bearing final sub-call content onto the outer run_code result', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + ctx.tools.register(defineContentToolFixture({ + name: 'image_result', + description: 'Return one durable image.', + parameters: {}, + execute: () => Promise.resolve([ + { type: 'text', text: 'image result' }, + { + type: 'image', + attachment: { + attachmentId: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as never, + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }, + ]), + })) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.image_result!({}) + return { logs: [], value: 'done' } + } + + const result = await runCode(ctx, 'program') + + expect(result.additionalContexts).toMatchObject([{ + role: 'user', + source: { kind: 'plugin', plugin: 'tools-code-mode' }, + content: [ + { type: 'text', text: 'image result' }, + { type: 'image', attachment: { mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ], + }]) + }) + + it('does not defer images removed by a nested post-execute decision', async () => { + for (const decision of ['block', 'replace'] as const) { + const { ctx, runtime } = await setup({ mode: 'code' }) + ctx.tools.register(defineContentToolFixture({ + name: 'image_result', + description: 'Return one durable image.', + parameters: {}, + execute: () => Promise.resolve([{ + type: 'image', + attachment: { + attachmentId: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as never, + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }]), + })) + ctx.on('tools/post-execute', (exec, _result, next): Promise => { + if (exec.name !== 'image_result') return next() + return Promise.resolve(decision === 'block' + ? { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] } + : { kind: 'accept', content: [{ type: 'text', text: 'replaced' }] }) + }) + runtime.behavior = async (request) => { + await request.bindings[0]!.functions.image_result!({}).catch(() => undefined) + return { logs: [], value: 'done' } + } + + const result = await runCode(ctx, 'program') + + expect(result.additionalContexts).toBeUndefined() + await ctx.fiber.dispose() + } + }) + it('keeps sub-call contexts when run_code fails after the nested dispatch', async () => { const { ctx, runtime } = await setup({ mode: 'both' }) registerEcho(ctx) diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index 85f481bf9f..4fa4aef2bf 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -15,7 +15,6 @@ import { basename, extname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' -import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -209,12 +208,6 @@ export function applyReadImageTool(ctx: Context): void { ...ref.name === undefined ? {} : { name: ref.name }, }, } - if (exec.parent !== undefined) { - exec.deferContext(createUserMessage({ - content: imageReadContent(value), - source: { kind: 'plugin', plugin: 'tool-fs' }, - })) - } return value }, // Pure display: a generic card in the read family with a follow-along From 49426cae02e9f0a638c06c58fd1001586bc5fa5b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:36:03 +0800 Subject: [PATCH 031/146] fix(mcp): project image results through durable attachments --- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 29 +- .../2026-07-07-mcp-client-plugin.zh.md | 29 +- packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 10 +- packages/mcp/mcp-client/README.zh.md | 10 +- packages/mcp/mcp-client/package.json | 3 + packages/mcp/mcp-client/src/tools.ts | 282 +++++++++++- .../mcp/mcp-client/tests/fixture-server.ts | 2 +- .../mcp/mcp-client/tests/mcp-client.e2e.ts | 60 ++- .../mcp/mcp-client/tests/mcp-client.spec.ts | 429 +++++++++++++++++- pnpm-lock.yaml | 6 + 12 files changed, 787 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index ec94f115bf..4cc00a5883 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 24828586645778294ad7bdafc6fd13a9bfb6745f -2026-07-07-mcp-client-plugin.zh.md: 8f58c9359ca8447717cc353f97f1333370fa6fda +2026-07-07-mcp-client-plugin.md: 9d1e12e23ee1140f8827612cc5156185959ccd4f +2026-07-07-mcp-client-plugin.zh.md: 4d41be9d96e64d5e12a318afe087e44df21b5009 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 2482858664..9d1e12e23e 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -141,11 +141,11 @@ Tools are never silently skipped; which tools are available never depends on plu A unified `execute` handler for all tools from one MCP server: 1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server. -2. Map the result: - - Multiple `text` content blocks → join with `'\n'` into a single `TextBlock` (required: `flattenText` uses `join('')` without separator, so multiple blocks would lose inter-block boundaries). - - `image` content blocks → discard with a `ctx.logger.warn` (the harness has no image content block type; [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)). - - `isError: true` → map to the harness `isError` result path (`{ content: [...], isError: true }`). -3. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, which sends `$/cancelRequest` to the server. +2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/Code Mode value. `isError: true` throws before any image persistence so the registry owns the failure path. +3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio, embedded resources, malformed blocks, and unknown types become explicit diagnostics. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references. +4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state. +5. Code Mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case. +6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate. ### Subprocess environment (stdio transport) @@ -189,13 +189,25 @@ Rejected. The remote name is untrusted, non-unique across deployments, and chang Rejected. `flattenText()` in the DeepSeek serializer uses `join('')` (no separator) when flattening `ContentBlock[]` to wire format. Multiple text blocks would silently lose inter-block boundaries — a correctness bug. All existing tools return a single TextBlock; the MCP bridge follows suit. +### Replace the canonical MCP result with core `ContentBlock[]` + +Rejected. Programmatic callers need protocol-complete MCP blocks and `structuredContent`, while Native consumers need durable core images rather than base64. One canonical protocol value plus a separate projection preserves both contracts. + +### Add a generic RichContent service or perform I/O in `output.render` + +Rejected. Core already owns the role-neutral content vocabulary, and a second service would duplicate its logging and ordering contracts. `output.render` is pure, synchronous, and replayable, so attachment I/O belongs in async execution with an exact finalization handoff. + +### Let each image-returning tool special-case Code Mode parents + +Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic Code Mode bridge observes the final post-policy content and forwards image-bearing results uniformly. + ## Testing Coverage is named per tier; each behavior lives at the cheapest tier that can express it. -- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package. -- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal. -- **Snapshot**: deliberately none. MCP tools introduce no new presentation shape — they register as raw `ToolDefinition`s and UI consumers use the generic-card fallback already pinned by their presentation suites. Adding an MCP server to a runnable snapshot composition would mutate its pinned system-prompt fixture and make every replay depend on spawning an external MCP server process for no new behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then. +- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, lossless canonical results, mixed rich ordering, atomic malformed batches, exact capability/store refusal, explicit non-image diagnostics, post-execute policy precedence, cancellation, and config schema validation. 100% per-file coverage gates the package. +- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, durable image save/read with base64 retained only in the canonical value, explicit refusal without an image route, duplicate-`serverName` rejection, and disposal. +- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the Code Mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot. ## Consequences @@ -206,3 +218,4 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex - **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's. - **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. - Crash recovery is automatic within the [reconnect budget](2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`. +- Image payloads can enter model context only through the shared durable attachment store and an exact positive route capability. Audio and embedded-resource payloads remain execution-local with explicit diagnostics. diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 8f58c9359c..4d41be9d96 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -141,11 +141,11 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器: 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 -2. 映射结果: - - 多个 `text` 内容块 → 以 `'\n'` 连接为单个 `TextBlock`(之所以必须这样做,是因为 `flattenText` 使用无分隔符的 `join('')`,多个内容块会丢失块间边界)。 - - `image` 内容块 → 丢弃并 `ctx.logger.warn`(harness 没有图片内容块类型;[删除图片 Agent Note](../simplification/2026-07-04-drop-image-content-block.md))。 - - `isError: true` → 映射到 harness 的 `isError` 结果路径(`{ content: [...], isError: true }`)。 -3. 取消:`exec.signal`(来自 agent loop(智能体循环)的取消)透传给 MCP SDK 的 `callTool`,后者向服务器发送 `$/cancelRequest`。 +2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/Code Mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。 +3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频、嵌入资源、格式错误的块和未知类型成为明确诊断。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。 +4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。 +5. Code Mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。 +6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。 ### 子进程环境(stdio 传输) @@ -189,13 +189,25 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha 否决。DeepSeek 序列化器中的 `flattenText()` 在将 `ContentBlock[]` 扁平化为协议格式(wire format)时使用 `join('')`(无分隔符)。多个 text 块会静默丢失块间边界——这是正确性缺陷。所有现有工具返回单个 TextBlock;MCP 桥接遵循同一做法。 +### 用核心 `ContentBlock[]` 替换规范 MCP 结果 + +不予采用。程序化调用方需要协议完整的 MCP 块和 `structuredContent`,Native 消费方则需要持久核心图片而不是 base64。一份规范协议值加一份独立投影可以同时保留两项契约。 + +### 添加通用 RichContent 服务,或在 `output.render` 中执行 I/O + +不予采用。核心已经拥有角色无关的内容词汇,第二套服务会重复其日志与顺序契约。`output.render` 必须纯净、同步且可回放,因此附件 I/O 属于异步执行,再经确切的最终化交接安装结果。 + +### 让每个返回图片的工具分别特殊处理 Code Mode 父调用 + +不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 Code Mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。 + ## 测试 覆盖范围按层级列出;每项行为都放在能够表达它的最低成本层级。 -- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。 -- **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose。 -- **快照**:刻意不做。MCP 工具不引入新的展示形态——它们以原始 `ToolDefinition` 注册,UI 消费方使用各自展示测试套件已固定的通用卡片兜底。将 MCP 服务器添加到某个可运行的快照组合会改变其已固定的系统提示词 fixture,且使每次回放依赖于 spawn 外部 MCP 服务器进程,而新增行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。 +- **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、无损规范结果、丰富内容混合顺序、格式错误批次原子性、确切能力/存储拒绝、明确的非图片诊断、post-execute 策略优先级、取消,以及配置 schema 校验。100% 逐文件覆盖率门禁约束该包。 +- **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、持久图片保存/读取且 base64 只保留在规范值中、缺少图片路由时明确拒绝、重复 `serverName` 拒绝,以及 dispose。 +- **快照**:组装后的 ACP 示例负责传输可见的内联图片 transcript 与 Code Mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照。 ## 后果 @@ -206,3 +218,4 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha - **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。 - **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。 - 崩溃恢复在[重连预算](2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。 +- 图片载荷只有通过共享持久附件存储和确切正向路由能力,才能进入模型上下文。音频与嵌入资源载荷仍只存在于执行局部,并附带明确诊断。 diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 67b937e0e2..cf715b23da 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/mcp/mcp-client/README.md -README.md: 266c3b7c2b38406800ae5dad1eb065c9dcbf50e6 -README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea +README.md: f3bf65d90d72f9eb3271cbbbb8ae8c586a7fd082 +README.zh.md: 1596ec72c28c4811eabb9f5cafe41cd7bdf1cf5e diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 266c3b7c2b..f3bf65d90d 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -65,7 +65,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - Listens for `notifications/tools/list_changed` → re-syncs; a fetch-phase failure keeps the previous generation registered, while a registration conflict rolls back the attempted generation and leaves no tools from that server. - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. -- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. +- Native/model rendering preserves MCP block order. Text-like runs join with newlines; resource links keep their name and URI as text; supported images become durable core image blocks only when `ctx.attachments` is mounted and the exact calling model route explicitly declares image input. The whole image batch is decoded and admitted before any member is saved. A malformed/refused image batch, audio, embedded resources, and unsupported blocks become explicit diagnostic text rather than disappearing. - On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery. - Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever. - Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior. @@ -75,6 +75,8 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` | Service | Usage | |---|---| | `ctx.tools` | Register/unregister MCP tools | +| `ctx.attachments` | Optionally validate and persist image result batches before model projection | +| `ctx.llm` | Optionally prove the exact calling route explicitly supports image input | ## Model Experience @@ -96,11 +98,11 @@ Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync #### What the model sees -The public tool name and JSON arguments remain in assistant history. Text result blocks are joined with newlines into one retained Native text result; image, audio, resource, and unsupported blocks become short placeholders there. Their full JSON blocks and optional structured content remain in the execution-local canonical value, and MCP `isError` rejects the call through the registry's error path. +The public tool name and JSON arguments remain in assistant history. The execution-local canonical value always retains the complete JSON MCP blocks and optional structured content for programmatic and Code Mode callers. In Native context, supported image blocks are durably projected beside text in their original order after exact route-capability proof; Code Mode additionally ferries that settled rich projection through the outer `run_code` result without changing the canonical binding value. Refused images, audio, embedded resources, resource links, and unknown blocks remain visible as bounded text diagnostics, and MCP `isError` rejects the call before image persistence. #### Token effect -Arguments and mapped text are retained until compaction. Binary and resource payloads are discarded rather than added to context. +Arguments, mapped text, and durable image references are retained until compaction. Inline MCP base64 stays only in the execution-local canonical value and is never copied into a session event; the provider reads verified bytes from the attachment store. Audio and embedded-resource payloads stay out of model context. #### KV Cache effect @@ -111,5 +113,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumer and are deferred. - **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles. - **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor. -- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. +- **Image is the only durable rich-result bridge** — PNG, JPEG, WebP, and GIF can enter Native context after exact capability proof. Audio and embedded-resource payloads remain execution-local with explicit diagnostics, while resource links preserve only their name and URI as text. - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 1b5b5c523e..1596ec72c2 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -65,7 +65,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - 监听 `notifications/tools/list_changed` → 重新同步;获取阶段失败时保留上一世代的注册,注册冲突则会回滚本次尝试的世代,并且不保留该服务器的任何工具。 - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 -- Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 +- Native/模型渲染会保留 MCP 块顺序。文本类连续块以换行连接;资源链接以文本保留名称和 URI;只有挂载 `ctx.attachments` 且确切调用模型路由明确声明支持图片输入时,受支持的图片才会成为持久核心图片块。整个图片批次会先完成解码与准入,再保存任一成员。格式错误或被拒绝的图片批次、音频、嵌入资源和不受支持的块会成为明确诊断文本,而不会消失。 - 断开/崩溃时:supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。 - 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。 - 重连状态在日志中对用户可见:reconnecting(warn,含尝试次数和延迟)、recovered(info)、最终失败和 disabled-loss(error)。dispose(资源释放)会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。 @@ -75,6 +75,8 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc | 服务 | 用途 | |---|---| | `ctx.tools` | 注册/注销 MCP 工具 | +| `ctx.attachments` | 可选;在模型投影前校验并持久保存图片结果批次 | +| `ctx.llm` | 可选;证明确切调用路由明确支持图片输入 | ## 模型体验 @@ -96,11 +98,11 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc #### 模型看到的内容 -公开工具名称和 JSON 参数会保留在 assistant 历史中。文本结果块会以换行连接为一个保留的 Native 文本结果;图片、音频、资源和不受支持的块在其中变为简短占位符。它们的完整 JSON 块及可选结构化内容保留在执行局部的规范值中;MCP `isError` 会通过注册表的错误路径拒绝调用。 +公开工具名称和 JSON 参数会保留在 assistant 历史中。执行局部的规范值始终为程序化调用方和 Code Mode 保留完整 JSON MCP 块及可选结构化内容。在 Native 上下文中,受支持的图片块会在确切路由能力得到证明后,按原始顺序与文本一起持久投影;Code Mode 还会经外层 `run_code` 结果转运这份已经结算的丰富投影,而不改变规范绑定值。被拒绝的图片、音频、嵌入资源、资源链接和未知块会继续以有界文本诊断可见;MCP `isError` 会在持久化图片前拒绝调用。 #### Token 影响 -参数和映射后的文本会保留到压缩(compaction)发生时。二进制与资源载荷会被丢弃,而不会加入上下文。 +参数、映射后的文本和持久图片引用会保留到压缩(compaction)发生时。内联 MCP base64 只存在于执行局部的规范值中,绝不会复制进会话事件;提供方会从附件存储读取经过校验的字节。音频和嵌入资源载荷仍不会进入模型上下文。 #### KV Cache 影响 @@ -111,5 +113,5 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 - **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。 - **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSE(Server-Sent Events)流恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。 -- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 +- **图片是唯一的持久丰富结果桥接**:PNG、JPEG、WebP 和 GIF 可以在确切能力得到证明后进入 Native 上下文。音频和嵌入资源载荷仍只存在于执行局部,并配有明确诊断;资源链接只以文本保留名称和 URI。 - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。 diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index cc68494a17..878c366695 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -32,6 +32,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", @@ -45,6 +46,8 @@ "zod": "^4.4.3" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 92862fa15f..aff1c19175 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -13,11 +13,14 @@ */ import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' import type { Context } from '@deepseek-ai/cordis' -import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools' @@ -53,6 +56,17 @@ const HASH_LENGTH = 12 /** Raw result record: the bridge owns JSON-value validation after transport. */ const RawCallToolResultSchema = z.record(z.string(), z.unknown()) +/** Raster formats supported by the durable attachment vocabulary. */ +const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +] + +/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */ +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + /** List without mutating the SDK's per-page output-validator cache. */ function listToolsUncached(client: Client, cursor?: string) { return client.request( @@ -143,13 +157,17 @@ export async function syncTools( `mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`, ) } - definitions.set(publicName, { - name: publicName, - description: tool.description ?? '', - parameters: tool.inputSchema, - output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)), - execute: createExecutor(client, tool.name, tool.execution?.taskSupport === 'required', opts), - }) + definitions.set(publicName, createDefinition( + client, + ctx, + publicName, + tool.name, + tool.description ?? '', + tool.inputSchema, + supportedOutputSchema(tool.outputSchema), + tool.execution?.taskSupport === 'required', + opts, + )) } cursor = response.nextCursor } while (cursor) @@ -183,6 +201,19 @@ interface McpContentBlock { type: string text?: string mimeType?: string + data?: string + name?: string + uri?: string +} + +/** Async rich projection staged for one exact ToolRegistry execution. */ +interface PreparedProjection { + /** Canonical MCP value returned by execute before registry materialization. */ + value: McpResult + /** Synchronous output.render projection expected before finalization. */ + fallback: ContentBlock[] + /** Image-enriched or explicit-refusal projection prepared during execute. */ + content: ContentBlock[] } /** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */ @@ -196,6 +227,49 @@ function supportedOutputSchema(candidate: unknown): JsonSchemaNode | undefined { } } +/** + * Build one generation-local tool definition and its execution-local rich projections. + * @param client - connected MCP client used for calls. + * @param ctx - plugin context carrying optional attachment and model services. + * @param publicName - registry-qualified public tool name. + * @param rawName - MCP wire tool name. + * @param description - model-facing tool description. + * @param parameters - MCP input schema. + * @param structuredSchema - supported structured-output schema, when advertised. + * @param taskRequired - whether this MCP tool requires unsupported task execution. + * @param opts - bridge timeout and namespace options. + * @returns a complete ToolRegistry definition. + */ +function createDefinition( + client: Client, + ctx: Context, + publicName: string, + rawName: string, + description: string, + parameters: Record, + structuredSchema: JsonSchemaNode | undefined, + taskRequired: boolean, + opts: ToolBridgeOptions, +): ToolDefinition { + const projections = new WeakMap() + return { + name: publicName, + description, + parameters, + output: createOutput(rawName, structuredSchema), + execute: createExecutor(client, ctx, rawName, taskRequired, opts, projections), + finalizeContent(exec: Readonly, result: Readonly) { + const projection = projections.get(exec) + if (projection === undefined) return undefined + projections.delete(exec) + if (result.isError) return undefined + if (!isDeepStrictEqual(result.value, projection.value)) return undefined + if (!isDeepStrictEqual(result.content, projection.fallback)) return undefined + return projection.content + }, + } +} + /** Build the canonical result schema and existing Native text projection. */ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefined): ToolDefinition['output'] { return { @@ -208,7 +282,7 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi required: structuredSchema === undefined ? ['content'] : ['content', 'structuredContent'], additionalProperties: false, }, - render(_args, value) { + render(_args: unknown, value: JsonValue) { const result = value as unknown as McpResult return [{ type: 'text', text: extractText(result.content, rawName) }] }, @@ -227,9 +301,11 @@ function createOutput(rawName: string, structuredSchema: JsonSchemaNode | undefi */ function createExecutor( client: Client, + ctx: Context, rawName: string, taskRequired: boolean, opts: ToolBridgeOptions, + projections: WeakMap, ): ToolDefinition['execute'] { return async (args: unknown, exec: ToolExecution) => { if (taskRequired) { @@ -268,12 +344,141 @@ function createExecutor( throw new Error(text) } - return { + const value: McpResult = { content, ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent as JsonValue } : {}, } + if (containsImage(content)) { + const fallback: ContentBlock[] = [{ type: 'text', text: extractText(content, rawName) }] + const projected = await prepareImageProjection(ctx, exec, content, rawName) + projections.set(exec, { value, fallback, content: projected }) + } + return value + } +} + +/** Whether an untrusted MCP content array contains a declared image block. */ +function containsImage(content: JsonValue[]): boolean { + return content.some(value => isRecord(value) && value.type === 'image') +} + +/** Narrow one JSON value to a string-keyed object. */ +function isRecord(value: JsonValue): value is { [key: string]: JsonValue } { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Narrow a declared MIME string to the durable image vocabulary. */ +function isImageMediaType(value: string): value is ImageMediaType { + return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType) +} + +/** Decode one untrusted MCP image block without accepting base64 aliases. */ +function decodeImage(block: McpContentBlock): SaveImageAttachment { + if (block.mimeType === undefined || !isImageMediaType(block.mimeType)) { + throw new Error('the declared media type is not PNG, JPEG, WebP, or GIF') + } + if (block.data === undefined || !CANONICAL_BASE64.test(block.data)) { + throw new Error('the image data is not canonical base64') + } + const data = Buffer.from(block.data, 'base64') + if (data.toString('base64') !== block.data) { + throw new Error('the image data is not canonical base64') + } + return { data, mediaType: block.mimeType } +} + +/** + * Resolve the active model route and durable store for an image-bearing result. + * @param ctx - plugin context with optional services. + * @param exec - exact tool execution whose agent supplies the latest route. + * @returns the attachment store after exact positive image-capability proof. + */ +async function resolveImageAdmission(ctx: Context, exec: ToolExecution): Promise { + const attachments = ctx.get('attachments') + if (attachments === undefined) throw new Error('no attachment store is mounted') + const routed = exec.agent?.session.requestHeader()?.config + const provider = routed?.provider ?? exec.agent?.options.provider + const model = routed?.model ?? exec.agent?.options.model + const llm = ctx.get('llm') + if (provider === undefined || model === undefined || llm === undefined) { + throw new Error('the current model route could not be resolved') + } + let info: Awaited> + try { + info = await llm.resolveModelInfo(provider, model, exec.signal) + } catch { + throw new Error('the current model route could not be verified') + } + if (info.inputModalities === undefined || !info.inputModalities.includes('image')) { + throw new Error(`model "${model}" does not declare image input`) + } + if (exec.signal.aborted) throw new Error('the tool call was canceled before image storage') + return attachments +} + +/** Stable diagnostic text for an image block that was not admitted. */ +function imageDiagnostic(block: McpContentBlock, reason: string): string { + const mediaType = block.mimeType ?? 'unknown media type' + return `[image unavailable: ${mediaType}; ${reason}; raw image data remains available to programmatic callers]` +} + +/** + * Decode, preflight, and durably save one MCP result's ordered image batch. + * Any refusal projects every image as text while retaining the canonical raw + * value for programmatic callers. + */ +async function prepareImageProjection( + ctx: Context, + exec: ToolExecution, + content: JsonValue[], + toolName: string, +): Promise { + const decoded: SaveImageAttachment[] = [] + const validationErrors = new Map() + const imageIndexes: number[] = [] + for (const [index, value] of content.entries()) { + if (!isRecord(value) || value.type !== 'image') continue + imageIndexes.push(index) + try { + decoded.push(decodeImage(value as unknown as McpContentBlock)) + } catch (error: unknown) { + // decodeImage owns every throw above and always produces Error. + validationErrors.set(index, (error as Error).message) + } + } + if (validationErrors.size > 0) { + return projectContent(content, toolName, (block, index) => ({ + type: 'text', + text: imageDiagnostic( + block, + validationErrors.get(index) ?? 'another image in the same result was invalid', + ), + })) + } + + let attachments: AttachmentStore + try { + attachments = await resolveImageAdmission(ctx, exec) + } catch (error: unknown) { + // resolveImageAdmission contains provider failures and throws Error only. + const reason = (error as Error).message + return projectContent(content, toolName, block => ({ type: 'text', text: imageDiagnostic(block, reason) })) + } + + try { + const refs = await attachments.saveImages(decoded) + const byIndex = new Map(imageIndexes.map((index, offset) => [index, refs[offset] as ImageAttachmentRef] as const)) + return projectContent(content, toolName, (_block, index) => ({ + type: 'image', + attachment: byIndex.get(index) as ImageAttachmentRef, + })) + } catch { + return projectContent(content, toolName, block => ({ + type: 'text', + text: imageDiagnostic(block, 'durable image storage rejected the result'), + })) } } @@ -286,32 +491,65 @@ function createExecutor( * guarded with fallbacks because this is a network trust boundary. */ function extractText(mcpContent: JsonValue[], toolName: string): string { - const parts: string[] = [] + const content = projectContent(mcpContent, toolName) + // The default image projector below also returns text, so this local call + // cannot produce a core image block. + return content.map(block => (block as Extract).text).join('\n') +} - for (const value of mcpContent) { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - parts.push('[unsupported content type: unknown]') +/** + * Project ordered MCP blocks into the core content vocabulary. + * Text-like runs are newline-coalesced; admitted images split those runs at + * their original position. + */ +function projectContent( + mcpContent: JsonValue[], + toolName: string, + image: (block: McpContentBlock, index: number) => ContentBlock = block => ({ + type: 'text', + text: imageDiagnostic(block, 'this result was not admitted to durable model context'), + }), +): ContentBlock[] { + const projected: ContentBlock[] = [] + const text: string[] = [] + const flushText = (): void => { + if (text.length === 0) return + projected.push({ type: 'text', text: text.splice(0).join('\n') }) + } + + for (const [index, value] of mcpContent.entries()) { + if (!isRecord(value)) { + text.push('[unsupported MCP content block: expected an object]') continue } const block = value as unknown as McpContentBlock switch (block.type) { case 'text': - if (block.text !== undefined) parts.push(block.text) + if (block.text !== undefined) text.push(block.text) break case 'image': - parts.push(`[image: ${block.mimeType ?? 'unknown'}, content discarded]`) + flushText() + projected.push(image(block, index)) + break + case 'resource_link': + if (block.name === undefined || block.uri === undefined) { + text.push('[resource link unavailable: the MCP block is missing its name or URI]') + } else { + text.push(`Resource link: ${block.name} (${block.uri})`) + } break case 'audio': - parts.push(`[audio: ${block.mimeType ?? 'unknown'}, content discarded]`) + text.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}; raw audio data remains available to programmatic callers]`) break case 'resource': - case 'resource_link': - parts.push('[resource: content discarded]') + text.push('[embedded resource unsupported; raw resource data remains available to programmatic callers]') break default: - parts.push(`[unsupported content type: ${block.type}]`) + text.push(`[unsupported MCP content type: ${block.type}]`) } } - - return parts.join('\n') || `(${toolName} returned no text content)` + flushText() + return projected.length > 0 + ? projected + : [{ type: 'text', text: `(${toolName} returned no model-visible content)` }] } diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts index 974e2a26b0..a2e9b5b5f0 100644 --- a/packages/mcp/mcp-client/tests/fixture-server.ts +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -46,7 +46,7 @@ server.registerTool('image', { }, async () => ({ content: [ { type: 'text', text: 'Here is an image:' }, - { type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }, + { type: 'image', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', mimeType: 'image/png' }, { type: 'text', text: 'End of image.' }, ], })) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 34d0970258..bcb97bd448 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -19,9 +19,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { z } from 'zod' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { CallId } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' @@ -43,6 +45,33 @@ async function mountRegistry(): Promise { return ctx } +/** Exact-route adapter used to prove real MCP image admission without an API key. */ +class ImageAdapter extends LlmAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] }) + } + + stream(_options: GenerateOptions): AsyncIterable { + throw new Error('MCP image e2e never streams') + } +} + +async function mountImageRegistry(dshHome: string): Promise { + const ctx = await mountRegistry() + await ctx.plugin(LocalAttachmentStore, { dshHome }) + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['visual'], new ImageAdapter()) + return ctx +} + +/** Calling-agent stand-in pinned to the keyless image-capable route. */ +function imageAgent(): object { + return { + options: { provider: 'visual', model: 'vision' }, + session: { requestHeader: () => undefined }, + } +} + function sleep(ms: number): Promise { const gate: PromiseWithResolvers = Promise.withResolvers() setTimeout(gate.resolve, ms) @@ -66,6 +95,7 @@ function nextCallId(): CallId { describe('fixture server — controlled scenarios', () => { let ctx: Context + let home: string const fixtureConfig: Config = { transport: 'stdio', @@ -79,13 +109,15 @@ describe('fixture server — controlled scenarios', () => { } beforeAll(async () => { - ctx = await mountRegistry() + home = await mkdtemp(join(tmpdir(), 'mcp-image-e2e-')) + ctx = await mountImageRegistry(home) await apply(ctx, fixtureConfig) }, 30_000) afterAll(async () => { if (ctx) await ctx.fiber.dispose() await sleep(200) + await rm(home, { recursive: true, force: true }) }) it('discovers all fixture tools under the server namespace', () => { @@ -141,16 +173,23 @@ describe('fixture server — controlled scenarios', () => { expect(result.content[0]).toMatchObject({ type: 'text' }) }) - it('executes image() → image placeholder', async () => { + it('executes image() → ordered durable image content', async () => { const result = await ctx.tools.execute({ - signal: testToolSignal, + signal: testToolSignal, agent: imageAgent() as never, callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, }) expect(result.isError).toBe(false) - const text = textOf(result.content[0]) - expect(text).toContain('Here is an image:') - expect(text).toContain('[image: image/png, content discarded]') - expect(text).toContain('End of image.') + expect(result.content).toHaveLength(3) + expect(result.content[0]).toEqual({ type: 'text', text: 'Here is an image:' }) + expect(result.content[2]).toEqual({ type: 'text', text: 'End of image.' }) + const image = result.content[1] + if (image?.type !== 'image') throw new Error(`expected an image block, got ${JSON.stringify(image)}`) + expect(image.attachment).toMatchObject({ mediaType: 'image/png', width: 1, height: 1 }) + const stored = await ctx.attachments.readImage(image.attachment) + expect(stored.data.byteLength).toBe(image.attachment.bytes) + if (result.isError) throw new Error('expected MCP image success') + expect(JSON.stringify(result.value)).toContain('iVBORw0KGgo') + expect(JSON.stringify(result.content)).not.toContain('iVBORw0KGgo') }) }) @@ -334,13 +373,14 @@ describe('server-everything — official test server', () => { expect(textOf(result.content[0])).toContain('10') }) - it('executes get-tiny-image → image placeholder', async () => { + it('executes get-tiny-image → explicit refusal without a durable route', async () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, }) expect(result.isError).toBe(false) - expect(textOf(result.content[0])).toContain('[image: image/png, content discarded]') + expect(result.content.map(block => block.type === 'text' ? block.text : '').join('\n')) + .toContain('[image unavailable: image/png; no attachment store is mounted;') }) }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index aa97ba34ce..4ef535cfd7 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -2,9 +2,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { Context } from '@deepseek-ai/cordis' -import { CallId } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision } from '@deepseek-ai/dsh-tools' import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/dsh-mcp-client/src/tools.ts' import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts' import type { Config } from '@deepseek-ai/dsh-mcp-client' @@ -63,6 +68,79 @@ async function mountRegistry(): Promise { return ctx } +const IMAGE_LIMITS: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 4, + maxMessageImageBytes: 2048, + maxImagePixels: 1024, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], +} + +/** Attachment fake that records exact decoded batches while using the real batch contract. */ +class RecordingAttachmentStore extends AttachmentStore { + readonly imageLimits = IMAGE_LIMITS + readonly saved: SaveImageAttachment[] = [] + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(input: SaveImageAttachment): Promise { + this.saved.push(input) + const marker = input.data[0] ?? 0 + return Promise.resolve({ + attachmentId: AttachmentId(`sha256:${marker.toString(16).padStart(64, '0')}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + }) + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('not used') + } +} + +/** Exact-route fake used only for image-capability admission. */ +class ImageCatalogAdapter extends LlmAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + inputModalities: model === 'vision' ? ['text', 'image'] : ['text'], + }) + } + + stream(_options: GenerateOptions): AsyncIterable { + throw new Error('MCP bridge tests never stream') + } +} + +async function mountRichRegistry(): Promise<{ ctx: Context; attachments: RecordingAttachmentStore }> { + const ctx = await mountRegistry() + await ctx.plugin(RecordingAttachmentStore) + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['visual'], new ImageCatalogAdapter()) + return { ctx, attachments: ctx.attachments as RecordingAttachmentStore } +} + +/** Calling-agent stand-in with no durable request header yet. */ +function agentOn(model: string | undefined = 'vision'): object { + return { + options: model === undefined ? {} : { provider: 'visual', model }, + session: { requestHeader: () => undefined }, + } +} + +/** Require one text block and return its text for diagnostic assertions. */ +function textAt(content: readonly ContentBlock[], index = 0): string { + const block = content[index] + if (block?.type !== 'text') throw new Error(`expected text content at index ${index}`) + return block.text +} + const defaultOpts: ToolBridgeOptions = { registrationFailure: 'contain', serverName: 'srv', @@ -363,24 +441,306 @@ describe('tool execution', () => { expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) - it('preserves full JSON MCP blocks while Native rendering uses placeholders', async () => { + it('preserves canonical MCP JSON while admitting an ordered mixed image result', async () => { + const rich = await mountRichRegistry() const blocks = [ { type: 'text', text: 'before' }, - { type: 'image', mimeType: 'image/png', data: 'base64-data', annotations: { audience: ['assistant'] } }, + { type: 'image', mimeType: 'image/png', data: 'AQ==', annotations: { audience: ['assistant'] } }, + { type: 'text', text: 'between' }, + { type: 'image', mimeType: 'image/jpeg', data: 'Ag==' }, + { type: 'text', text: 'after' }, ] satisfies JsonValue[] const client = createMockClient( [{ name: 'img', inputSchema: { type: 'object' } }], { content: blocks }, ) - await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} }) + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('c1'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) - expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' }) + expect(result.content.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text']) + expect(result.content[0]).toEqual({ type: 'text', text: 'before' }) + expect(result.content[2]).toEqual({ type: 'text', text: 'between' }) + expect(result.content[4]).toEqual({ type: 'text', text: 'after' }) + const firstImage = result.content[1] + const secondImage = result.content[3] + if (firstImage?.type !== 'image' || secondImage?.type !== 'image') throw new Error('expected ordered image blocks') + expect(firstImage.attachment.mediaType).toBe('image/png') + expect(firstImage.attachment.bytes).toBe(1) + expect(secondImage.attachment.mediaType).toBe('image/jpeg') + expect(secondImage.attachment.bytes).toBe(1) + expect(rich.attachments.saved.map(input => [...input.data])).toEqual([[1], [2]]) + expect(JSON.stringify(result.content)).not.toContain('AQ==') + expect(JSON.stringify(result.content)).not.toContain('Ag==') if (result.isError) throw new Error('expected MCP success') expect(result.value).toEqual({ content: blocks }) }) + it('keeps a valid raw image result while explicitly refusing it without a durable route', async () => { + const blocks = [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] satisfies JsonValue[] + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: blocks }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('no-store'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + + expect(result.content).toEqual([{ + type: 'text', + text: '[image unavailable: image/png; no attachment store is mounted; raw image data remains available to programmatic callers]', + }]) + if (result.isError) throw new Error('image refusal must preserve MCP success') + expect(result.value).toEqual({ content: blocks }) + }) + + it('rejects a malformed image batch before storing any member', async () => { + const rich = await mountRichRegistry() + const blocks = [ + { type: 'image', mimeType: 'image/png', data: 'AQ==' }, + { type: 'image', mimeType: 'image/png', data: 'not base64' }, + ] satisfies JsonValue[] + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: blocks }, + ) + + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('bad-batch'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + + expect(rich.attachments.saved).toEqual([]) + expect(result.content).toHaveLength(2) + expect(textAt(result.content, 0)).toContain('another image in the same result was invalid') + expect(textAt(result.content, 1)).toContain('not canonical base64') + }) + + it('rejects non-canonical and incomplete image blocks as one atomic batch', async () => { + const rich = await mountRichRegistry() + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [ + { type: 'image', mimeType: 'image/tiff', data: 'AQ==' }, + { type: 'image', mimeType: 'image/png', data: 'AB==' }, + { type: 'image', mimeType: 'image/png' }, + ] }, + ) + + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('strict-batch'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + + expect(rich.attachments.saved).toEqual([]) + expect(result.content).toHaveLength(3) + expect(textAt(result.content, 0)).toContain('not PNG, JPEG, WebP, or GIF') + expect(textAt(result.content, 1)).toContain('not canonical base64') + expect(textAt(result.content, 2)).toContain('not canonical base64') + }) + + it('does not admit images for a route without declared image input', async () => { + const rich = await mountRichRegistry() + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, + ) + + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('text-route'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn('text') as never, + }) + + expect(rich.attachments.saved).toEqual([]) + expect(textAt(result.content)).toContain('does not declare image input') + }) + + it('refuses images when the exact route is missing, unverifiable, or canceled', async () => { + const rich = await mountRichRegistry() + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, + ) + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + + const noProvider = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('no-provider'), + name: 'mcp__srv__img', + arguments: {}, + agent: { options: { model: 'vision' }, session: { requestHeader: () => undefined } } as never, + }) + expect(textAt(noProvider.content)).toContain('route could not be resolved') + + const noModel = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('no-model'), + name: 'mcp__srv__img', + arguments: {}, + agent: { options: { provider: 'visual' }, session: { requestHeader: () => undefined } } as never, + }) + expect(textAt(noModel.content)).toContain('route could not be resolved') + + const noLlmCtx = await mountRegistry() + await noLlmCtx.plugin(RecordingAttachmentStore) + await syncTools(client as never, noLlmCtx, defaultOpts, new Map()) + const noLlm = await noLlmCtx.tools.execute({ + signal: testToolSignal, + callId: CallId('no-llm'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + expect(textAt(noLlm.content)).toContain('route could not be resolved') + + vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockRejectedValueOnce(new Error('catalog down')) + const unverified = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('unverified'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + expect(textAt(unverified.content)).toContain('route could not be verified') + + vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockResolvedValueOnce({ + provider: 'visual', id: 'vision', name: 'vision', + }) + const unknown = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('unknown-modalities'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + expect(textAt(unknown.content)).toContain('does not declare image input') + + const controller = new AbortController() + vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockImplementationOnce(async (provider, model) => { + controller.abort(new Error('stop')) + return { provider, id: model, name: model, inputModalities: ['text', 'image'] } + }) + const canceled = await rich.ctx.tools.execute({ + signal: controller.signal, + callId: CallId('canceled'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + expect(canceled.isError).toBe(true) + expect(canceled.content[0]).toEqual({ type: 'text', text: 'Error: tool call aborted' }) + expect(rich.attachments.saved).toEqual([]) + }) + + it('refuses images when attachment storage rejects the admitted batch', async () => { + const rich = await mountRichRegistry() + vi.spyOn(rich.attachments, 'saveImages').mockRejectedValueOnce(new Error('disk full')) + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, + ) + + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('store-rejected'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + + expect(textAt(result.content)).toContain('durable image storage rejected the result') + }) + + it('lets post-execute replacement win over a prepared image projection', async () => { + const rich = await mountRichRegistry() + rich.ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'accept', + content: [{ type: 'text', text: 'policy replacement' }], + })) + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, + ) + + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('replaced'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + + expect(rich.attachments.saved).toHaveLength(1) + expect(result.content).toEqual([{ type: 'text', text: 'policy replacement' }]) + }) + + it('lets post-execute value replacement and blocking discard prepared projections', async () => { + const valueRich = await mountRichRegistry() + valueRich.ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'accept', + value: { content: [{ type: 'text', text: 'value replacement' }] }, + })) + const valueClient = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, + ) + await syncTools(valueClient as never, valueRich.ctx, defaultOpts, new Map()) + const replaced = await valueRich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('value-replaced'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + expect(replaced.content).toEqual([{ type: 'text', text: 'value replacement' }]) + + const blockedRich = await mountRichRegistry() + blockedRich.ctx.on('tools/post-execute', async (): Promise => ({ + kind: 'block', + feedback: [{ type: 'text', text: 'blocked by policy' }], + })) + const blockedClient = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'Ag==' }] }, + ) + await syncTools(blockedClient as never, blockedRich.ctx, defaultOpts, new Map()) + const blocked = await blockedRich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('blocked'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + expect(blocked.isError).toBe(true) + expect(blocked.content).toEqual([{ type: 'text', text: 'blocked by policy' }]) + }) + it('preserves primitive JSON MCP blocks while Native rendering marks them unsupported', async () => { const blocks = [42, null, ['nested']] satisfies JsonValue[] const client = createMockClient( @@ -396,7 +756,7 @@ describe('tool execution', () => { expect(result.content[0]).toEqual({ type: 'text', - text: '[unsupported content type: unknown]\n[unsupported content type: unknown]\n[unsupported content type: unknown]', + text: '[unsupported MCP content block: expected an object]\n[unsupported MCP content block: expected an object]\n[unsupported MCP content block: expected an object]', }) if (result.isError) throw new Error('expected primitive MCP blocks to remain a successful JSON value') expect(result.value).toEqual({ content: blocks }) @@ -547,7 +907,7 @@ describe('tool execution edge cases', () => { ctx = await mountRegistry() }) - it('handles audio content with placeholder', async () => { + it('reports unsupported audio without claiming the raw block was discarded', async () => { const client = createMockClient( [{ name: 'audio_tool', inputSchema: { type: 'object' } }], { content: [{ type: 'audio', mimeType: 'audio/mp3' }] }, @@ -556,10 +916,13 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' }) + expect(result.content[0]).toEqual({ + type: 'text', + text: '[audio result unsupported: audio/mp3; raw audio data remains available to programmatic callers]', + }) }) - it('handles resource content with placeholder', async () => { + it('reports unsupported embedded resources without discarding the raw block', async () => { const client = createMockClient( [{ name: 'res_tool', inputSchema: { type: 'object' } }], { content: [{ type: 'resource' }] }, @@ -568,19 +931,36 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) + expect(result.content[0]).toEqual({ + type: 'text', + text: '[embedded resource unsupported; raw resource data remains available to programmatic callers]', + }) }) - it('handles resource_link content with placeholder', async () => { + it('preserves resource-link name and URI in the model projection', async () => { const client = createMockClient( [{ name: 'link_tool', inputSchema: { type: 'object' } }], - { content: [{ type: 'resource_link' }] }, + { content: [{ type: 'resource_link', name: 'Design', uri: 'https://example.test/design' }] }, ) await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' }) + expect(result.content[0]).toEqual({ type: 'text', text: 'Resource link: Design (https://example.test/design)' }) + }) + + it('diagnoses an incomplete resource link', async () => { + const client = createMockClient( + [{ name: 'link_tool', inputSchema: { type: 'object' } }], + { content: [{ type: 'resource_link', name: 'Missing URI' }] }, + ) + + await syncTools(client as never, ctx, defaultOpts, new Map()) + const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('missing-link'), name: 'mcp__srv__link_tool', arguments: {} }) + + expect(result.content[0]).toEqual({ + type: 'text', text: '[resource link unavailable: the MCP block is missing its name or URI]', + }) }) it('handles unknown content types', async () => { @@ -592,7 +972,7 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' }) + expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported MCP content type: video]' }) }) it('handles image with missing mimeType (buggy server)', async () => { @@ -604,7 +984,10 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' }) + expect(result.content[0]).toEqual({ + type: 'text', + text: '[image unavailable: unknown media type; the declared media type is not PNG, JPEG, WebP, or GIF; raw image data remains available to programmatic callers]', + }) }) it('handles audio with missing mimeType (buggy server)', async () => { @@ -616,7 +999,10 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' }) + expect(result.content[0]).toEqual({ + type: 'text', + text: '[audio result unsupported: unknown media type; raw audio data remains available to programmatic callers]', + }) }) it('handles text block with missing text (buggy server)', async () => { @@ -628,7 +1014,7 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' }) + expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no model-visible content)' }) }) it('handles empty content array', async () => { @@ -640,7 +1026,7 @@ describe('tool execution edge cases', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) - expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' }) + expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no model-visible content)' }) }) @@ -678,7 +1064,10 @@ describe('tool execution edge cases', () => { const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) expect(result.isError).toBe(true) - expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' }) + expect(result.content[0]).toEqual({ + type: 'text', + text: 'Error: [image unavailable: image/png; this result was not admitted to durable model context; raw image data remains available to programmatic callers]', + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab7b3dae65..8bfc8937e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5521,6 +5521,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-attachment-local': + specifier: workspace:^ + version: link:../../attachment/attachment-local '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From 4f87c1fe6d6911809aaaaf0c30e4ceeeef5c13ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:36:22 +0800 Subject: [PATCH 032/146] feat(acp): bridge durable image prompts and replies --- ...-23-acp-automation-only-protocol.i18n.yaml | 4 +- ...2026-07-23-acp-automation-only-protocol.md | 20 +- ...6-07-23-acp-automation-only-protocol.zh.md | 20 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../code-mode-image.cordis.snapshot.yml | 42 ++ examples/acp-agent/code-mode-image.cordis.yml | 29 ++ examples/acp-agent/tests/acp.snapshot.ts | 18 + .../snapshots/code-mode-read-image/input.json | 14 + .../code-mode-read-image/session.jsonl | 33 ++ .../stdout.expected.jsonl | 4 + .../system-prompt.expected.md | 457 ++++++++++++++++++ .../snapshots/inline-image-prompt/input.json | 28 ++ .../inline-image-prompt/session.jsonl | 17 + .../inline-image-prompt/stdout.expected.jsonl | 4 + .../read-image/stdout.expected.jsonl | 2 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 22 +- packages/acp/acp/README.zh.md | 22 +- packages/acp/acp/package.json | 3 + packages/acp/acp/src/codec.ts | 34 +- packages/acp/acp/src/content.ts | 238 +++++++++ packages/acp/acp/src/index.ts | 301 ++++++++---- packages/acp/acp/tests/bridge.spec.ts | 83 +++- packages/acp/acp/tests/codec.spec.ts | 10 +- packages/acp/acp/tests/content.spec.ts | 232 +++++++++ packages/acp/acp/tests/dispose.spec.ts | 32 ++ packages/acp/acp/tests/edges.spec.ts | 45 ++ packages/acp/acp/tests/harness.ts | 76 ++- packages/acp/acp/tests/turns.spec.ts | 191 +++++++- .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 8 + .../acp-snapshot/tests/harness.spec.ts | 19 + pnpm-lock.yaml | 3 + 37 files changed, 1808 insertions(+), 223 deletions(-) create mode 100644 examples/acp-agent/code-mode-image.cordis.snapshot.yml create mode 100644 examples/acp-agent/code-mode-image.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/code-mode-read-image/input.json create mode 100644 examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/inline-image-prompt/input.json create mode 100644 examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl create mode 100644 packages/acp/acp/src/content.ts create mode 100644 packages/acp/acp/tests/content.spec.ts diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index 3cc2da0976..966be9e743 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: 56dcaf8b4327a008f26b884264958cac02d6541a -2026-07-23-acp-automation-only-protocol.zh.md: a34e179a1d38c1867ea8165b149a9ec7716c1c0e +2026-07-23-acp-automation-only-protocol.md: 3d13e3fb51819ef4f892f33f9c86554988576e36 +2026-07-23-acp-automation-only-protocol.zh.md: 224c1bd611aae23937f5610665c4bd316e15c425 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 56dcaf8b43..3d13e3fb51 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -8,15 +8,17 @@ English | [中文](2026-07-23-acp-automation-only-protocol.zh.md) The ACP bridge had become a second interactive product UI. It translated durable events into editor cards, terminal metadata, diffs, plans, titles, reasoning, commands, modes, model and permission pickers, session navigation, and human elicitation. Those responsibilities duplicated the TUI and the Web client while coupling an automation transport to UI services, persistence queries, presentation policy, and editor-specific conventions. -ACP still has one useful role: another agent or automated controller can start a harness process, create an isolated session, send text, receive the committed answer, cancel work, and answer a permission request. The out-of-process ACP subagent backend depends on that standard protocol boundary. +ACP still has one useful role: another agent or automated controller can start a harness process, create an isolated session, send text or a narrowly supported inline image, receive the committed text/image answer, cancel work, and answer a permission request. The out-of-process ACP subagent backend depends on that standard protocol boundary. The snapshot suite complicates removal. Most ACP scenarios exercise the assembled agent backend rather than ACP presentation, so deleting the suite with the editor bridge would discard broad keyless behavioral coverage. ## Decision -`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh text sessions with one in-flight prompt each, committed assistant text updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts carry the spec-required baseline only — text plus resource links flattened to bracketed textual references; the bridge rejects additional directories, MCP servers, beyond-baseline prompt content (image, audio, embedded resources), empty prompts, unknown sessions, and overlapping prompts. +`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh sessions with one in-flight prompt each, committed assistant text/image updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge rejects additional directories, MCP servers, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts. -The bridge emits only committed `assistant/message` text. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. +Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each image prompt rechecks the session's latest exact route, strictly decodes every block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; a completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. + +The bridge emits only committed `assistant/message` text and images. A per-session promise chain preserves block and message order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. @@ -24,13 +26,13 @@ The app composition contains the agent spine, persistence, checkpoint policy, an The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output. -Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle. +Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure cancel prompt admission and agents, drain ordered output, settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle. ## Snapshot boundary The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions. -Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiation, fresh-session creation, text and resource-link flattening, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement, per-session cancellation, failed transport closure, ACP-only reload cleanup, and teardown quiescence. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. +Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthful image capability, fresh-session creation, ordered text/image admission, resource-link flattening, all-member validation before writes, absence of inline base64 in durable events, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement after ordered output, verified assistant-image delivery, cancellation during admission without a late followup, failed transport closure, ACP-only reload cleanup, and teardown quiescence. An assembled keyless snapshot sends a real inline PNG through the runnable ACP example and pins only its durable reference in the session log. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. ## Alternatives considered @@ -44,10 +46,16 @@ Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiat **Delete the ACP snapshot suite or migrate every scenario in this change.** Rejected because most scenarios test the backend and remain valuable, while a full harness migration is an independent testing change. Only scenarios whose driver was a deleted UI method leave this suite. +**Advertise image support whenever the ACP SDK has an image block.** Rejected because protocol vocabulary does not prove this deployment can persist bytes or that the configured exact route accepts visual input. Unknown capability is false at initialization; prompt admission rechecks the live route. + +**Flatten inline and assistant images to markers or persist ACP base64 in session events.** Rejected because markers silently lose model/user intent and base64 makes durable logs the binary store. ACP translates between its wire block and the existing durable `ImageBlock` reference at the transport boundary. + +**Create a generic RichContent service for ACP, MCP, and Web.** Rejected because core `ContentBlock` plus the attachment seam already own the shared contract. Each front door keeps only protocol parsing, capability proof, and lifecycle orchestration; shared batch limits and image validation stay in `AttachmentStore.saveImages()`. + ## Consequences ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. -Automation clients receive complete committed text rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. +Automation clients receive complete committed text/images rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. Backend snapshot coverage therefore remains transport-coupled to ACP even though that transport is incidental to the behavior under test. diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index a34e179a1d..224c1bd611 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -8,15 +8,17 @@ Status: implemented ACP(Agent Client Protocol)桥接层已经变成第二套交互式产品 UI。它将持久事件转换为编辑器卡片、终端元数据、diff、计划、标题、推理(reasoning)、命令、模式、模型和权限选择器、会话导航以及面向人类的询问。这些职责与 TUI 和 Web 客户端重复,同时将自动化传输层与 UI 服务、持久化查询、展示策略和编辑器特定约定耦合在一起。 -ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控制器可以启动 harness 进程、创建隔离会话、发送文本、接收已提交的回答、取消工作并回答权限请求。跨进程 ACP subagent 后端依赖这个标准协议边界。 +ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控制器可以启动 harness 进程、创建隔离会话、发送文本或范围狭窄的受支持内联图片、接收已提交的文本/图片回答、取消工作并回答权限请求。跨进程 ACP subagent 后端依赖这个标准协议边界。 快照套件使移除工作更复杂。大多数 ACP 场景测试的是组装后的 agent 后端,而不是 ACP 展示层;如果随编辑器桥接层一起删除整个套件,就会丢失大量无密钥行为测试。 ## 决策 -`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新文本会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词只承载规范要求的基线内容——文本,加上被展平为方括号文本引用的资源链接;桥接层会拒绝附加目录、MCP 服务器、超出基线的提示词内容(图片、音频、内嵌资源)、空提示词、未知会话和重叠提示词。 +`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本/图片更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层会拒绝附加目录、MCP 服务器、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。 -桥接层只发出已提交的 `assistant/message` 文本。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 +图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个图片提示词都会重新检查会话的最新确切路由、严格解码全部块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。 + +桥接层只发出已提交的 `assistant/message` 文本与图片。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时保持块与消息顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中登记的同一 agent 对象;外部请求或缺少调用标识的请求会继续委派;RPC 失败则映射为拒绝请求的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 @@ -24,13 +26,13 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。 -断开连接与插件 dispose(资源释放)共享同一个经记忆化的完全停稳边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。 +断开连接与插件 dispose(资源释放)共享同一个经记忆化的完全停稳边界。传输关闭无论成功还是失败,都会取消提示词准入和 agent、排空有序输出、将待处理提示词以已取消状态结算、dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。 ## 快照边界 ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 -协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 +协议与生命周期测试会锁定停止原因编解码器、版本协商、真实图片能力、新会话创建、有序文本/图片准入、资源链接展平、写入前校验全部成员、持久事件中不含内联 base64、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、在有序输出后结算提示词、经过校验的助手图片交付、准入期间取消且不产生迟到 followup、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。组装后的无密钥快照通过可运行 ACP 示例发送一张真实内联 PNG,并在会话日志中只固定其持久引用。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 ## 考虑过的替代方案 @@ -44,10 +46,16 @@ ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后 **删除 ACP 快照套件,或在本次变更中迁移每个场景。** 不予采用,因为大多数场景测试后端且仍有价值,而完整的 harness 迁移是一项独立的测试变更。只有通过已删除的 UI 方法驱动的场景才离开该套件。 +**只要 ACP SDK 具有图片块就公布图片支持。** 不予采用,因为协议词汇不能证明当前部署可以持久化字节,也不能证明配置的确切路由接受视觉输入。初始化时能力未知即为 false;提示词准入会重新检查实时路由。 + +**把内联图片和助手图片展平为标记,或把 ACP base64 持久化进会话事件。** 不予采用,因为标记会静默丢失模型/用户意图,base64 则会让持久日志变成二进制存储。ACP 在传输边界把自身协议块与现有持久 `ImageBlock` 引用相互转换。 + +**为 ACP、MCP 和 Web 创建通用 RichContent 服务。** 不予采用,因为核心 `ContentBlock` 与附件 seam 已经拥有共享契约。每个入口只保留协议解析、能力证明与生命周期编排;共享批次限制和图片校验留在 `AttachmentStore.saveImages()` 中。 + ## 结果 ACP 具有适合 agent 与自动化的精简约定,而 TUI 和 Web 拥有面向人类的交互与展示。该包注入的服务、依赖、协议分支和生命周期状态更少,也不再将自身定位为通用编辑器入口。 -自动化客户端收到完整的已提交文本,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。 +自动化客户端收到完整的已提交文本/图片,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。 因此,后端快照测试仍与 ACP 传输层耦合,尽管对于受测行为而言,该传输层只是附带因素。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ceba64b2c0..38167f6f59 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 369490ec41480b8c46355f0edc3eb97f2f0c76cb -config-catalog.zh.md: a735d7021d44dda3cffa49c31430249f70431720 +config-catalog.md: 27ed96659fd23bd33495a632aa0fd552f44e0163 +config-catalog.zh.md: 01a74bd8f06dcce5b5281f12796b151246b73f35 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 369490ec41..27ed96659f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/acp/acp/src/index.ts:70`](../packages/acp/acp/src/index.ts) +Source: [`packages/acp/acp/src/index.ts:71`](../packages/acp/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a735d7021d..01a74bd8f0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -29,7 +29,7 @@ export interface AcpConfig { 依赖:`Stream`(`@agentclientprotocol/sdk`) -来源:[`packages/acp/acp/src/index.ts:70`](../packages/acp/acp/src/index.ts) +来源:[`packages/acp/acp/src/index.ts:71`](../packages/acp/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/examples/acp-agent/code-mode-image.cordis.snapshot.yml b/examples/acp-agent/code-mode-image.cordis.snapshot.yml new file mode 100644 index 0000000000..42dda231f7 --- /dev/null +++ b/examples/acp-agent/code-mode-image.cordis.snapshot.yml @@ -0,0 +1,42 @@ +# Keyless replay combines Code Mode with the durable image store and an exact +# image-capable replay route. The scenario generates its tiny PNG inside the +# run_code program, then exercises read_image as a nested dispatch. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + tools: + mode: code + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text, image] + - id: deepseek-v4-pro + inputModalities: [text] diff --git a/examples/acp-agent/code-mode-image.cordis.yml b/examples/acp-agent/code-mode-image.cordis.yml new file mode 100644 index 0000000000..e51984b354 --- /dev/null +++ b/examples/acp-agent/code-mode-image.cordis.yml @@ -0,0 +1,29 @@ +# Code Mode image overlay: mounts the worker runtime and durable attachment +# store so a nested read_image result can cross the generic rich-result bridge. +# The authored snapshot is replay-only; the live config retains the ordinary +# exact provider route for manual use. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + tools: + mode: code + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 736b17ed04..357472dd77 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -32,6 +32,7 @@ const AGENT = { // The Code Mode overlay configs (include-patched variants of cordis.yml; the // replay swap resolves each one's sibling `*cordis.snapshot.yml`). const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) +const CODE_MODE_IMAGE_CONFIG = fileURLToPath(new URL('../code-mode-image.cordis.yml', import.meta.url)) const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url)) const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url)) @@ -212,6 +213,13 @@ const SCENARIOS: Scenario[] = [ headerClass: 'image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, + { + name: 'inline-image-prompt', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_CONFIG, + }, { name: 'pty-tools', hasModelTurn: true, @@ -539,6 +547,16 @@ const SCENARIOS: Scenario[] = [ // tools:sdk section rides in the prompt, and the program's tool calls land as // tool/code-dispatch events. Each overlay composes and pins its own header class. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG }, + { + name: 'code-mode-read-image', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'code-image', + toolSchemasSource: 'code-mode-turn', + configPath: CODE_MODE_IMAGE_CONFIG, + posixOnly: true, + }, // A nested fs dispatch inside run_code discovers workspace instructions. The // projection enters the inbox after the outer result and becomes model-visible // on the following step, retaining workspace provenance end to end. diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/input.json b/examples/acp-agent/tests/snapshots/code-mode-read-image/input.json new file mode 100644 index 0000000000..f04f56a70e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl new file mode 100644 index 0000000000..cb63f29c34 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1783952000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786431644501,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"}]}} +{"type":"turn/start","seq":1,"time":1786431644502,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786431644502,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786431644557,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786431644558,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}} +{"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}} +{"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} +{"type":"tool/code-dispatch-start","seq":17,"time":1786431644829,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:2","name":"read_image","arguments":{"file_path":"red.png"}}} +{"type":"tool/code-dispatch","seq":18,"time":1786431644871,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:2","name":"read_image","arguments":{"file_path":"red.png"},"isError":false,"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}]}} +{"type":"tool/result","seq":19,"time":1786431644874,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"code-image-call"},"content":[{"type":"tool-result","toolCallId":"code-image-call","content":[{"type":"text","text":"{{cwd}}/red.png"}],"isError":false}],"role":"user","id":"73e999fa-4aab-4609-970d-4c675e3557f1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","seq":20,"time":1786431644874,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"99bca54a-c323-4df8-8695-7ef17d02dd65"}]}} +{"type":"step/end","seq":21,"time":1786431644874,"data":{"turn":1,"step":1}} +{"type":"agent/inbox/spliced","seq":22,"time":1786431644874,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":23,"time":1786431644884,"data":{"turn":1,"step":2}} +{"type":"user/message","seq":24,"time":1786431644885,"data":{"content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"source":{"kind":"plugin","plugin":"tools-code-mode"},"role":"user","id":"99bca54a-c323-4df8-8695-7ef17d02dd65"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":25,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl new file mode 100644 index 0000000000..4f0fb2e442 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md new file mode 100644 index 0000000000..3dde6f9f77 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -0,0 +1,457 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. + +The available tools: + +```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +interface ToolArgsMap { + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + bash: { + /** The bash command to execute. */ + command: string; + /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ + description: string; + /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ + timeoutMs?: number; + /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ + workdir?: string; + /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */ + run_in_background?: boolean; + /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ + justification?: string; + } & Record; + /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ + create_goal: { + /** The concrete completion objective inferred from the direct human request. */ + objective: string; + /** Optional positive safe-integer limit on automatic continuation rounds. */ + max_goal_rounds?: number; + } & Record; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + } & Record; + /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ + get_goal: Record; + /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */ + interrupt_agent: { + /** The agent id of the running agent to interrupt. */ + agent_id: string; + } & Record; + /** List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */ + list_agents: { + /** children (default) lists direct children only; descendants walks the complete tree below you. */ + scope?: "children" | "descendants"; + } & Record; + /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ + ralph: { + /** The immutable completion objective for every fresh Ralph round. */ + objective: string; + /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ + maxRounds?: number; + } & Record; + /** Read a UTF-8 text file and return line-numbered content. */ + read: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + } & Record; + /** Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input. */ + read_image: { + /** Path to the image file, resolved by the filesystem backend. */ + file_path: string; + } & Record; + /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */ + send_message: { + /** The subagent id returned when the background subagent was started. */ + subagent_id: string; + /** The message to deliver to the subagent. */ + message: string; + } & Record; + /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ + skill: { + /** The exact skill name from the available skills list. */ + name: string; + } & Record; + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + subagent: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ + prompt: string; + /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ + run_in_background?: boolean; + } & Record; + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + subagent_fork: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ + prompt: string; + /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ + run_in_background?: boolean; + } & Record; + /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ + task_kill: { + /** Task id returned by the tool that started the background work. */ + task_id: string; + /** Optional short reason, recorded in the log and forwarded to the task. */ + reason?: string; + } & Record; + /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ + task_list: Record; + /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ + task_output: { + /** Task id returned by the tool that started the background work. */ + task_id: string; + /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ + wait?: boolean; + /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ + timeout_ms?: number; + } & Record; + /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ + todo_write: { + /** The COMPLETE task list, replacing any previous list. */ + todos: ({ + /** What the task is — a short imperative line. */ + content: string; + /** pending (not started) | in_progress (now) | completed (done). */ + status: "pending" | "in_progress" | "completed"; + })[]; + } & Record; + /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ + update_goal: { + /** Exact id returned by get_goal. */ + goal_id: string; + /** Exact positive revision returned by get_goal. */ + revision: number; + /** edit | pause | resume | complete | blocked */ + action: "edit" | "pause" | "resume" | "complete" | "blocked"; + /** Replacement objective; valid only with action edit. */ + objective?: string; + /** Replacement cap; valid only with action edit. */ + max_goal_rounds?: number; + /** Concrete blocking condition; required only with action blocked. */ + blocked_reason?: string; + } & Record; + /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ + workflow: { + /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ + script: string; + /** The workflow identity block (plain JSON — never code). */ + meta: { + /** Short kebab-case workflow name. */ + name: string; + /** One-line description of what the workflow does. */ + description: string; + /** Optional guidance on when this workflow applies. */ + whenToUse?: string; + /** Optional phase declarations matched by phase() calls. */ + phases?: ({ + /** The phase title phase() calls match by exact string. */ + title: string; + /** Optional one-line description of the phase. */ + detail?: string; + /** Optional provider override this phase is expected to use. */ + provider?: string; + /** Optional model override this phase is expected to use. */ + model?: string; + } & Record)[]; + } & Record; + /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ + args?: Record; + } & Record; + /** Create or fully replace a UTF-8 text file. */ + write: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ + sandbox_permissions?: "workspace-write" | "danger-full-access"; + /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ + justification?: string; + } & Record; +} + +interface ToolOutputMap { + bash: { + kind: "background"; + taskId: string; + } | { + kind: "foreground"; + exitCode: number | null; + signal: string | null; + timedOut: boolean; + aborted: boolean; + timeoutMs: number; + stdout: { + text: string; + truncated: boolean; + spillPath?: string; + }; + stderr: { + text: string; + truncated: boolean; + spillPath?: string; + }; + sandbox?: { + mode: string; + denied: boolean; + enforcement?: string; + runnerFailed?: boolean; + }; + }; + create_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + edit: { + path: string; + before: string; + after: string; + }; + get_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + interrupt_agent: { + accepted: boolean; + }; + list_agents: ({ + kind: "child"; + id: string; + label: string; + status: "running" | "idle" | "complete"; + parent?: string; + depth?: number; + } | { + kind: "diagnostic"; + id: string; + reason: "corrupt" | "unsupported" | "unavailable"; + parent?: string; + depth?: number; + })[]; + ralph: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + read: { + path: string; + offset: number; + lines: { + number: number; + text: string; + }[]; + totalLines: number; + }; + read_image: { + path: string; + image: { + attachmentId: string; + mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif"; + bytes: number; + width: number; + height: number; + name?: string; + }; + }; + send_message: { + messageId: string; + }; + skill: { + name: string; + provider: string; + resourceBase?: { + kind: "directory"; + path: string; + } | { + kind: "url"; + url: string; + } | { + kind: "opaque"; + description: string; + }; + content: string; + }; + subagent: { + kind: "background"; + taskId: string; + } | { + kind: "continuable"; + subagentId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + subagent_fork: { + kind: "background"; + taskId: string; + } | { + kind: "continuable"; + subagentId: string; + } | { + kind: "foreground"; + runId: string; + output: JsonValue[]; + }; + task_kill: { + outcome: "cancellation-requested" | "already-finished"; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + task_list: ({ + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + })[]; + task_output: { + text: string; + task: { + id: string; + kind: string; + label: string; + status: "running" | "stopping" | "completed" | "killed" | "failed"; + detail?: string; + startedAt: number; + finishedAt?: number; + }; + }; + todo_write: { + todos: ({ + content: string; + status: "pending" | "in_progress" | "completed"; + })[]; + counts: { + pending: number; + inProgress: number; + completed: number; + }; + }; + update_goal: { + goal: null; + } | { + goal: { + id: string; + revision: number; + objective: string; + phase: "active" | "paused" | "blocked" | "complete"; + roundsStarted: number; + maxGoalRounds: number; + blockedReason?: { + code: string; + message: string; + }; + }; + activation: "armed" | "disarmed"; + }; + workflow: { + runId: string; + agentsStarted: number; + result: JsonValue; + }; + write: { + path: string; + operation: "create" | "update"; + before: string | null; + after: string; + }; +} + +type ToolName = keyof ToolOutputMap + +declare class ToolCallError extends Error { + readonly name: "ToolCallError"; + readonly toolName: ToolName; +} + +declare const tools: { + [K in ToolName]: (args: ToolArgsMap[K]) => Promise; +} +``` diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/input.json b/examples/acp-agent/tests/snapshots/inline-image-prompt/input.json new file mode 100644 index 0000000000..5f6e2cb13e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/input.json @@ -0,0 +1,28 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "promptContent", + "content": [ + { + "type": "text", + "text": "Inspect this image, then reply with exactly " + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "text", + "text": "the single word DONE." + } + ] + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl new file mode 100644 index 0000000000..89cffe656d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"44444444-4444-4444-8444-444444444444","createdAt":1783952000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783952000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783952000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783952000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783952000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1783952000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl new file mode 100644 index 0000000000..4f0fb2e442 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl index 82ae8907ca..4f0fb2e442 100644 --- a/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 1b303a23a8..1a39a39562 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/acp/acp/README.md -README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893 -README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17 +README.md: 40d4b2df18f8102a352d8a8eb438e88da7fe720c +README.zh.md: 57b7e5a3f987861cfe0c5453f5d5a26d565f77ed diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 9cc4a5e271..40d4b2df18 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). +Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text/image prompts, collect committed assistant text/images, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules. @@ -21,21 +21,21 @@ Both fields are optional so another agent/request listener may supply the target | Method | Behavior | |---|---| -| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. | +| `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. No session, editor, terminal, filesystem, or MCP capability is advertised. | | `authenticate` | No-op because the server advertises no authentication methods. | | `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. | -| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | -| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. | -| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. | +| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission, whole-agent idle, and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | +| `session/cancel` | Cancels only the addressed agent and marks any already-started admission so the pending prompt waits for it to quiesce, publishes no late user message, and settles as `cancelled`; unknown ids are no-ops. | +| `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. Raw deltas and non-message events are omitted. | | `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. | One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer. -Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text; reasoning and tool activity remain in the session log for observability through other interfaces. +Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text or images; reasoning and tool activity remain in the session log for observability through other interfaces. Per-session delivery is serialized because attachment reads are asynchronous, and a missing or corrupt committed image fails the prompt response instead of emitting a placeholder. ## Lifecycle -Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. +Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately. @@ -45,15 +45,15 @@ ACP requires each prompt response to carry a `stopReason`, but the bridge does n ## Model Experience -### Prompt text +### Prompt text and images #### What the model sees -`session/prompt` text blocks are concatenated verbatim into one user message; a baseline resource link appears in that message as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request. +`session/prompt` preserves text/image order in one user message; adjacent text is concatenated, and a resource link appears as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Inline image base64 is discarded after batch admission, so the durable message contains only verified attachment references. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request. #### Token effect -Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts. +Prompt tokens and image charges are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts. #### KV Cache effect @@ -76,6 +76,6 @@ Append-only through the owning tool result. ## Known Limitations and Deferred Work - **Fresh sessions only** — load, list, resume, delete, and fork are unsupported. -- **Baseline prompts and one workspace only** — images, audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content. +- **Raster images and one workspace only** — image prompts require a durable store plus an exact route that declares image input; only PNG, JPEG, WebP, and GIF are accepted. Audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content. - **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire. - **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index eafae5602b..57b7e5a3f9 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 +通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本/图片提示词、收集已提交的 assistant 文本/图片、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。 @@ -21,21 +21,21 @@ | 方法 | 行为 | |---|---| -| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | +| `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频与嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | | `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 | -| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | -| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | +| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入、整个 agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 | +| `session/cancel` | 仅取消指定的 agent,并标记已经启动的准入工作,使待处理提示词等待其停稳、不发布迟到的用户消息,随后以 `cancelled` 结算;未知 id 为空操作。 | +| `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 | | `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | 一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。 -已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 +已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本或图片;推理与工具活动仍保留在会话日志中,以便其他界面观测。由于附件读取是异步的,每个会话会串行交付内容;已提交图片缺失或损坏时,提示词响应会失败,而不会发出占位符。 ## 生命周期 -客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 +客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。 @@ -45,15 +45,15 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它 ## 模型体验 -### 提示词文本 +### 提示词文本与图片 #### 模型看到的内容 -`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。 +`session/prompt` 会在一条用户消息中保留文本/图片顺序;相邻文本会拼接,资源链接则表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。内联图片 base64 在批量准入后即被丢弃,因此持久消息只包含经过校验的附件引用。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。 #### Token 影响 -提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。 +提示词 token 与图片费用取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。 #### KV Cache 影响 @@ -76,6 +76,6 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它 ## 已知限制与暂缓事项 - **仅新会话**:不支持加载、列出、恢复、删除和 fork。 -- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。 +- **仅光栅图片和一个 workspace**:图片提示词要求持久存储以及明确声明支持图片输入的确切路由;只接受 PNG、JPEG、WebP 和 GIF。音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。 - **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。 - **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。 diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index ff5a52bc63..ba95a2cf44 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -36,13 +36,16 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 9fcdb68f7b..151756a03e 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-acp/codec */ -import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk' +import type { StopReason } from '@agentclientprotocol/sdk' import type { TurnEndReason } from '@deepseek-ai/dsh-session' /** @@ -32,35 +32,3 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'end_turn' } } - -/** - * Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate - * verbatim; resource links become explicit textual references so a baseline - * client can point at files without the bridge silently dropping that context. - * @param prompt - supported ACP prompt blocks. - * @returns text in wire order, with resource links rendered as bracketed references. - */ -export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { - return prompt.flatMap((block): string[] => { - switch (block.type) { - case 'text': - return [block.text] - case 'resource_link': - return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`] - default: - return [] - } - }).join('') -} - -/** - * Whether a prompt carries content beyond the ACP baseline. The spec requires - * every agent to accept `text` and `resource_link`; richer inline payloads - * (image, audio, embedded resource) are optional capabilities this bridge does - * not advertise, so they are rejected rather than silently dropped. - * @param prompt - ACP prompt blocks to inspect. - * @returns `true` when any block is neither `text` nor `resource_link`. - */ -export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean { - return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link') -} diff --git a/packages/acp/acp/src/content.ts b/packages/acp/acp/src/content.ts new file mode 100644 index 0000000000..56e027a1b7 --- /dev/null +++ b/packages/acp/acp/src/content.ts @@ -0,0 +1,238 @@ +/** ACP wire-content admission and projection owned by the ACP adapter. @module */ + +import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' +import type { Context } from '@deepseek-ai/cordis' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +/** Raster formats shared by ACP image blocks and the core attachment vocabulary. */ +const IMAGE_MEDIA_TYPES: readonly ImageMediaType[] = [ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +] + +/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */ +const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +/** Content-admission failure category used by the protocol handler. */ +export type AcpContentFailureKind = 'invalid' | 'internal' + +/** Error with a stable ACP request-failure category and no raw binary payload. */ +export class AcpContentError extends Error { + /** Whether the bridge should report invalid params or an internal failure. */ + readonly kind: AcpContentFailureKind + + /** + * @param message - safe protocol-facing detail without inline binary data. + * @param kind - request-failure category. + * @param options - optional causal chain for diagnostics. + */ + constructor(message: string, kind: AcpContentFailureKind, options?: ErrorOptions) { + super(message, options) + this.name = 'AcpContentError' + this.kind = kind + } +} + +/** Narrow a wire MIME string to the durable raster vocabulary. */ +function imageMediaType(value: string): ImageMediaType | undefined { + return IMAGE_MEDIA_TYPES.includes(value as ImageMediaType) ? value as ImageMediaType : undefined +} + +/** Strictly decode one ACP inline image without accepting base64 aliases. */ +function decodeImage(block: Extract): SaveImageAttachment { + const mediaType = imageMediaType(block.mimeType) + if (mediaType === undefined) { + throw new AcpContentError('image mimeType must be image/png, image/jpeg, image/webp, or image/gif', 'invalid') + } + if (!CANONICAL_BASE64.test(block.data)) { + throw new AcpContentError('image data must be canonical base64', 'invalid') + } + const data = Buffer.from(block.data, 'base64') + if (data.toString('base64') !== block.data) { + throw new AcpContentError('image data must be canonical base64', 'invalid') + } + return { data, mediaType } +} + +/** Resolve the exact current route and require explicit image input support. */ +async function assertImageRoute(ctx: Context, agent: Agent, signal: AbortSignal): Promise { + const routed = agent.session.requestHeader()?.config + const provider = routed?.provider ?? agent.options.provider + const model = routed?.model ?? agent.options.model + const llm = ctx.get('llm') + if (provider === undefined || model === undefined || llm === undefined) { + throw new AcpContentError('the current model route could not be resolved for image input', 'invalid') + } + let info: Awaited> + try { + info = await llm.resolveModelInfo(provider, model, signal) + } catch (error: unknown) { + throw new AcpContentError('the current model route could not be verified for image input', 'invalid', { cause: error }) + } + if (info.inputModalities === undefined || !info.inputModalities.includes('image')) { + throw new AcpContentError(`model "${model}" does not declare image input`, 'invalid') + } +} + +/** + * Determine whether initialization may truthfully advertise inline image prompts. + * Unknown service, route, capability, or deployment media support is negative. + * @param ctx - bridge context carrying optional attachment and model services. + * @param provider - configured provider route used for newly created sessions. + * @param model - configured exact model id used for newly created sessions. + * @returns whether this bridge can admit images at initialization time. + */ +export async function supportsAcpImagePrompts( + ctx: Context, + provider: string | undefined, + model: string | undefined, +): Promise { + const attachments = ctx.get('attachments') + const llm = ctx.get('llm') + if (attachments === undefined || llm === undefined || provider === undefined || model === undefined) return false + if (!attachments.imageLimits.mediaTypes.some(mediaType => IMAGE_MEDIA_TYPES.includes(mediaType))) return false + try { + const info = await llm.resolveModelInfo(provider, model) + return info.inputModalities?.includes('image') === true + } catch { + return false + } +} + +/** Render one baseline resource link into the core's current text vocabulary. */ +function resourceLinkText(block: Extract): string { + return `\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n` +} + +/** + * Admit one ACP prompt into ordered durable core content. + * Every wire block and image is validated before the ordered image batch starts + * writing; cancellation after a successful content-addressed write may leave an + * unreachable object but never queues a late user message. + * @param ctx - bridge context carrying attachment and model services. + * @param agent - destination agent whose latest exact route controls admission. + * @param prompt - untrusted ACP prompt blocks in wire order. + * @param imageEnabled - capability result advertised during initialization. + * @param signal - admission cancellation signal. + * @returns core content with durable image references in wire order. + */ +export async function admitAcpPrompt( + ctx: Context, + agent: Agent, + prompt: readonly AcpContentBlock[], + imageEnabled: boolean, + signal: AbortSignal, +): Promise { + const images: SaveImageAttachment[] = [] + for (const block of prompt) { + switch (block.type) { + case 'text': + case 'resource_link': + break + case 'image': + if (!imageEnabled) throw new AcpContentError('inline image prompts were not advertised by this connection', 'invalid') + images.push(decodeImage(block)) + break + case 'audio': + throw new AcpContentError('audio prompt content is not supported', 'invalid') + case 'resource': + throw new AcpContentError('embedded resource prompt content is not supported', 'invalid') + /* v8 ignore next 2 -- ACP ContentBlock is a closed generated union. */ + default: + throw new AcpContentError('unsupported ACP prompt content', 'invalid') + } + } + + let refs: readonly ImageAttachmentRef[] = [] + if (images.length > 0) { + const attachments = ctx.get('attachments') + if (attachments === undefined) throw new AcpContentError('no attachment store is mounted', 'invalid') + await assertImageRoute(ctx, agent, signal) + signal.throwIfAborted() + try { + refs = await attachments.saveImages(images) + } catch (error: unknown) { + if (error instanceof AttachmentError && error.code !== 'ATTACHMENT_WRITE_FAILED') { + throw new AcpContentError(error.message, 'invalid', { cause: error }) + } + throw new AcpContentError('unable to persist the prompt image batch', 'internal', { cause: error }) + } + signal.throwIfAborted() + } + + const content: ContentBlock[] = [] + let pendingText = '' + let imageIndex = 0 + const flushText = (): void => { + if (pendingText.length === 0) return + content.push({ type: 'text', text: pendingText }) + pendingText = '' + } + for (const block of prompt) { + switch (block.type) { + case 'text': + pendingText += block.text + break + case 'resource_link': + pendingText += resourceLinkText(block) + break + case 'image': { + flushText() + const ref = refs[imageIndex++] as ImageAttachmentRef + content.push({ type: 'image', attachment: ref }) + break + } + /* v8 ignore start -- the validation pass above rejects both tags before reconstruction. */ + case 'audio': + case 'resource': + break + /* v8 ignore stop */ + /* v8 ignore next 2 -- validated by the first closed-union switch. */ + default: + break + } + } + flushText() + if (!content.some(block => block.type === 'image' || (block.type === 'text' && block.text.trim().length > 0))) { + throw new AcpContentError('empty prompt', 'invalid') + } + return content +} + +/** + * Translate one committed assistant block to ACP wire content. + * Images are re-read and integrity-verified before inline base64 delivery; + * unsupported core output blocks stay off the automation wire. + * @param ctx - bridge context carrying the authoritative attachment store. + * @param block - committed core assistant block. + * @returns ACP text/image content, or undefined for non-output blocks. + */ +export async function assistantBlockToAcp( + ctx: Context, + block: ContentBlock, +): Promise { + if (block.type === 'text') { + return block.text.length === 0 ? undefined : { type: 'text', text: block.text } + } + if (block.type !== 'image') return undefined + const attachments = ctx.get('attachments') + if (attachments === undefined) { + throw new AcpContentError('cannot deliver assistant image: no attachment store is mounted', 'internal') + } + let stored: Awaited> + try { + stored = await attachments.readImage(block.attachment) + } catch (error: unknown) { + throw new AcpContentError('cannot deliver assistant image: the attachment is unavailable or corrupt', 'internal', { cause: error }) + } + return { + type: 'image', + data: Buffer.from(stored.data).toString('base64'), + mimeType: stored.ref.mediaType, + } +} diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index d595c69e69..eeef146165 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -2,9 +2,9 @@ * Automation-only Agent Client Protocol server over JSON-RPC stdio. * * The bridge exposes fresh harness sessions to trusted programmatic clients. It - * carries prompt text, committed assistant text, cancellation, and one-shot - * permission decisions; presentation and human-interaction features stay with - * the harness's UI modules. + * carries prompt text/images, committed assistant text/images, cancellation, + * and one-shot permission decisions; presentation and human-interaction + * features stay with the harness's UI modules. * * @module @deepseek-ai/dsh-acp */ @@ -37,7 +37,8 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' // Side-effect type import: declaration-merges the approval waterfall answered below. import type {} from '@deepseek-ai/dsh-user-approval' -import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts' +import { AcpContentError, admitAcpPrompt, assistantBlockToAcp, supportsAcpImagePrompts } from './content.ts' +import { turnEndToStopReason } from './codec.ts' export const name = 'acp' /** The bridge creates and owns agents; every other concern is carried by the agent composition. */ @@ -86,14 +87,27 @@ interface SessionRecord { agent: Agent /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ dispose: () => Promise - /** In-flight prompt and its captured turn number for exact settlement. */ + /** Ordered assistant-output delivery; every task contains its own failure. */ + outputTail: Promise + /** In-flight admission/turn/output lifecycle for exact settlement. */ inflight: { resolve: (reason: StopReason) => void reject: (error: Error) => void - messageId: string + /** Set only after rich-content admission succeeds and the message is built. */ + messageId: string | undefined turn: number | undefined /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ endReason: TurnEndReason | undefined + /** Admission quiescence gate, including any attachment write already in progress. */ + admissionDone: Promise + finishAdmission: () => void + admissionController: AbortController + cancelRequested: boolean + settlementStarted: boolean + /** Conversion failure for committed output owned by this prompt's turn. */ + outputError: Error | undefined + /** Failure before a correlated turn exists. */ + agentError: Error | undefined } | undefined } @@ -110,6 +124,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const sessions = new Map() let closed = false let conn: AgentSideConnection + let imagePromptEnabled = false /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ const ownedRecord = (agent: Agent): SessionRecord | undefined => { @@ -127,19 +142,15 @@ export function apply(ctx: Context, config: AcpConfig): void { return record } - /** Send a protocol update without letting a disconnected client fail an agent turn. */ - const notify = (notification: SessionNotification): void => { - /* v8 ignore next 3 -- only a transport write failure reaches this guard. */ - void conn.sessionUpdate(notification).catch((error: unknown) => { + /** Send one ordered protocol update while containing transport-only failure. */ + const notify = async (notification: SessionNotification): Promise => { + try { + await conn.sessionUpdate(notification) + /* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */ + } catch (error: unknown) { logger.warn(`acp: session/update failed: ${String(error)}`) - }) - } - - const settlePrompt = (record: SessionRecord, reason: StopReason): void => { - const inflight = record.inflight - if (inflight === undefined) return - record.inflight = undefined - inflight.resolve(reason) + } + /* v8 ignore stop */ } const rejectFromError = ( @@ -149,48 +160,89 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.reject(internalError(`turn failed: ${reason.error.message}`)) } - // Emit only committed assistant text. Raw chunks, reasoning, tools, plans, - // titles, and retry markers are presentation or trace data and stay off the - // automation wire. + /** + * Settle one exact prompt only after admission, agent activity, and ordered + * assistant delivery have all reached quiescence. + */ + const settleAfterQuiescence = ( + record: SessionRecord, + inflight: NonNullable, + ): void => { + if (inflight.settlementStarted) return + inflight.settlementStarted = true + void (async () => { + await inflight.admissionDone + await record.agent.whenIdle() + // session/event enqueues synchronously before the agent becomes idle; + // reading the live tail here includes every committed output task. + await record.outputTail + /* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */ + if (record.inflight !== inflight) return + record.inflight = undefined + if (inflight.cancelRequested) { + inflight.resolve('cancelled') + return + } + if (inflight.outputError !== undefined) { + inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`)) + return + } + if (inflight.agentError !== undefined) { + inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`)) + return + } + const end = inflight.endReason + if (end === undefined) { + inflight.resolve('cancelled') + } else if (end.kind === 'error') { + rejectFromError(inflight, end) + } else { + // Token-limit and other non-terminal endings are not prompt-level stop + // reasons; ordinary quiescence reports end_turn. + inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) + } + })() + /* v8 ignore start -- admissionDone only resolves, whenIdle is a quiescence gate, and outputTail contains its own failures. */ + .catch((error: unknown) => { + if (record.inflight !== inflight) return + record.inflight = undefined + inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`)) + }) + /* v8 ignore stop */ + } + + // Emit only committed assistant text/images. Raw chunks, reasoning, tools, + // plans, titles, and retry markers are presentation or trace data and stay + // off the automation wire. One per-session chain preserves block/message + // order across asynchronous attachment reads. ctx.on('session/event', (session, event: SessionEvent) => { const record = sessions.get(session.header.id) if (record === undefined || record.agent.session !== session) return try { if (event.type === 'assistant/message') { - for (const block of event.data.message.content) { - if (block.type === 'text' && block.text.length > 0) { - notify({ + const inflight = record.inflight?.turn === event.data.turn ? record.inflight : undefined + const previous = record.outputTail + const delivery = previous.then(async () => { + for (const block of event.data.message.content) { + const content = await assistantBlockToAcp(ctx, block) + if (content === undefined) continue + await notify({ sessionId: record.agent.session.id, - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: block.text }, - }, - }) - } else if (block.type === 'image') { - notify({ - sessionId: record.agent.session.id, - update: { - sessionUpdate: 'agent_message_chunk', - content: { - type: 'text', - text: `[image attachment ${block.attachment.attachmentId}]`, - }, - }, + update: { sessionUpdate: 'agent_message_chunk', content }, }) } - } + }) + record.outputTail = delivery.catch((error: unknown) => { + // assistantBlockToAcp owns conversion failures and always throws Error. + const failure = error as Error + if (inflight !== undefined) inflight.outputError ??= failure + logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`) + }) } } finally { const inflight = record.inflight if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - if (event.data.reason.kind === 'error') { - // Model failures surface immediately as prompt errors; ordinary - // endings wait for whole-agent idle below. - record.inflight = undefined - rejectFromError(inflight, event.data.reason) - } else { - inflight.endReason = event.data.reason - } + inflight.endReason = event.data.reason } } }) @@ -205,8 +257,8 @@ export function apply(ctx: Context, config: AcpConfig): void { const record = ownedRecord(agent) const inflight = record?.inflight if (record === undefined || inflight === undefined || inflight.turn === turn) return - record.inflight = undefined - inflight.reject(internalError(`turn failed: ${errorChain(error)}`)) + inflight.agentError = new Error(errorChain(error)) + settleAfterQuiescence(record, inflight) }) // Permission requests are a machine policy channel for ACP clients such as @@ -231,17 +283,18 @@ export function apply(ctx: Context, config: AcpConfig): void { const makeAgent = (connection: AgentSideConnection): AcpAgent => { conn = connection return { - initialize(_params: InitializeRequest): Promise { + async initialize(_params: InitializeRequest): Promise { // Single-version agent: the spec's "same version if supported, else // the latest supported" both resolve to this server's one version. - return Promise.resolve({ + imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model) + return { protocolVersion: PROTOCOL_VERSION, agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, agentCapabilities: { - promptCapabilities: { image: false, audio: false, embeddedContext: false }, + promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false }, }, authMethods: [], - }) + } }, authenticate(_params: AuthenticateRequest): Promise { @@ -269,6 +322,7 @@ export function apply(ctx: Context, config: AcpConfig): void { sessions.set(sessionId, { agent: handle.agent, dispose: () => handle.dispose(), + outputTail: Promise.resolve(), inflight: undefined, }) return { sessionId } @@ -280,66 +334,91 @@ export function apply(ctx: Context, config: AcpConfig): void { if (record.inflight !== undefined) { throw invalidParams('a prompt is already in flight for this session') } - if (promptHasUnsupportedContent(params.prompt)) { - throw invalidParams('only text and resource_link prompt content is supported') + const completion = Promise.withResolvers() + const admission = Promise.withResolvers() + const admissionController = new AbortController() + const inflight: NonNullable = { + resolve: completion.resolve, + reject: completion.reject, + messageId: undefined, + turn: undefined, + endReason: undefined, + admissionDone: admission.promise, + finishAdmission: admission.resolve, + admissionController, + cancelRequested: false, + settlementStarted: false, + outputError: undefined, + agentError: undefined, } - const text = acpPromptToText(params.prompt) - if (text.trim().length === 0) throw invalidParams('empty prompt') + // Reserve the one-prompt slot before the first asynchronous route or + // attachment operation so concurrent prompts and cancellation observe + // admission as genuinely in flight. + record.inflight = inflight - // Not driving a retired agent is this bridge's contract: an - // agent-loop-only reload disposes the loop's agents while the bridge - // record survives, so validate the record against the live registry - // before sending — a disposed machine would accept the item silently. - if (ctx.agents.get(record.agent.id) !== record.agent) { - throw internalError('prompt was not queued: the agent was disposed outside the bridge') + let admissionFailed = false + let admissionFailure: unknown + try { + // Do not persist rich content for a retired destination. Re-check + // after admission too because an agent-loop reload may race storage. + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const content = await admitAcpPrompt( + ctx, + record.agent, + params.prompt, + imagePromptEnabled, + admissionController.signal, + ) + // No await may separate this final abort check from followup: a + // cancellation that wins admission must never enqueue a late turn. + admissionController.signal.throwIfAborted() + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const message = createUserMessage({ content, source: { kind: 'user' } }) + inflight.messageId = message.id + record.agent.followup(message) + } catch (error: unknown) { + admissionFailed = true + admissionFailure = error + } finally { + inflight.finishAdmission() } - const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) - const stopReason = await new Promise((resolve, reject) => { - // Arm the slot before followup() so a listener-driven synchronous - // turn cannot slip past correlation; a synchronous followup() - // failure (invalid input) must free the slot again or the session - // would reject every later prompt as already in flight. - const inflight: NonNullable = { - resolve, reject, messageId: message.id, turn: undefined, endReason: undefined, + + if (inflight.cancelRequested) { + settleAfterQuiescence(record, inflight) + return { stopReason: await completion.promise } + } + if (admissionFailed) { + record.inflight = undefined + if (admissionFailure instanceof AcpContentError) { + throw admissionFailure.kind === 'invalid' + ? invalidParams(admissionFailure.message) + : internalError(admissionFailure.message) } - record.inflight = inflight - try { - record.agent.followup(message) - // The machine's send() contains listener failures and accepts - // any typed input; this guards a future synchronous throw so the - // slot cannot wedge. - /* v8 ignore start -- future-proofing guard, see above */ - } catch (error: unknown) { - record.inflight = undefined - const detail = error instanceof Error ? error.message : String(error) - throw internalError(`prompt was not queued: ${detail}`) - } - /* v8 ignore stop */ - // Settlement waits for whole-agent idle: a correlated turn/end arms - // `endReason`, while a turnless slot (admission discarded the - // prompt) stays cancelled. Other producers may run further turns - // before quiescence; the prompt settles only when the agent stops. - void record.agent.whenIdle().then(() => { - if (record.inflight !== inflight) return - record.inflight = undefined - const end = inflight.endReason - if (end === undefined) { - inflight.resolve('cancelled') - } else { - // Token-limit and other non-terminal endings are not prompt-level - // stop reasons (see README); only normal quiescence reports end_turn. - inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) - } - }) - }) + if (admissionFailure instanceof RequestError) throw admissionFailure + // The admission codec and same-process agent seam throw Error values. + const detail = (admissionFailure as Error).message + throw internalError(`prompt was not queued: ${detail}`) + } + + settleAfterQuiescence(record, inflight) + const stopReason = await completion.promise return { stopReason } }, cancel(params: CancelNotification): Promise { const record = sessions.get(SessionId(params.sessionId)) if (record === undefined) return Promise.resolve() + const inflight = record.inflight + if (inflight !== undefined) { + inflight.cancelRequested = true + inflight.admissionController.abort(new Error('ACP prompt cancelled')) + settleAfterQuiescence(record, inflight) + } record.agent.cancel({ kind: 'user' }) - settlePrompt(record, 'cancelled') return Promise.resolve() }, } @@ -362,10 +441,24 @@ export function apply(ctx: Context, config: AcpConfig): void { // on persistence or scoped cleanup, and the top-level agents must not keep // running model and tool calls for its whole duration. for (const record of records) { + const inflight = record.inflight + if (inflight !== undefined) { + inflight.cancelRequested = true + inflight.admissionController.abort(new Error('ACP bridge disposed')) + settleAfterQuiescence(record, inflight) + } record.agent.cancel({ kind: 'user' }) - settlePrompt(record, 'cancelled') } quiescing = (async () => { + // Preserve the same prompt boundary during connection teardown: a rich + // admission already writing must stop before its slot settles, and every + // committed output conversion must drain while attachment services remain + // available. session/event enqueues output synchronously before idle. + await Promise.all(records.map(async (record) => { + await record.inflight?.admissionDone + await record.agent.whenIdle() + await record.outputTail + })) // Continuable subagents outlive the turn that started them, and their // Activations own descendant teardown. Drain only these sessions' forests // child-first BEFORE disposing the top-level agents, so no descendant is diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 619a628ea1..2823f717db 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -28,6 +29,17 @@ describe('automation-only ACP bridge', () => { }) }) + it('advertises image prompts only with an exact capable route and attachment store', async () => { + harness = await makeBridgeHarness({ imageCapable: true }) + const capable = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + expect(capable.agentCapabilities?.promptCapabilities?.image).toBe(true) + await harness.dispose() + + harness = await makeBridgeHarness({ imageCapable: true, attachments: false }) + const noStore = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + expect(noStore.agentCapabilities?.promptCapabilities?.image).toBe(false) + }) + it('negotiates an unsupported version and accepts the required no-op authentication call', async () => { harness = await makeBridgeHarness() const response = await harness.client.initialize({ protocolVersion: 0, clientCapabilities: {} }) @@ -77,6 +89,73 @@ describe('automation-only ACP bridge', () => { expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'first second' }]) }) + it('admits mixed text/image prompts in wire order and logs references only', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('done')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const resolve = vi.spyOn(harness.ctx.llm, 'resolveModelInfo') + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.client.prompt({ + sessionId, + prompt: [ + { type: 'text', text: 'before' }, + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + { type: 'text', text: 'between' }, + { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' }, + { type: 'text', text: 'after' }, + ], + }) + + expect(resolve).toHaveBeenCalledWith('mock', 'mock', expect.any(AbortSignal)) + expect(harness.attachments?.saved.map(input => [...input.data])).toEqual([[1], [2]]) + const requestContent = harness.adapter.requests[0]?.messages.at(-1)?.content + expect(requestContent?.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text']) + expect(requestContent?.[0]).toEqual({ type: 'text', text: 'before' }) + expect(requestContent?.[2]).toEqual({ type: 'text', text: 'between' }) + expect(requestContent?.[4]).toEqual({ type: 'text', text: 'after' }) + const firstImage = requestContent?.[1] + const secondImage = requestContent?.[3] + if (firstImage?.type !== 'image' || secondImage?.type !== 'image') throw new Error('expected ordered image blocks') + expect(firstImage.attachment.mediaType).toBe('image/png') + expect(firstImage.attachment.bytes).toBe(1) + expect(secondImage.attachment.mediaType).toBe('image/jpeg') + expect(secondImage.attachment.bytes).toBe(1) + const agent = harness.ctx.agents.get(SessionId(sessionId)) + expect(JSON.stringify(agent?.session.events)).not.toContain('AQ==') + }) + + it('rejects a malformed image batch atomically and frees the prompt slot', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('recovered')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.prompt({ + sessionId, + prompt: [ + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + { type: 'image', data: 'not base64', mimeType: 'image/png' }, + ], + })).rejects.toThrow(/canonical base64/) + expect(harness.attachments?.saved).toEqual([]) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'retry' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + }) + + it('reports durable image write failures as internal prompt failures', async () => { + harness = await makeBridgeHarness({ imageCapable: true }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + vi.spyOn(harness.attachments!, 'saveImages').mockRejectedValueOnce( + new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'), + ) + + await expect(harness.client.prompt({ + sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + })).rejects.toThrow(/unable to persist the prompt image batch/) + }) + it('renders the deployment persona for an ACP-created agent', async () => { harness = await makeBridgeHarness({ persona: 'Automation persona for {{model}} in {{cwd}}.', script: [textResponse('ok')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -107,7 +186,7 @@ describe('automation-only ACP bridge', () => { })).resolves.toHaveProperty('sessionId') }) - it('rejects empty and beyond-baseline prompts before a turn starts', async () => { + it('rejects empty and unadvertised image prompts before a turn starts', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) @@ -117,7 +196,7 @@ describe('automation-only ACP bridge', () => { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'image', data: '', mimeType: 'image/png' }], - })).rejects.toThrow(/only text and resource_link/) + })).rejects.toThrow(/inline image prompts were not advertised/) expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false) }) diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 335ead9798..2a48336af0 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { TurnEndReason } from '@deepseek-ai/dsh-session' -import { acpPromptToText, turnEndToStopReason } from '../src/codec.ts' +import { turnEndToStopReason } from '../src/codec.ts' describe('ACP codec', () => { it.each([ @@ -13,12 +13,4 @@ describe('ACP codec', () => { ] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => { expect(turnEndToStopReason(reason)).toBe(expected) }) - - it('drops unsupported blocks from baseline text conversion', () => { - expect(acpPromptToText([{ - type: 'image', - data: '', - mimeType: 'image/png', - }])).toBe('') - }) }) diff --git a/packages/acp/acp/tests/content.spec.ts b/packages/acp/acp/tests/content.spec.ts new file mode 100644 index 0000000000..476708d687 --- /dev/null +++ b/packages/acp/acp/tests/content.spec.ts @@ -0,0 +1,232 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { + AcpContentError, + admitAcpPrompt, + assistantBlockToAcp, + supportsAcpImagePrompts, +} from '../src/content.ts' + +const REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'1'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + +interface AdmissionFixture { + ctx: Context + agent: Agent + saveImages: ReturnType Promise>> + resolveModelInfo: ReturnType +} + +function admissionFixture(options: { + attachments?: boolean + llm?: boolean + provider?: string | undefined + model?: string | undefined + header?: { provider?: string; model?: string } +} = {}): AdmissionFixture { + const saveImages = vi.fn(async (inputs: readonly SaveImageAttachment[]) => inputs.map((input, index) => ({ + ...REF, + attachmentId: AttachmentId(`sha256:${String(index + 1).padStart(64, '0')}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + }))) + const resolveModelInfo = vi.fn(async (provider: string, model: string) => ({ + provider, + id: model, + name: model, + inputModalities: ['text', 'image'] as const, + })) + const attachments = options.attachments === false ? undefined : { saveImages } + const llm = options.llm === false ? undefined : { resolveModelInfo } + const ctx = { + get(name: string) { + if (name === 'attachments') return attachments + if (name === 'llm') return llm + return undefined + }, + } as unknown as Context + const provider = 'provider' in options ? options.provider : 'mock' + const model = 'model' in options ? options.model : 'vision' + const agent = { + options: { provider, model }, + session: { requestHeader: () => options.header === undefined ? undefined : { config: options.header } }, + } as unknown as Agent + return { ctx, agent, saveImages, resolveModelInfo } +} + +describe('ACP rich content codec', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('advertises image input only when every deployment prerequisite is explicit', async () => { + const absent = (attachments: unknown, llm: unknown): Context => ({ + get: (name: string) => name === 'attachments' ? attachments : name === 'llm' ? llm : undefined, + }) as unknown as Context + const store = { imageLimits: { mediaTypes: ['image/png'] } } + const noMediaStore = { imageLimits: { mediaTypes: [] } } + const imageLlm = { resolveModelInfo: vi.fn().mockResolvedValue({ inputModalities: ['text', 'image'] }) } + const textLlm = { resolveModelInfo: vi.fn().mockResolvedValue({ inputModalities: ['text'] }) } + const unknownLlm = { resolveModelInfo: vi.fn().mockResolvedValue({}) } + const brokenLlm = { resolveModelInfo: vi.fn().mockRejectedValue(new Error('catalog down')) } + + await expect(supportsAcpImagePrompts(absent(undefined, imageLlm), 'p', 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, undefined), 'p', 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, imageLlm), undefined, 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, imageLlm), 'p', undefined)).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(noMediaStore, imageLlm), 'p', 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, brokenLlm), 'p', 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, unknownLlm), 'p', 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, textLlm), 'p', 'm')).resolves.toBe(false) + await expect(supportsAcpImagePrompts(absent(store, imageLlm), 'p', 'm')).resolves.toBe(true) + }) + + it('validates every rich wire block before any image write', async () => { + const fixture = admissionFixture() + const signal = new AbortController().signal + + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'image', data: 'AQ==', mimeType: 'image/tiff' }, + ] as never, true, signal)).rejects.toThrow(/mimeType/) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'image', data: 'not base64', mimeType: 'image/png' }, + ], true, signal)).rejects.toThrow(/canonical base64/) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'image', data: 'AB==', mimeType: 'image/png' }, + ], true, signal)).rejects.toThrow(/canonical base64/) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'audio', data: 'AQ==', mimeType: 'audio/wav' }, + ], true, signal)).rejects.toThrow(/audio prompt/) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'resource', resource: { uri: 'file:///tmp/a', text: 'a' } }, + ], true, signal)).rejects.toThrow(/embedded resource/) + expect(fixture.saveImages).not.toHaveBeenCalled() + }) + + it('requires the advertised capability, store, and exact image-capable route', async () => { + const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const + const capable = admissionFixture() + await expect(admitAcpPrompt(capable.ctx, capable.agent, prompt, false, new AbortController().signal)) + .rejects.toThrow(/not advertised/) + + const noStore = admissionFixture({ attachments: false }) + await expect(admitAcpPrompt(noStore.ctx, noStore.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/no attachment store/) + + const noProvider = admissionFixture({ provider: undefined }) + await expect(admitAcpPrompt(noProvider.ctx, noProvider.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/route could not be resolved/) + const noModel = admissionFixture({ model: undefined }) + await expect(admitAcpPrompt(noModel.ctx, noModel.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/route could not be resolved/) + const noLlm = admissionFixture({ llm: false }) + await expect(admitAcpPrompt(noLlm.ctx, noLlm.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/route could not be resolved/) + + const broken = admissionFixture() + broken.resolveModelInfo.mockRejectedValueOnce(new Error('catalog down')) + await expect(admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/route could not be verified/) + const unknown = admissionFixture() + unknown.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision' }) + await expect(admitAcpPrompt(unknown.ctx, unknown.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/does not declare image input/) + const textOnly = admissionFixture() + textOnly.resolveModelInfo.mockResolvedValueOnce({ + provider: 'mock', id: 'vision', name: 'vision', inputModalities: ['text'], + }) + await expect(admitAcpPrompt(textOnly.ctx, textOnly.agent, prompt, true, new AbortController().signal)) + .rejects.toThrow(/does not declare image input/) + + const routed = admissionFixture({ provider: 'fallback', model: 'fallback', header: { provider: 'live', model: 'vision-2' } }) + await expect(admitAcpPrompt(routed.ctx, routed.agent, prompt, true, new AbortController().signal)).resolves.toHaveLength(1) + expect(routed.resolveModelInfo).toHaveBeenCalledWith('live', 'vision-2', expect.any(AbortSignal)) + }) + + it('classifies image-policy failures separately from durable write failures', async () => { + const fixture = admissionFixture() + const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const + fixture.saveImages.mockRejectedValueOnce(new AttachmentError('too many', 'TOO_MANY_IMAGES')) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + .rejects.toMatchObject({ kind: 'invalid', message: 'too many' }) + fixture.saveImages.mockRejectedValueOnce(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED')) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) + fixture.saveImages.mockRejectedValueOnce(new Error('unknown store failure')) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + .rejects.toBeInstanceOf(AcpContentError) + }) + + it('honors cancellation on both sides of the durable image write', async () => { + const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const + const before = admissionFixture() + const beforeController = new AbortController() + beforeController.abort(new Error('cancel before write')) + await expect(admitAcpPrompt(before.ctx, before.agent, prompt, true, beforeController.signal)) + .rejects.toThrow('cancel before write') + expect(before.saveImages).not.toHaveBeenCalled() + + const after = admissionFixture() + const afterController = new AbortController() + after.saveImages.mockImplementationOnce(async () => { + afterController.abort(new Error('cancel after write')) + return [REF] + }) + await expect(admitAcpPrompt(after.ctx, after.agent, prompt, true, afterController.signal)) + .rejects.toThrow('cancel after write') + expect(after.saveImages).toHaveBeenCalledOnce() + }) + + it('reconstructs image-only and baseline prompts without empty text blocks', async () => { + const fixture = admissionFixture() + const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + ], true, new AbortController().signal) + expect(imageOnly).toHaveLength(1) + expect(imageOnly[0]?.type).toBe('image') + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'text', text: 'before' }, + { type: 'resource_link', name: 'Guide', uri: 'https://example.test/guide' }, + { type: 'text', text: 'after' }, + ], true, new AbortController().signal)).resolves.toEqual([{ + type: 'text', + text: 'before\n[resource_link name="Guide" uri="https://example.test/guide"]\nafter', + }]) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + { type: 'text', text: ' \n ' }, + ], true, new AbortController().signal)).rejects.toThrow(/empty prompt/) + }) + + it('projects only non-empty text and verified durable images to ACP', async () => { + const fixture = admissionFixture() + await expect(assistantBlockToAcp(fixture.ctx, { type: 'text', text: '' })).resolves.toBeUndefined() + await expect(assistantBlockToAcp(fixture.ctx, { type: 'text', text: 'hello' })).resolves.toEqual({ + type: 'text', text: 'hello', + }) + await expect(assistantBlockToAcp(fixture.ctx, { type: 'reasoning', text: 'private' })).resolves.toBeUndefined() + + const noStore = admissionFixture({ attachments: false }) + await expect(assistantBlockToAcp(noStore.ctx, { type: 'image', attachment: REF })) + .rejects.toThrow(/no attachment store/) + const readImage = vi.fn().mockRejectedValue(new AttachmentError('gone', 'ATTACHMENT_NOT_FOUND')) + const missingCtx = { get: (name: string) => name === 'attachments' ? { readImage } : undefined } as unknown as Context + await expect(assistantBlockToAcp(missingCtx, { type: 'image', attachment: REF })) + .rejects.toThrow(/unavailable or corrupt/) + const storedCtx = { + get: (name: string) => name === 'attachments' + ? { readImage: vi.fn().mockResolvedValue({ ref: REF, data: Uint8Array.of(1) }) } + : undefined, + } as unknown as Context + await expect(assistantBlockToAcp(storedCtx, { type: 'image', attachment: REF })).resolves.toEqual({ + type: 'image', data: 'AQ==', mimeType: 'image/png', + }) + }) +}) diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 48b5e76095..4aa32f078c 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' @@ -26,6 +27,37 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) + it('disposal drains asynchronous assistant image delivery before releasing sessions', async () => { + const script: StreamChunk[][] = [] + harness = await makeBridgeHarness({ script }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(4), mediaType: 'image/png' }) + script.push([ + { type: 'block-start', index: 0, blockType: 'image' }, + { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, + { type: 'finish', reason: { kind: 'stop' } }, + ]) + const readStarted = Promise.withResolvers() + const releaseRead = Promise.withResolvers() + harness.attachments!.beforeRead = () => { + readStarted.resolve(undefined) + return releaseRead.promise + } + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) + await readStarted.promise + + let disposed = false + const disposal = harness.acpFiber.dispose().finally(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + releaseRead.resolve(undefined) + await disposal + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + }) + it('drains continuable subagents before disposing its own sessions', async () => { harness = await makeBridgeHarness() const order: string[] = [] diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index cdb5764b53..84bbff3b3d 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -54,6 +54,51 @@ describe('ACP automation output boundary', () => { expect(harness.updates).toHaveLength(0) }) + it('delivers output from a bridge-owned session driven by another in-process producer', async () => { + harness = await makeBridgeHarness({ script: [textResponse('external')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } })) + await agent.whenIdle() + + expect(harness.updates).toEqual([{ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'external' }, + }]) + }) + + it('contains output conversion failure outside an ACP prompt', async () => { + harness = await makeBridgeHarness({ script: [[ + { type: 'block-start', index: 0, blockType: 'image' }, + { + type: 'block-end', + index: 0, + block: { + type: 'image', + attachment: { + attachmentId: `sha256:${'a'.repeat(64)}` as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ]] }) + const warn = vi.spyOn(harness.ctx.logger, 'warn') + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } })) + await agent.whenIdle() + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('output conversion failed')) }) + expect(harness.updates).toEqual([]) + }) + // `session/update` is a JSON-RPC notification, so a client-side handler // failure never reaches the bridge; this pins that the prompt still settles // normally with such a client. The bridge's own write-failure guard is diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index c5b03c39a2..ae66f9f841 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -1,6 +1,7 @@ /** In-memory ACP transport fixture over the real agent factory and loop. */ import { Context } from '@deepseek-ai/cordis' +import { createHash } from 'node:crypto' import { ClientSideConnection, ndJsonStream, @@ -11,7 +12,9 @@ import { type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' -import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import * as AcpPlugin from '../src/index.ts' @@ -21,7 +24,10 @@ import type { AcpConfig } from '../src/index.ts' class MockAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] - constructor(private readonly script: (StreamChunk[] | 'hang')[]) { + constructor( + private readonly script: (StreamChunk[] | 'hang')[], + private readonly imageCapable: boolean, + ) { super() } @@ -31,7 +37,21 @@ class MockAdapter extends LlmAdapter { } override listModels(provider: string) { - return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : []) + return Promise.resolve(provider === 'mock' ? [{ + provider: 'mock', + id: 'mock', + name: 'Mock', + inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const, + }] : []) + } + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + inputModalities: this.imageCapable ? ['text', 'image'] : ['text'], + }) } async * stream(options: GenerateOptions): AsyncIterable { @@ -57,6 +77,49 @@ class MockAdapter extends LlmAdapter { } } +const IMAGE_LIMITS: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 4, + maxMessageImageBytes: 2048, + maxImagePixels: 1024, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], +} + +/** In-memory durable store for ACP wire-order and lifecycle tests. */ +class MemoryAttachmentStore extends AttachmentStore { + readonly imageLimits = IMAGE_LIMITS + readonly saved: SaveImageAttachment[] = [] + readonly objects = new Map() + beforeValidate: (() => Promise) | undefined + beforeRead: (() => Promise) | undefined + + async validateImage(input: SaveImageAttachment): Promise { + await this.beforeValidate?.() + if (input.data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') + } + + saveImage(input: SaveImageAttachment): Promise { + this.saved.push(input) + const digest = createHash('sha256').update(input.data).digest('hex') + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${digest}`), + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + } + this.objects.set(ref.attachmentId, { ref, data: Uint8Array.from(input.data) }) + return Promise.resolve(ref) + } + + async readImage(ref: ImageAttachmentRef): Promise { + await this.beforeRead?.() + const stored = this.objects.get(ref.attachmentId) + if (stored === undefined) throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + return { ref: stored.ref, data: Uint8Array.from(stored.data) } + } +} + /** Scripted text response ending in a clean stop. */ export function textResponse(text: string): StreamChunk[] { return [ @@ -93,6 +156,7 @@ export interface BridgeHarness { ctx: Context client: ClientSideConnection adapter: MockAdapter + attachments: MemoryAttachmentStore | undefined updates: CapturedUpdate[] sessionUpdates: { sessionId: string; update: CapturedUpdate }[] permissionRequests: RequestPermissionRequest[] @@ -113,10 +177,13 @@ export async function makeBridgeHarness(options: { script?: (StreamChunk[] | 'hang')[] config?: AcpConfigOverrides persona?: string + imageCapable?: boolean + attachments?: boolean } = {}): Promise { - const adapter = new MockAdapter(options.script ?? []) + const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true) const ctx = new Context() await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -135,6 +202,7 @@ export async function makeBridgeHarness(options: { const harness: BridgeHarness = { ctx, adapter, + attachments: ctx.get('attachments') as MemoryAttachmentStore | undefined, updates, sessionUpdates, permissionRequests, diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 848d112215..e11023ce46 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -1,4 +1,4 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' @@ -41,35 +41,100 @@ describe('ACP prompt lifecycle', () => { await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) - it('renders an assistant image as an explicit attachment placeholder', async () => { - const attachmentId = `sha256:${'a'.repeat(64)}` as never - harness = await makeBridgeHarness({ - script: [[ - { type: 'block-start', index: 0, blockType: 'image' }, - { - type: 'block-end', - index: 0, - block: { - type: 'image', - attachment: { - attachmentId, - mediaType: 'image/png', - bytes: 1, - width: 1, - height: 1, - }, - }, + it('delivers a committed assistant image as verified ACP base64', async () => { + const script: StreamChunk[][] = [] + harness = await makeBridgeHarness({ script }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(1), mediaType: 'image/png' }) + script.push([ + { type: 'block-start', index: 0, blockType: 'image' }, + { + type: 'block-end', + index: 0, + block: { + type: 'image', + attachment: ref, }, - { type: 'finish', reason: { kind: 'stop' } }, - ]], - }) + }, + { type: 'finish', reason: { kind: 'stop' } }, + ]) const sessionId = await newSession(harness) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) - await vi.waitFor(() => { - expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`) + expect(harness.updates).toContainEqual({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'image', data: 'AQ==', mimeType: 'image/png' }, }) }) + it('preserves committed text/image/text order on the ACP wire', async () => { + const script: StreamChunk[][] = [] + harness = await makeBridgeHarness({ script }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(2), mediaType: 'image/jpeg' }) + script.push([ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'before' } }, + { type: 'block-start', index: 1, blockType: 'image' }, + { type: 'block-end', index: 1, block: { type: 'image', attachment: ref } }, + { type: 'block-start', index: 2, blockType: 'text' }, + { type: 'block-end', index: 2, block: { type: 'text', text: 'after' } }, + { type: 'finish', reason: { kind: 'stop' } }, + ]) + const sessionId = await newSession(harness) + + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) + + expect(harness.updates).toEqual([ + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'before' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'after' } }, + ]) + }) + + it('does not settle a prompt before ordered output delivery drains', async () => { + const script: StreamChunk[][] = [] + harness = await makeBridgeHarness({ script }) + const ref = await harness.attachments!.saveImage({ data: Uint8Array.of(3), mediaType: 'image/png' }) + script.push([ + { type: 'block-start', index: 0, blockType: 'image' }, + { type: 'block-end', index: 0, block: { type: 'image', attachment: ref } }, + { type: 'finish', reason: { kind: 'stop' } }, + ]) + const readStarted = Promise.withResolvers() + const delivery = Promise.withResolvers() + harness.attachments!.beforeRead = () => { + readStarted.resolve(undefined) + return delivery.promise + } + const sessionId = await newSession(harness) + let settled = false + + const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + .finally(() => { settled = true }) + await readStarted.promise + expect(settled).toBe(false) + delivery.resolve(undefined) + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }) + }) + + it('fails prompt delivery when a committed image attachment is missing', async () => { + const missing = { + attachmentId: `sha256:${'a'.repeat(64)}` as never, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + harness = await makeBridgeHarness({ script: [[ + { type: 'block-start', index: 0, blockType: 'image' }, + { type: 'block-end', index: 0, block: { type: 'image', attachment: missing } }, + { type: 'finish', reason: { kind: 'stop' } }, + ]] }) + const sessionId = await newSession(harness) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] })) + .rejects.toThrow(/assistant output delivery failed/) + expect(harness.updates).toEqual([]) + }) + it('rejects a failed turn and never publishes its partial chunks', async () => { harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] }) const sessionId = await newSession(harness) @@ -194,6 +259,84 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) }) + it('reserves the prompt slot during image admission and cancels without a late followup', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [] }) + const validationStarted = Promise.withResolvers() + const releaseValidation = Promise.withResolvers() + harness.attachments!.beforeValidate = () => { + validationStarted.resolve(undefined) + return releaseValidation.promise + } + const sessionId = await newSession(harness) + let settled = false + const first = harness.client.prompt({ + sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + }).finally(() => { settled = true }) + await validationStarted.promise + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] })) + .rejects.toThrow(/already in flight/) + await harness.client.cancel({ sessionId }) + expect(settled).toBe(false) + releaseValidation.resolve(undefined) + + await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.adapter.requests).toEqual([]) + const events = harness.ctx.agents.get(SessionId(sessionId))?.session.events ?? [] + expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) + }) + + it('does not queue admitted content into an agent retired during storage', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [] }) + const validationStarted = Promise.withResolvers() + const releaseValidation = Promise.withResolvers() + harness.attachments!.beforeValidate = () => { + validationStarted.resolve(undefined) + return releaseValidation.promise + } + const sessionId = await newSession(harness) + const prompt = harness.client.prompt({ + sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + }) + await validationStarted.promise + + await harness.loopFiber.dispose() + releaseValidation.resolve(undefined) + + await expect(prompt).rejects.toThrow(/disposed outside the bridge/) + expect(harness.attachments!.saved).toHaveLength(1) + expect(harness.adapter.requests).toEqual([]) + }) + + it('honors cancellation in the admission-to-followup handoff gap', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [] }) + const sessionId = await newSession(harness) + const saveImages = harness.attachments!.saveImages.bind(harness.attachments!) + vi.spyOn(harness.attachments!, 'saveImages').mockImplementationOnce(async (inputs) => { + const refs = await saveImages(inputs) + queueMicrotask(() => { void harness!.client.cancel({ sessionId }) }) + return refs + }) + + await expect(harness.client.prompt({ + sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + })).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.adapter.requests).toEqual([]) + }) + + it('wraps an unexpected same-process followup failure and frees the prompt slot', async () => { + harness = await makeBridgeHarness({ script: [] }) + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + vi.spyOn(agent, 'followup').mockImplementationOnce(() => { throw new Error('synthetic followup failure') }) + + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/prompt was not queued: synthetic followup failure/) + }) + it('cancels a running turn and records the aborted outcome', async () => { harness = await makeBridgeHarness({ script: ['hang'] }) const sessionId = await newSession(harness) diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index ca9e9e67f0..3afae2d055 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/support/acp-snapshot/README.md -README.md: 06f1cb67cfcd954254db480ea696d10d81b37438 -README.zh.md: c4a5643f8c5d7e5b62a160cd32e5969187d34046 +README.md: 31f4ec0caeb995a10202d4a452ee7e433749762f +README.zh.md: f97bac11de690fe980595c77375aead47b3b0214 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 06f1cb67cf..31f4ec0cae 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -61,7 +61,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC and Web snapshot recorders. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. +Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the JSON-RPC and Web snapshot recorders. Input scripts cover initialization, fresh-session creation, shorthand text prompts, exact structured ACP prompt blocks, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. ## Model Experience diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index c4a5643f8c..f97bac11de 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -61,7 +61,7 @@ defineAcpSnapshotSuite({ 示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 -约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC 和 Web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 +约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 JSON-RPC 和 Web 快照录制器消费。输入脚本覆盖初始化、新建会话、文本提示简写、精确结构化 ACP 提示词块、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once` 等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 ## 模型体验 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d37bf289b5..21800862fc 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -25,6 +25,7 @@ import { vi } from 'vitest' import { ClientSideConnection, PROTOCOL_VERSION, + type ContentBlock as AcpContentBlock, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, @@ -69,6 +70,7 @@ export type InputStep = | { op: 'newSession' } | { op: 'newSessionExpectError'; additionalDirectories?: string[] } | { op: 'prompt'; text: string } + | { op: 'promptContent'; content: AcpContentBlock[] } | { op: 'promptAndWaitForAgentMessage'; text: string; waitForText: string } | { op: 'promptExpectError'; text: string } | { @@ -422,6 +424,12 @@ async function runStep( await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] }) return } + case 'promptContent': { + const sessionId = getSessionId() + if (sessionId === undefined) throw new Error('snapshot-harness: promptContent before newSession') + await client.prompt({ sessionId, prompt: step.content }) + return + } case 'promptAndWaitForAgentMessage': { const sessionId = getSessionId() if (sessionId === undefined) throw new Error('snapshot-harness: promptAndWaitForAgentMessage before newSession') diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 68dc58c770..5bd97b101d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -407,6 +407,24 @@ describe('runScenario', () => { expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd) }) + it('drives a structured prompt-content step without flattening its wire blocks', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const result = await runScenario( + { + steps: [...boot, { + op: 'promptContent', + content: [ + { type: 'text', text: 'before' }, + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + { type: 'text', text: 'after' }, + ], + }], + }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ) + expect(result.rawStdout).toContain('"stopReason":"end_turn"') + }) + it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ echoEnv: true, stderrNote: 'fake bin booted' }) const childFiles = [join(dir, 'session.1.jsonl'), join(dir, 'session.2.jsonl')] @@ -1097,6 +1115,7 @@ describe('runScenario', () => { it.each([ [{ op: 'prompt', text: 'x' }, /prompt before newSession/], + [{ op: 'promptContent', content: [{ type: 'text', text: 'x' }] }, /promptContent before newSession/], [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/], [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/], [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bfc8937e9..a2d0633514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -778,6 +778,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants From ee5111841a8c4f08310f94ea05c58f400aee1bbc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:05:59 +0800 Subject: [PATCH 033/146] docs: refresh module graph for rich content bridges --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 7 +++++-- docs/module-graph.zh.md | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d5ac47cf78..b454760c65 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/module-graph.md -module-graph.md: 56e029df192f28a787748b12074ee4dfe67d1c58 -module-graph.zh.md: 839c5edf758a3076874643cb6bdbd91954ca369e +module-graph.md: b8e9464a4603a0cf6326f684e8091ff5bb7bd4a6 +module-graph.zh.md: 0acbfdd82aa45e189d16b9c6ca450c271a2547d2 diff --git a/docs/module-graph.md b/docs/module-graph.md index 56e029df19..b8e9464a46 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -639,7 +639,9 @@ flowchart TD pkg_session_query --> pkg_session_persistence pkg_session_query --> pkg_session_title pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment pkg_acp --> pkg_invariants + pkg_acp --> pkg_llm pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_api_remotes --> pkg_agent @@ -886,6 +888,7 @@ flowchart TD pkg_tool_lsp --> pkg_system_prompt pkg_tool_lsp --> pkg_timeout pkg_tool_lsp --> pkg_tools + pkg_mcp_client --> pkg_attachment pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess @@ -1468,7 +1471,7 @@ flowchart TD | [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1510,7 +1513,7 @@ flowchart TD | [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 839c5edf75..0acbfdd82a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -641,7 +641,9 @@ flowchart TD pkg_session_query --> pkg_session_persistence pkg_session_query --> pkg_session_title pkg_acp --> pkg_agent + pkg_acp --> pkg_attachment pkg_acp --> pkg_invariants + pkg_acp --> pkg_llm pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_api_remotes --> pkg_agent @@ -888,6 +890,7 @@ flowchart TD pkg_tool_lsp --> pkg_system_prompt pkg_tool_lsp --> pkg_timeout pkg_tool_lsp --> pkg_tools + pkg_mcp_client --> pkg_attachment pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess @@ -1470,7 +1473,7 @@ flowchart TD | [`compact`](../packages/compact/compact) | `compact` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | -| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | +| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | @@ -1512,7 +1515,7 @@ flowchart TD | [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | From 32c584561a0223e246f77b7499cf48678a382b3c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:20:53 +0800 Subject: [PATCH 034/146] ci: refresh pull request merge ref From 57fc6bc539ee960531db4b3fb49db184db9fb5a1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:45:09 +0800 Subject: [PATCH 035/146] fix(attachment): distinguish admission from storage failures --- packages/acp/acp/src/content.ts | 6 ++--- packages/acp/acp/tests/content.spec.ts | 8 ++++-- .../attachment/attachment/README.i18n.yaml | 4 +-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/error.ts | 23 +++++++++++++++++ packages/attachment/attachment/src/index.ts | 2 +- .../attachment/attachment/tests/index.spec.ts | 13 ++++++++++ packages/mcp/mcp-client/src/tools.ts | 8 ++++-- .../mcp/mcp-client/tests/mcp-client.spec.ts | 25 ++++++++++++++++++- 10 files changed, 80 insertions(+), 13 deletions(-) diff --git a/packages/acp/acp/src/content.ts b/packages/acp/acp/src/content.ts index 56e027a1b7..66ac7ea3be 100644 --- a/packages/acp/acp/src/content.ts +++ b/packages/acp/acp/src/content.ts @@ -2,7 +2,7 @@ import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' import type { Context } from '@deepseek-ai/cordis' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -72,7 +72,7 @@ async function assertImageRoute(ctx: Context, agent: Agent, signal: AbortSignal) try { info = await llm.resolveModelInfo(provider, model, signal) } catch (error: unknown) { - throw new AcpContentError('the current model route could not be verified for image input', 'invalid', { cause: error }) + throw new AcpContentError('the current model route could not be verified for image input', 'internal', { cause: error }) } if (info.inputModalities === undefined || !info.inputModalities.includes('image')) { throw new AcpContentError(`model "${model}" does not declare image input`, 'invalid') @@ -157,7 +157,7 @@ export async function admitAcpPrompt( try { refs = await attachments.saveImages(images) } catch (error: unknown) { - if (error instanceof AttachmentError && error.code !== 'ATTACHMENT_WRITE_FAILED') { + if (isImageAdmissionError(error)) { throw new AcpContentError(error.message, 'invalid', { cause: error }) } throw new AcpContentError('unable to persist the prompt image batch', 'internal', { cause: error }) diff --git a/packages/acp/acp/tests/content.spec.ts b/packages/acp/acp/tests/content.spec.ts index 476708d687..a22dbe9069 100644 --- a/packages/acp/acp/tests/content.spec.ts +++ b/packages/acp/acp/tests/content.spec.ts @@ -133,8 +133,9 @@ describe('ACP rich content codec', () => { const broken = admissionFixture() broken.resolveModelInfo.mockRejectedValueOnce(new Error('catalog down')) - await expect(admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal)) - .rejects.toThrow(/route could not be verified/) + const routeFailure = admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal) + await expect(routeFailure).rejects.toMatchObject({ kind: 'internal' }) + await expect(routeFailure).rejects.toThrow(/route could not be verified/) const unknown = admissionFixture() unknown.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision' }) await expect(admitAcpPrompt(unknown.ctx, unknown.agent, prompt, true, new AbortController().signal)) @@ -158,6 +159,9 @@ describe('ACP rich content codec', () => { await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'invalid', message: 'too many' }) fixture.saveImages.mockRejectedValueOnce(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED')) + await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) + fixture.saveImages.mockRejectedValueOnce(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT')) await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) fixture.saveImages.mockRejectedValueOnce(new Error('unknown store failure')) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index cef3af3a62..b88b6b2132 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/attachment/attachment/README.md -README.md: c0a86d324da8c27ec386103f40ac50534c2483d7 -README.zh.md: 562c8af0df20634ac2072c4a8d422b4e0b4b47dd +README.md: 05c4bce5498f3c0bf172264be3e4b834ea0925e2 +README.zh.md: 91a454da09d32b0a87d02ca7ccb482e95c485a37 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index c0a86d324d..05c4bce549 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `isImageAdmissionError` distinguishes caller-correctable image-policy failures from storage faults so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 562c8af0df..91a454da09 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`isImageAdmissionError` 区分可由调用方修正的图片策略失败与存储故障,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 ## 模型体验 diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 827d77f58a..071d2bc39b 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -24,3 +24,26 @@ export class AttachmentError extends Error { this.code = code } } + +/** Attachment failures caused by the caller's proposed image batch. */ +const IMAGE_ADMISSION_ERROR_CODES = new Set([ + 'TOO_MANY_IMAGES', + 'IMAGES_TOO_LARGE', + 'UNSUPPORTED_IMAGE_TYPE', + 'INVALID_IMAGE', + 'IMAGE_TYPE_MISMATCH', + 'IMAGE_TOO_LARGE', + 'IMAGE_TOO_MANY_PIXELS', +]) + +/** + * Distinguish caller-correctable image admission failures from storage faults. + * @param error - failure raised while validating or persisting an image batch. + * @returns whether the caller can correct the proposed image content or batch. + */ +export function isImageAdmissionError(error: unknown): error is AttachmentError { + return error instanceof Error + && 'code' in error + && typeof error.code === 'string' + && IMAGE_ADMISSION_ERROR_CODES.has(error.code) +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 72e680f010..8c411dbfa5 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -10,7 +10,7 @@ import type { } from './types.ts' export { AttachmentId } from './brand.ts' -export { AttachmentError } from './error.ts' +export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentId as AttachmentIdType, ImageAttachmentLimits, diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 5a75c24dc4..18aa6894f2 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -1,7 +1,9 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import AttachmentStore, { + AttachmentError, AttachmentId, + isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, type SaveImageAttachment, @@ -93,3 +95,14 @@ describe('AttachmentStore.saveImages', () => { expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2']) }) }) + +describe('isImageAdmissionError', () => { + it('separates caller-correctable image policy failures from storage faults', () => { + expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true) + expect(isImageAdmissionError(new AttachmentError('too many', 'TOO_MANY_IMAGES'))).toBe(true) + expect(isImageAdmissionError(Object.assign(new Error('foreign policy error'), { code: 'IMAGE_TOO_LARGE' }))).toBe(true) + expect(isImageAdmissionError(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT'))).toBe(false) + expect(isImageAdmissionError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(false) + expect(isImageAdmissionError(new Error('unknown failure'))).toBe(false) + }) +}) diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index aff1c19175..e5bf7a93a6 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -18,6 +18,7 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' import type { Context } from '@deepseek-ai/cordis' +import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -474,10 +475,13 @@ async function prepareImageProjection( type: 'image', attachment: byIndex.get(index) as ImageAttachmentRef, })) - } catch { + } catch (error: unknown) { + const reason = isImageAdmissionError(error) + ? `image admission rejected the result: ${error.message}` + : 'durable image storage rejected the result' return projectContent(content, toolName, block => ({ type: 'text', - text: imageDiagnostic(block, 'durable image storage rejected the result'), + text: imageDiagnostic(block, reason), })) } } diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 4ef535cfd7..7d3b2f9d77 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { Context } from '@deepseek-ai/cordis' -import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -676,6 +676,29 @@ describe('tool execution', () => { expect(textAt(result.content)).toContain('durable image storage rejected the result') }) + it('reports attachment policy rejection as image admission rather than storage failure', async () => { + const rich = await mountRichRegistry() + vi.spyOn(rich.attachments, 'saveImages').mockRejectedValueOnce( + new AttachmentError('too many images', 'TOO_MANY_IMAGES'), + ) + const client = createMockClient( + [{ name: 'img', inputSchema: { type: 'object' } }], + { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, + ) + + await syncTools(client as never, rich.ctx, defaultOpts, new Map()) + const result = await rich.ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('policy-rejected'), + name: 'mcp__srv__img', + arguments: {}, + agent: agentOn() as never, + }) + + expect(textAt(result.content)).toContain('image admission rejected the result: too many images') + expect(textAt(result.content)).not.toContain('storage rejected') + }) + it('lets post-execute replacement win over a prepared image projection', async () => { const rich = await mountRichRegistry() rich.ctx.on('tools/post-execute', async (): Promise => ({ From adf4878b4a3b6b5890b6487acfbb683a62f2e201 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:45:24 +0800 Subject: [PATCH 036/146] fix(acp): isolate prompt admission from agent work --- ...-23-acp-automation-only-protocol.i18n.yaml | 4 +- ...2026-07-23-acp-automation-only-protocol.md | 4 +- ...6-07-23-acp-automation-only-protocol.zh.md | 4 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 6 +- packages/acp/acp/README.zh.md | 6 +- packages/acp/acp/src/index.ts | 34 +++++++--- packages/acp/acp/tests/turns.spec.ts | 64 +++++++++++++++++++ 8 files changed, 103 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index 966be9e743..39c21af76a 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: 3d13e3fb51819ef4f892f33f9c86554988576e36 -2026-07-23-acp-automation-only-protocol.zh.md: 224c1bd611aae23937f5610665c4bd316e15c425 +2026-07-23-acp-automation-only-protocol.md: 08e222d6eaec35dd7e1acc6ec6c8a3ed74bf227a +2026-07-23-acp-automation-only-protocol.zh.md: 35c945411f7223d9d8d293b39f8a56fe6a5c3700 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 3d13e3fb51..08e222d6ea 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -16,7 +16,7 @@ The snapshot suite complicates removal. Most ACP scenarios exercise the assemble `@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh sessions with one in-flight prompt each, committed assistant text/image updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge rejects additional directories, MCP servers, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts. -Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each image prompt rechecks the session's latest exact route, strictly decodes every block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; a completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. +Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each image prompt rechecks the session's latest exact route, strictly decodes every block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; before the prompt enters the Agent inbox it neither cancels nor waits for unrelated Agent work. A completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. Caller-correctable image-policy failures map to invalid parameters, while route lookup, storage corruption, and persistence failures remain internal faults. The bridge emits only committed `assistant/message` text and images. A per-session promise chain preserves block and message order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. @@ -32,7 +32,7 @@ Disconnect and plugin disposal share one memoized quiescence boundary. Both succ The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions. -Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthful image capability, fresh-session creation, ordered text/image admission, resource-link flattening, all-member validation before writes, absence of inline base64 in durable events, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement after ordered output, verified assistant-image delivery, cancellation during admission without a late followup, failed transport closure, ACP-only reload cleanup, and teardown quiescence. An assembled keyless snapshot sends a real inline PNG through the runnable ACP example and pins only its durable reference in the session log. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. +Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthful image capability, fresh-session creation, ordered text/image admission, resource-link flattening, all-member validation before writes, absence of inline base64 in durable events, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement after ordered output, verified assistant-image delivery, cancellation during admission without a late followup or cancellation of unrelated Agent work, exclusion of unrelated pre-inbox failures, failed transport closure, ACP-only reload cleanup, and teardown quiescence. An assembled keyless snapshot sends a real inline PNG through the runnable ACP example and pins only its durable reference in the session log. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index 224c1bd611..35c945411f 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -16,7 +16,7 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 `@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本/图片更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层会拒绝附加目录、MCP 服务器、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。 -图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个图片提示词都会重新检查会话的最新确切路由、严格解码全部块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。 +图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个图片提示词都会重新检查会话的最新确切路由、严格解码全部块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;提示词进入 Agent inbox 前既不会取消,也不会等待无关的 Agent 工作。已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。可由调用方修正的图片策略失败会映射为无效参数,路由查询、存储损坏和持久化失败则仍属于内部故障。 桥接层只发出已提交的 `assistant/message` 文本与图片。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时保持块与消息顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 @@ -32,7 +32,7 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 -协议与生命周期测试会锁定停止原因编解码器、版本协商、真实图片能力、新会话创建、有序文本/图片准入、资源链接展平、写入前校验全部成员、持久事件中不含内联 base64、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、在有序输出后结算提示词、经过校验的助手图片交付、准入期间取消且不产生迟到 followup、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。组装后的无密钥快照通过可运行 ACP 示例发送一张真实内联 PNG,并在会话日志中只固定其持久引用。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 +协议与生命周期测试会锁定停止原因编解码器、版本协商、真实图片能力、新会话创建、有序文本/图片准入、资源链接展平、写入前校验全部成员、持久事件中不含内联 base64、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、在有序输出后结算提示词、经过校验的助手图片交付、准入期间取消且不产生迟到 followup 或取消无关 Agent 工作、排除进入 inbox 前的无关失败、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。组装后的无密钥快照通过可运行 ACP 示例发送一张真实内联 PNG,并在会话日志中只固定其持久引用。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 ## 考虑过的替代方案 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 1a39a39562..37e6230aba 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/acp/acp/README.md -README.md: 40d4b2df18f8102a352d8a8eb438e88da7fe720c -README.zh.md: 57b7e5a3f987861cfe0c5453f5d5a26d565f77ed +README.md: aaabb0c824e12c250851985e92c0473f147e8efa +README.zh.md: e722dbf06404f453dc746c7daf61d3e7b5b68fc2 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 40d4b2df18..aaabb0c824 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -24,8 +24,8 @@ Both fields are optional so another agent/request listener may supply the target | `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. No session, editor, terminal, filesystem, or MCP capability is advertised. | | `authenticate` | No-op because the server advertises no authentication methods. | | `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. | -| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission, whole-agent idle, and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | -| `session/cancel` | Cancels only the addressed agent and marks any already-started admission so the pending prompt waits for it to quiesce, publishes no late user message, and settles as `cancelled`; unknown ids are no-ops. | +| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission plus, once queued, whole-Agent idle and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | +| `session/cancel` | Marks and aborts any in-progress admission without cancelling or waiting for unrelated Agent work; once this prompt has entered the Agent inbox, it cancels the addressed Agent and waits for the owned interval to quiesce. No late user message is published and the prompt settles as `cancelled`. With no in-flight prompt it cancels autonomous work; unknown ids are no-ops. | | `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. Raw deltas and non-message events are omitted. | | `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. | @@ -37,7 +37,7 @@ Committed-message output intentionally trades token-by-token latency for a clean Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. -ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately. +ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. The operation interval starts when the prompt enters the Agent inbox and ends after admission, whole-Agent idle, and ordered output delivery all quiesce; failures from unrelated Agent work before that inbox receipt are not attributed to the prompt. Committed assistant messages stream across the owned interval, and steering or injected work may contribute before idle. Settlement precedence is explicit cancellation, output-delivery failure, interval-wide Agent failure, then the correlated turn ending. Token-limit endings settle as `end_turn`; a correlated model error rejects only at the same quiescence boundary. ## Running diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 57b7e5a3f9..e722dbf064 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -24,8 +24,8 @@ | `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频与嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | | `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入、整个 agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 | -| `session/cancel` | 仅取消指定的 agent,并标记已经启动的准入工作,使待处理提示词等待其停稳、不发布迟到的用户消息,随后以 `cancelled` 结算;未知 id 为空操作。 | +| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入,以及消息入队后的整个 Agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 | +| `session/cancel` | 标记并中止正在进行的准入,但不会取消或等待同一 Agent 上无关的既有工作;该提示词进入 Agent inbox 后,才会取消指定的 Agent 并等待自有区间停稳。不发布迟到的用户消息,提示词以 `cancelled` 结算。没有进行中的提示词时会取消自主工作;未知 id 为空操作。 | | `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 | | `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | @@ -37,7 +37,7 @@ 客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 -ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。 +ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。操作区间从提示词进入 Agent inbox 开始,在准入、整个 Agent 空闲和有序输出交付全部停稳后结束;inbox 接收前无关 Agent 工作的失败不会归因给该提示词。已提交的 assistant 消息会在自有区间内流式输出,Agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。结算优先级依次为显式取消、输出交付失败、区间内 Agent 失败、关联轮次结束。因 token 上限而结束时以 `end_turn` 结算;关联模型错误也只会在同一个完全停稳边界拒绝提示词。 ## 运行 diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index eeef146165..7be2a2bda6 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -95,6 +95,8 @@ interface SessionRecord { reject: (error: Error) => void /** Set only after rich-content admission succeeds and the message is built. */ messageId: string | undefined + /** Whether this prompt has entered the Agent's durable inbox interval. */ + messageQueued: boolean turn: number | undefined /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ endReason: TurnEndReason | undefined @@ -106,7 +108,7 @@ interface SessionRecord { settlementStarted: boolean /** Conversion failure for committed output owned by this prompt's turn. */ outputError: Error | undefined - /** Failure before a correlated turn exists. */ + /** Interval-wide failure outside the correlated turn. */ agentError: Error | undefined } | undefined } @@ -172,10 +174,12 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.settlementStarted = true void (async () => { await inflight.admissionDone - await record.agent.whenIdle() - // session/event enqueues synchronously before the agent becomes idle; - // reading the live tail here includes every committed output task. - await record.outputTail + if (inflight.messageQueued) { + await record.agent.whenIdle() + // session/event enqueues synchronously before the agent becomes idle; + // reading the live tail here includes every committed output task. + await record.outputTail + } /* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */ if (record.inflight !== inflight) return record.inflight = undefined @@ -202,7 +206,7 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) } })() - /* v8 ignore start -- admissionDone only resolves, whenIdle is a quiescence gate, and outputTail contains its own failures. */ + /* v8 ignore start -- admissionDone only resolves, and the queued path's idle/output gates contain their own failures. */ .catch((error: unknown) => { if (record.inflight !== inflight) return record.inflight = undefined @@ -256,7 +260,7 @@ export function apply(ctx: Context, config: AcpConfig): void { ctx.on('agent/error', ({ agent, turn, error }) => { const record = ownedRecord(agent) const inflight = record?.inflight - if (record === undefined || inflight === undefined || inflight.turn === turn) return + if (record === undefined || inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return inflight.agentError = new Error(errorChain(error)) settleAfterQuiescence(record, inflight) }) @@ -341,6 +345,7 @@ export function apply(ctx: Context, config: AcpConfig): void { resolve: completion.resolve, reject: completion.reject, messageId: undefined, + messageQueued: false, turn: undefined, endReason: undefined, admissionDone: admission.promise, @@ -379,7 +384,15 @@ export function apply(ctx: Context, config: AcpConfig): void { } const message = createUserMessage({ content, source: { kind: 'user' } }) inflight.messageId = message.id - record.agent.followup(message) + inflight.messageQueued = true + try { + record.agent.followup(message) + } catch (error: unknown) { + // The typed same-process seam may fail synchronously before durable + // inbox receipt; restore the pre-operation boundary for mapping. + inflight.messageQueued = false + throw error + } } catch (error: unknown) { admissionFailed = true admissionFailure = error @@ -418,7 +431,10 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.admissionController.abort(new Error('ACP prompt cancelled')) settleAfterQuiescence(record, inflight) } - record.agent.cancel({ kind: 'user' }) + // Admission is not Agent work. Preserve unrelated producers until this + // prompt has entered the durable inbox; without a prompt, cancellation + // continues to target autonomous work on the addressed Agent. + if (inflight === undefined || inflight.messageQueued) record.agent.cancel({ kind: 'user' }) return Promise.resolve() }, } diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index e11023ce46..c72b4b9da3 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -287,6 +287,70 @@ describe('ACP prompt lifecycle', () => { expect(events.some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) }) + it('does not cancel unrelated Agent work while its prompt is still in admission', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: ['hang'] }) + const validationStarted = Promise.withResolvers() + const releaseValidation = Promise.withResolvers() + harness.attachments!.beforeValidate = () => { + validationStarted.resolve(undefined) + return releaseValidation.promise + } + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'unrelated work' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + await vi.waitFor(() => { expect(harness!.adapter.requests).toHaveLength(1) }) + + const prompt = harness.client.prompt({ + sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + }) + await validationStarted.promise + await harness.client.cancel({ sessionId }) + + expect(harness.adapter.requests[0]?.signal?.aborted).toBe(false) + releaseValidation.resolve(undefined) + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + expect(agent.status).toBe('running') + agent.cancel({ kind: 'hook', reason: 'test cleanup' }) + await agent.whenIdle() + }) + + it('does not attribute an unrelated Agent failure during prompt admission', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('answer')] }) + const validationStarted = Promise.withResolvers() + const releaseValidation = Promise.withResolvers() + harness.attachments!.beforeValidate = () => { + validationStarted.resolve(undefined) + return releaseValidation.promise + } + let failUnrelatedWork = true + harness.ctx.on('agent/pre-step', (_payload, next) => { + if (!failUnrelatedWork) return next() + failUnrelatedWork = false + throw new Error('unrelated pre-step failure') + }) + const sessionId = await newSession(harness) + const agent = harness.ctx.agents.get(SessionId(sessionId))! + const prompt = harness.client.prompt({ + sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + }) + await validationStarted.promise + + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'unrelated work' }], + source: { kind: 'plugin', plugin: 'test' }, + })) + await agent.whenIdle() + releaseValidation.resolve(undefined) + + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }) + expect(messageText(harness)).toBe('answer') + }) + it('does not queue admitted content into an agent retired during storage', async () => { harness = await makeBridgeHarness({ imageCapable: true, script: [] }) const validationStarted = Promise.withResolvers() From fdd1050510344216c42c2560d47f42914dda7811 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:47:40 +0800 Subject: [PATCH 037/146] test(snapshot): refresh code-mode image prompt --- .../both-mode-turn/system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 3771a70950..10df35add4 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -30,7 +30,7 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. The available tools: diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index 3dde6f9f77..04e072e025 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -5,6 +5,8 @@ You are a coding assistant powered by the deepseek-v4-flash model. Your working Verify your work by running the code or tests. Keep answers brief and factual. +`run_code` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program. + Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. @@ -21,6 +23,8 @@ Use the workflow tool ONLY when the user explicitly asks for a workflow or for l Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. + ## Writing code for run_code Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: @@ -82,7 +86,7 @@ interface ToolArgsMap { /** The agent id of the running agent to interrupt. */ agent_id: string; } & Record; - /** List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */ + /** List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only. */ list_agents: { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; @@ -120,23 +124,21 @@ interface ToolArgsMap { /** The exact skill name from the available skills list. */ name: string; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ + /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */ run_in_background?: boolean; } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */ + /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result. */ subagent_fork: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; - /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */ - run_in_background?: boolean; } & Record; /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ task_kill: { @@ -295,7 +297,7 @@ interface ToolOutputMap { kind: "child"; id: string; label: string; - status: "running" | "idle" | "complete"; + status: "running" | "idle" | "ready"; parent?: string; depth?: number; } | { From de1720605115be81a966833e7e232c4deddcc1c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:21:45 +0800 Subject: [PATCH 038/146] fix(attachment): type failure codes --- docs/subsystems/attachment.i18n.yaml | 4 +- docs/subsystems/attachment.md | 2 +- docs/subsystems/attachment.zh.md | 2 +- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/error.ts | 47 +++++++++++++------ packages/attachment/attachment/src/index.ts | 1 + .../attachment/attachment/tests/index.spec.ts | 3 +- 9 files changed, 43 insertions(+), 24 deletions(-) diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index c2438874b3..73873e074a 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/attachment.md -attachment.md: c769d9e608b9e1ab12a5960ca2629a297853bf26 -attachment.zh.md: d07ea722656fafd93793850b8dd268cb14e6856b +attachment.md: ff5a802b23b0111dff4481394772438f5d68feab +attachment.zh.md: 6ca15c1a5b079463066e8c09b1f9dd26faed7158 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index c769d9e608..ff5a802b23 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -121,5 +121,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:30`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index d07ea72265..6ca15c1a5b 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -121,5 +121,5 @@ abstract saveImage(input: SaveImageAttachment): Promise abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` -Source: [`packages/attachment/attachment/src/index.ts:30`](../../packages/attachment/attachment/src/index.ts) +Source: [`packages/attachment/attachment/src/index.ts:31`](../../packages/attachment/attachment/src/index.ts) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index b88b6b2132..7075b0fb50 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/attachment/attachment/README.md -README.md: 05c4bce5498f3c0bf172264be3e4b834ea0925e2 -README.zh.md: 91a454da09d32b0a87d02ca7ccb482e95c485a37 +README.md: 4fe608552492c33d2bd9acddce51ea1cf20acae4 +README.zh.md: a3093fc9dd1f926cb1c54831c6302eb7bbca25c5 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 05c4bce549..4fe6085524 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `isImageAdmissionError` distinguishes caller-correctable image-policy failures from storage faults so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 91a454da09..a3093fc9dd 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`isImageAdmissionError` 区分可由调用方修正的图片策略失败与存储故障,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 ## 模型体验 diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 071d2bc39b..125d31ad13 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -1,5 +1,31 @@ /** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ +const IMAGE_ADMISSION_ERROR_CODES = [ + 'TOO_MANY_IMAGES', + 'IMAGES_TOO_LARGE', + 'UNSUPPORTED_IMAGE_TYPE', + 'INVALID_IMAGE_BASE64', + 'INVALID_IMAGE', + 'IMAGE_TYPE_MISMATCH', + 'IMAGE_TOO_LARGE', + 'IMAGE_TOO_MANY_PIXELS', +] as const + +/** Caller-correctable attachment failure codes raised while admitting image input. */ +export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number] + +/** Stable attachment failure codes used for protocol error routing. */ +export type AttachmentErrorCode = + | ImageAdmissionErrorCode + | 'INVALID_ATTACHMENT_REF' + | 'ATTACHMENT_CORRUPT' + | 'ATTACHMENT_WRITE_FAILED' + | 'ATTACHMENT_NOT_FOUND' + | 'ATTACHMENT_READ_FAILED' + +/** Runtime membership for structurally compatible errors crossing package boundaries. */ +const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) + /** * Stable failures suitable for host RPC error mapping. * @@ -11,39 +37,30 @@ */ export class AttachmentError extends Error { /** Stable machine-routing failure code. */ - readonly code: string + readonly code: AttachmentErrorCode /** * @param message - human-readable failure description without raw bytes or host paths. * @param code - stable machine-routing code. * @param options - optional chained cause. */ - constructor(message: string, code: string, options?: ErrorOptions) { + constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions) { super(message, options) this.name = 'AttachmentError' this.code = code } } -/** Attachment failures caused by the caller's proposed image batch. */ -const IMAGE_ADMISSION_ERROR_CODES = new Set([ - 'TOO_MANY_IMAGES', - 'IMAGES_TOO_LARGE', - 'UNSUPPORTED_IMAGE_TYPE', - 'INVALID_IMAGE', - 'IMAGE_TYPE_MISMATCH', - 'IMAGE_TOO_LARGE', - 'IMAGE_TOO_MANY_PIXELS', -]) - /** * Distinguish caller-correctable image admission failures from storage faults. * @param error - failure raised while validating or persisting an image batch. * @returns whether the caller can correct the proposed image content or batch. */ -export function isImageAdmissionError(error: unknown): error is AttachmentError { +export function isImageAdmissionError( + error: unknown, +): error is AttachmentError & { readonly code: ImageAdmissionErrorCode } { return error instanceof Error && 'code' in error && typeof error.code === 'string' - && IMAGE_ADMISSION_ERROR_CODES.has(error.code) + && IMAGE_ADMISSION_ERROR_CODE_SET.has(error.code) } diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 8c411dbfa5..11283cfd4b 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -11,6 +11,7 @@ import type { export { AttachmentId } from './brand.ts' export { AttachmentError, isImageAdmissionError } from './error.ts' +export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export type { AttachmentId as AttachmentIdType, ImageAttachmentLimits, diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 18aa6894f2..61caacda0f 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -97,8 +97,9 @@ describe('AttachmentStore.saveImages', () => { }) describe('isImageAdmissionError', () => { - it('separates caller-correctable image policy failures from storage faults', () => { + it('separates caller-correctable image admission failures from storage faults', () => { expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true) + expect(isImageAdmissionError(new AttachmentError('bad base64', 'INVALID_IMAGE_BASE64'))).toBe(true) expect(isImageAdmissionError(new AttachmentError('too many', 'TOO_MANY_IMAGES'))).toBe(true) expect(isImageAdmissionError(Object.assign(new Error('foreign policy error'), { code: 'IMAGE_TOO_LARGE' }))).toBe(true) expect(isImageAdmissionError(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT'))).toBe(false) From f3bfcf33bb44ea349e77551f85b59e094deb881f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:39:17 +0800 Subject: [PATCH 039/146] fix(ci): budget native Windows coverage timing --- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 3 ++ scripts/ci-workflow.spec.ts | 3 ++ scripts/run-gates.spec.ts | 29 +++++++++++++++++++ scripts/run-gates.ts | 13 +++++++++ 7 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index dcdbff1208..faff260808 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98 -2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365 +2026-08-08-native-windows-pull-request-ci.md: a27be457621ecc9733bed7cf96465b5179ec1143 +2026-08-08-native-windows-pull-request-ci.zh.md: c1fc98eb456b3b9671f199b54b940beb02bc745f diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 33fbf1ae37..a27be45762 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Asynchronous fixtures whose real process, Git, SQLite, watcher, or lazy grammar startup can exceed Vitest's default polling window use explicit bounded waits without changing their asserted outcomes. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Both coverage gates set Vitest's default per-test and polling budgets to 15 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures repeatedly needed 8–10 seconds only under the complete lane's concurrent Windows instrumentation. This lane-scoped default preserves explicit fixture budgets and asserted outcomes, while the 60-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 552e5cd312..c1fc98eb45 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。对于真实进程、Git、SQLite、watcher 或延迟语法启动可能超过 Vitest 的默认轮询窗口的异步 fixture,系统会使用显式的有界等待,而不改变其断言结果。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 15 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture 反复需要 8–10 秒。这个只属于该通道的默认值保留了 fixture 显式预算的权威性和原有断言结果,60 分钟的作业截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2095c139dd..38a539bb74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -439,6 +439,9 @@ jobs: timeout-minutes: 60 env: DSH_COVERAGE_MAX_WORKERS: '2' + # Instrumented process and polling fixtures can exceed Vitest's defaults + # under the complete lane's concurrent gate load. + DSH_COVERAGE_TEST_TIMEOUT_MS: '15000' DSH_GATE_CONCURRENCY: '2' DSH_PUBLINT_CONCURRENCY: '8' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index fe0c7b87e5..8a6e7d1b06 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -64,6 +64,9 @@ describe('CI workflow', () => { expect(windowsNative['runs-on']).toContain('dsh-windows-2025-16core') expect(windowsNative.name).toBe('windows node 24 / native complete') expect(windowsNative.if).toBe("github.event_name == 'pull_request'") + expect(windowsNative.env).toMatchObject({ + DSH_COVERAGE_TEST_TIMEOUT_MS: '15000', + }) const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 6ef494b76b..e7071aaa86 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -92,6 +92,35 @@ describe('gate graph validation', () => { expect(byId.get('duplication')?.allowFailure).toBe(true) }) + it('applies one configured test and polling timeout to both coverage gates', () => { + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) + + for (const id of ['coverage', 'coverage-exempt-heavy']) { + expect(gates.find(subject => subject.id === id)?.args).toEqual(expect.arrayContaining([ + '--testTimeout=15000', + '--expect.poll.timeout=15000', + ])) + } + }) + + it('keeps Vitest timeout defaults when the coverage override is absent', () => { + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))) + + for (const id of ['coverage', 'coverage-exempt-heavy']) { + expect(gates.find(subject => subject.id === id)?.args).not.toEqual(expect.arrayContaining([ + expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout)=/), + ])) + } + }) + + it('rejects an invalid coverage timeout before starting a gate', () => { + expect(() => withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '0', () => + withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))) + .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') + }) + it.each([ ['empty', [], /gate graph has no gates/], ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index c824c96ac0..4905716ade 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -481,6 +481,9 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // small share. A budget of 1 gives each gate 1 worker; lanes that need a // strict total of one (the serial reference jobs) also set // DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all. +// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll +// defaults together for instrumented lanes whose scheduling overhead exceeds +// those defaults. Explicit fixture timeouts remain authoritative. function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') if (flag === undefined) return { instrumented: [], exempt: [] } @@ -493,14 +496,23 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { } } +function coverageTimeoutArgs(): string[] { + return [ + ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--testTimeout'), + ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--expect.poll.timeout'), + ] +} + function coverageGates(): Gate[] { const workers = coverageWorkerArgs() + const timeouts = coverageTimeoutArgs() return [ pnpmExec('coverage', [ 'vitest', 'run', '--coverage', ...workers.instrumented, + ...timeouts, ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, @@ -510,6 +522,7 @@ function coverageGates(): Gate[] { 'run', ...coverageExemptHeavySuites.map(suite => suite.filter), ...workers.exempt, + ...timeouts, ], { label: 'test:coverage-exempt-heavy', }), From 6e64f770305506ad894f2fec82fbf7d99eb67ee8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:58:19 +0800 Subject: [PATCH 040/146] test(plugin-inventory): avoid randomized id order --- .../plugin-inventory/tests/inventory.spec.ts | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index e979d34306..a8d04ce65d 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -52,28 +52,28 @@ describe('PluginInventoryService', () => { }) await ctx.loader.create({ name: 'cordis:active', group: true }) - expect(inventory.list()).toEqual({ - entries: [ - { - entryId: activeId, - moduleName: 'cordis:active', - enabled: true, - fiberPhase: 'active', - }, - { - entryId: pendingId, - moduleName: 'cordis:pending', - enabled: true, - fiberPhase: 'pending', - }, - { - entryId: disabledId, - moduleName: 'cordis:not-installed', - enabled: false, - fiberPhase: null, - }, - ], - }) + const entries = inventory.list().entries + expect(entries).toHaveLength(3) + expect(entries).toEqual(expect.arrayContaining([ + { + entryId: activeId, + moduleName: 'cordis:active', + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + moduleName: 'cordis:pending', + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + moduleName: 'cordis:not-installed', + enabled: false, + fiberPhase: null, + }, + ])) await ctx.loader.update(activeId, { disabled: true }) expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ From 238e7f456ac13a124c30b0b1a7e46b73f501e687 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:37:47 +0800 Subject: [PATCH 041/146] fix(docs): repair latest-master pairing drift --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 2 +- .../feature/2026-08-10-telemetry-default-off.zh.md | 2 +- packages/client/ui-settings-general/README.i18n.yaml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 7c4995a88d..5fbc0483e4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f -2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a +2026-08-10-telemetry-default-off.md: 3b9cd4bc3e9c98ee96ae036a0e981aa585a02403 +2026-08-10-telemetry-default-off.zh.md: 18f83869243b3d5c65278e654f4ac10e9bfe7d30 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 4bda346c2b..3b9cd4bc3e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 706f2c18fb..18f8386924 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 961fb0de13..3a0fae8d41 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-settings-general/README.md -README.md: d8578a7fbd451c1b7ec54dadeb3d391d597cc18e -README.zh.md: 246c04193e79f46f1e8035c6a40f55a20f1d0c26 +README.md: d02230d281482d03545a7dd9bb06fd5f1085d017 +README.zh.md: 9e2011902227c8d656f57813d4ecec92147d0f6f From d322206246dec8d210ee6210a148ef9d8305d0bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:41:06 +0800 Subject: [PATCH 042/146] fix(ci): stabilize latest-master acceptance gates --- ...2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../2026-08-08-native-windows-pull-request-ci.md | 2 +- .../2026-08-08-native-windows-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 2 +- apps/web/tests/scaffold.ts | 7 +++++-- .../workspace-context/tests/workspace-context.spec.ts | 2 +- scripts/ci-workflow.spec.ts | 2 +- scripts/coverage-exempt.ts | 1 + scripts/install-lefthook.mjs | 2 +- scripts/install-lefthook.spec.ts | 10 +++++----- 10 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index faff260808..17b2425233 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: a27be457621ecc9733bed7cf96465b5179ec1143 -2026-08-08-native-windows-pull-request-ci.zh.md: c1fc98eb456b3b9671f199b54b940beb02bc745f +2026-08-08-native-windows-pull-request-ci.md: 39dadfa9ba883bd9178091cf67a32dc44cf405f5 +2026-08-08-native-windows-pull-request-ci.zh.md: 14f21c3e97761a351621062a61636e5e9f33151f diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index a27be45762..39dadfa9ba 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -18,7 +18,7 @@ Every pull request also starts an ordinary independent `windows-native` job name The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. -The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Both coverage gates set Vitest's default per-test and polling budgets to 15 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures repeatedly needed 8–10 seconds only under the complete lane's concurrent Windows instrumentation. This lane-scoped default preserves explicit fixture budgets and asserted outcomes, while the 60-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +The 16-core lane gives coverage a two-worker budget, split into one instrumented worker and one exempt-heavy worker, runs two top-level gates concurrently, and allows eight publint workers. Every Vitest project uses forked workers because Node 24's CJS lexer fatal reproduced in shared worker threads on Windows and POSIX; the two-gate schedule prevents the exempt-heavy Oxlint probe from racing the workspace build over its temporary contract files. Both coverage gates set Vitest's default per-test and polling budgets to 30 seconds because unrelated process, Git, SQLite, watcher, grammar, and static-gate fixtures can exceed 15 seconds only under the complete lane's concurrent Windows instrumentation. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. These lane-scoped budgets preserve asserted outcomes, while the 60-minute job deadline still bounds a stuck run. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Relative to the previous two-core serial job, six coverage workers produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, but later exact-head repeats exposed unreliable fixtures and worker exits under four, three, and two concurrent instrumented workers. The selected budget therefore reduces that fan-out to one while retaining the exempt-heavy suite as a second concurrent coverage worker and preserving two-way top-level overlap. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index c1fc98eb45..14f21c3e97 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -18,7 +18,7 @@ Status: implemented 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 -16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 15 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture 反复需要 8–10 秒。这个只属于该通道的默认值保留了 fixture 显式预算的权威性和原有断言结果,60 分钟的作业截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +16 核通道为覆盖率分配 2 个工作线程,其中 1 个用于插桩套件,1 个用于免覆盖率项较多的套件;同时运行 2 项顶层门禁,并允许 8 个 publint 工作线程。每个 Vitest 项目都使用 fork 工作线程,因为 Node 24 的 CJS lexer 致命故障可在 Windows 与 POSIX 的共享工作线程中复现;双门禁调度可避免免覆盖率项较多的 Oxlint 探测与工作区构建在临时约定文件上发生竞态。两项覆盖率门禁都将 Vitest 默认的单测试和轮询时间预算设为 30 秒,因为在完整通道并发的 Windows 插桩下,多个互不相关的进程、Git、SQLite、watcher、语法和静态门禁 fixture 可能超过 15 秒。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入免覆盖率项较多的门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。这些只属于该通道的预算保留了原有断言结果,60 分钟的作业截止时间仍会约束卡死的运行。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。与此前的双核串行作业相比,6 个覆盖率工作线程曾分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,但后续的分支头精确复跑先后在 4 个、3 个和 2 个插桩工作线程并发时暴露出不稳定的 fixture 与工作线程退出。因此,所选预算将这一扇出降至 1,同时保留免覆盖率项较多的套件作为第二个并发覆盖率工作线程,并继续让两项顶层门禁重叠执行。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork 工作线程内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38a539bb74..3f9e72b056 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -441,7 +441,7 @@ jobs: DSH_COVERAGE_MAX_WORKERS: '2' # Instrumented process and polling fixtures can exceed Vitest's defaults # under the complete lane's concurrent gate load. - DSH_COVERAGE_TEST_TIMEOUT_MS: '15000' + DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' DSH_GATE_CONCURRENCY: '2' DSH_PUBLINT_CONCURRENCY: '8' steps: diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 99d1605a5b..3713423edc 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -51,7 +51,7 @@ import { dshHomePath } from '@deepseek-ai/dsh-paths' // } from '@deepseek-ai/dsh-client-ui-settings-general' export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding' export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' -export const WELCOME_NOTICE_VERSION = '2026-07-30.7' +export const WELCOME_NOTICE_VERSION = '2026-08-11.1' export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -421,7 +421,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise() async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise { - const signal = AbortSignal.timeout(1000) + const signal = new AbortController().signal await agentEvents(ctx, agent).waterfall( 'agent/pre-step', { messages: [], turn: 1, step: 1, signal }, diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 8a6e7d1b06..b2aa910bab 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -65,7 +65,7 @@ describe('CI workflow', () => { expect(windowsNative.name).toBe('windows node 24 / native complete') expect(windowsNative.if).toBe("github.event_name == 'pull_request'") expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_TEST_TIMEOUT_MS: '15000', + DSH_COVERAGE_TEST_TIMEOUT_MS: '30000', }) const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts index b560567014..eff6ca2b13 100644 --- a/scripts/coverage-exempt.ts +++ b/scripts/coverage-exempt.ts @@ -38,4 +38,5 @@ export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ { filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' }, { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, + { filter: 'scripts/translation-pairing-merge.spec.ts', exclude: 'scripts/translation-pairing-merge.spec.ts' }, ] diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 198f428b0a..3f8a4904ed 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -23,7 +23,7 @@ const OWNERSHIP_MARKER_VERSION = 1 const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks' const INSTALL_LOCK = 'dsh-lefthook-install.lock' const INSTALL_LOCK_TIMEOUT_MS = 30_000 -const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000 +const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 5_000 const INSTALL_LOCK_POLL_MS = 50 const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE' const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.' diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 2c429bba25..7078180cb3 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -22,9 +22,9 @@ const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url)) const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json'))) const fixtures: string[] = [] -// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can -// legitimately exceed Vitest's default deadline without changing the installer behavior. -const MULTI_PROCESS_TEST_TIMEOUT_MS = 20_000 +// Multi-worktree cases spawn several Git and Node subprocesses; native Windows +// coverage concurrency can delay them without changing installer behavior. +const MULTI_PROCESS_TEST_TIMEOUT_MS = 30_000 interface Fixture { container: string @@ -183,7 +183,7 @@ function installLockPath(fixture: Fixture): string { } async function waitForPath(path: string): Promise { - const deadline = Date.now() + 5_000 + const deadline = Date.now() + 10_000 while (!existsSync(path)) { if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`) await new Promise(resolveWait => setTimeout(resolveWait, 10)) @@ -210,7 +210,7 @@ function runInstaller( }) } -describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { +describe('worktree-local Lefthook installer', { timeout: 30_000 }, () => { for (const [label, extraEnv] of [ ['CI', { CI: 'true' }], ['GitHub Actions', { GITHUB_ACTIONS: 'true' }], From 4bc526f40b41a4a17e0f7f8f6bffe1df62eedac8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 21:07:48 +0800 Subject: [PATCH 043/146] test(web): realign the scaffold welcome-notice version mirror The e2e scaffold pre-acknowledges the welcome notice by mirroring WELCOME_NOTICE_VERSION from ui-settings-general. The client bumped it to 2026-08-11.1 while the mirror stayed at 2026-07-30.7, so the stale acknowledgement stopped suppressing the notice and its overlay covered the page: every settings-touching web e2e timed out clicking Settings. --- apps/web/tests/scaffold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 99d1605a5b..1d437ea97a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -51,7 +51,7 @@ import { dshHomePath } from '@deepseek-ai/dsh-paths' // } from '@deepseek-ai/dsh-client-ui-settings-general' export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding' export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' -export const WELCOME_NOTICE_VERSION = '2026-07-30.7' +export const WELCOME_NOTICE_VERSION = '2026-08-11.1' export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const import { settingsNamespace } from '@deepseek-ai/dsh-settings' From 4366528a382694971397a7aebf51bc0d63d80f7e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 21:07:57 +0800 Subject: [PATCH 044/146] feat(settings): serve every registered namespace and key plugin cards on it A plugin that registered a settings namespace could not reach the browser configuration page: the api-proxy filtered every read and gated every write through two hardcoded namespace lists, and the plugin configuration section rendered an unordered list of cards carrying an opaque id rather than the namespace they edit. Both gates lived in this repository, so a user-authored plugin was configurable only by hand-editing settings.yaml. The proxy now serves whatever ctx.settings.describe() returns and adds no boundary of its own; a name no registration answers folds into the seam's own settings-rejected, and the settings-not-exposed code retires. The settings seam is untouched: which client may read a namespace, and which page renders it, are facts about consumers. settings.plugin.item becomes a keyed slot whose key is the namespace a card edits, following tool.call.toolview. The section reads describe once and dispatches the intersection of the slot ledger and the served set, so a namespace another surface owns renders nothing without declaring anything, and a card for an uncomposed plugin is never dispatched. --- ...26-07-30-config-plane-boundaries.i18n.yaml | 4 +- .../2026-07-30-config-plane-boundaries.md | 2 + .../2026-07-30-config-plane-boundaries.zh.md | 2 + ...12-plugin-owned-settings-surface.i18n.yaml | 6 + ...026-08-12-plugin-owned-settings-surface.md | 57 +++++++++ ...-08-12-plugin-owned-settings-surface.zh.md | 57 +++++++++ ...6-08-10-web-plugin-configuration.i18n.yaml | 4 +- .../2026-08-10-web-plugin-configuration.md | 2 + .../2026-08-10-web-plugin-configuration.zh.md | 2 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- .../cookbook/adding-a-settings-card.i18n.yaml | 6 + docs/cookbook/adding-a-settings-card.md | 100 ++++++++++++++++ docs/cookbook/adding-a-settings-card.zh.md | 100 ++++++++++++++++ .../src/client/settings-store.ts | 2 +- .../tests/settings-store.client.spec.ts | 4 +- .../client/ui-plugin-config/README.i18n.yaml | 4 +- packages/client/ui-plugin-config/README.md | 10 +- packages/client/ui-plugin-config/README.zh.md | 10 +- .../src/client/PluginConfigSection.tsx | 36 +++--- .../ui-plugin-config/src/client/index.ts | 57 +++++---- .../src/client/section-store.ts | 110 ++++++++++++++++++ .../src/client/slot-contract.ts | 21 ++-- .../client/ui-plugin-config/src/invariant.ts | 4 +- .../tests/apply.client.spec.ts | 53 +++++++-- .../tests/section.client.spec.tsx | 37 ++++-- .../tests/stores.client.spec.ts | 94 +++++++++++++++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 71 ++--------- packages/host/apiproxy/src/api/rpc.schema.ts | 1 - packages/host/apiproxy/src/api/rpc.ts | 6 - .../apiproxy/tests/api-proxy-config.spec.ts | 61 +++++----- website/docs.ts | 3 +- 36 files changed, 745 insertions(+), 197 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md create mode 100644 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md create mode 100644 docs/cookbook/adding-a-settings-card.i18n.yaml create mode 100644 docs/cookbook/adding-a-settings-card.md create mode 100644 docs/cookbook/adding-a-settings-card.zh.md create mode 100644 packages/client/ui-plugin-config/src/client/section-store.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index 62d3cdf39a..ca24995abf 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md -2026-07-30-config-plane-boundaries.md: 0a689603b9619453f4a6613d79a82a1e671f0401 -2026-07-30-config-plane-boundaries.zh.md: b0e306a1adad971b611d315edfcdf9103091d022 +2026-07-30-config-plane-boundaries.md: a09049ab5cc89d2f2d83e30636602c002f7f347b +2026-07-30-config-plane-boundaries.zh.md: a151456b9e7a62e7e691a9e2101aed9470b212de diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index 0a689603b9..a09049ab5c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -6,6 +6,8 @@ English | [中文](2026-07-30-config-plane-boundaries.zh.md) > Scope: boundary hardening of the [web configuration plane](2026-07-30-web-config-plane.md) — which namespaces reach the wire, which callers reach them, and how an editor holding a partial, possibly stale view writes without destroying what it cannot see. +> The caller boundary, the redaction, and the revision fencing remain current. Restricting which namespaces reach the wire to the configurable-provider directory is superseded by the [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), which serves every registered namespace. + ## Problem The plane worked and was reachable by more callers, and with more authority, than its design claimed. diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index b0e306a1ad..a151456b9e 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -6,6 +6,8 @@ Status: implemented > 范围:对 [Web 配置面](2026-07-30-web-config-plane.md)的边界加固——哪些 namespace 能抵达协议、哪些调用方能抵达它们,以及一个只持有局部、且可能过期视图的编辑器该如何写入,才不会毁掉它看不见的东西。 +> 调用方边界、脱敏与 revision 设栅依然有效。把「哪些 namespace 能抵达协议」限制为可配置提供方目录这一条,已被[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)取代——后者服务每一个已注册的 namespace。 + ## 问题 这个面能用,但能触达它的调用方、以及它们所拥有的权限,都比设计声称的更多。 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml new file mode 100644 index 0000000000..6abe3f25e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.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 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +2026-08-12-plugin-owned-settings-surface.md: 3e6b75e8516312dc72313541b05e3dfb9f57140f +2026-08-12-plugin-owned-settings-surface.zh.md: ad06a25c5cb9023f15ca39d6302049c30fa36ce3 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md new file mode 100644 index 0000000000..3e6b75e851 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -0,0 +1,57 @@ +# Agent Note: Plugin-owned settings surface + +Status: implemented + +English | [中文](2026-08-12-plugin-owned-settings-surface.zh.md) + +## Problem + +A plugin that registered a settings namespace could not reach the browser configuration page, and both gates that stopped it lived in this repository. + +`packages/host/apiproxy` held two hardcoded namespace lists. `settings.describe` filtered its answer through them and every write checked them first, so a namespace outside them answered `settings-not-exposed` even when its owner had registered it. Adding a plugin to the configuration page therefore meant editing a package the plugin author does not own. + +The plugin configuration section rendered an unordered list of whatever cards were registered into `settings.plugin.item`. A card carried an opaque `id`, never the namespace it edited, so the section could not tell which served namespaces already had a home. That left every question about "who renders this namespace" unanswerable from the ledger the section could see. + +Together the two meant a user-authored plugin was configurable only by hand-editing `settings.yaml`. The [web plugin configuration note](../feature/2026-08-10-web-plugin-configuration.md) recorded the allowlist as deliberate, and the [config-plane boundaries note](2026-07-30-config-plane-boundaries.md) tied web-configurability to membership in the configurable-provider directory. Both conclusions blocked exactly the plugin authors the general seam was built for. + +## Decision + +**Registering is exposing.** The api-proxy serves every namespace `ctx.settings.describe()` returns and gates no write. `WEB_SETTINGS_NAMESPACES`, `PRODUCT_SETTINGS_NAMESPACES`, the union with `ctx.llm.listConfigurableProviders()`, and the `settings-not-exposed` error code are gone. A name no registration answers — unknown, or malformed and therefore unable to address one — folds into the seam's own `settings-rejected`, so the proxy contributes no boundary and no vocabulary of its own. + +**The settings seam is untouched.** Which client may read a namespace, and which page renders it, are facts about consumers; a Service Definition that carried either would let one Consumer dictate its contract. `SettingsRegisterOptions` gains nothing. + +**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. + +**The section drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the section's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer. + +Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the section counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line. + +**Nothing renders a form it was not given.** The section supplies no fallback card. A plugin's browser half owns its card completely — chrome, controls, and copy — which is what the slot's `fallback` option would have replaced with a schema-reverse-rendered form. + +## What the allowlist protected + +The removed gate was not the boundary it read as. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`. The read the gate blocked was already available to the same browser through the plugin inventory page, which lists every mounted plugin with its effective configuration. The writes it blocked were the least consequential ones on the plane: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served. + +The one namespace whose exposure actually changes is `agent-default-model`. It has no browser half, so nothing renders it. + +## Alternatives considered + +**A declaration on `settings.register()`** (`client: { surface: 'plugin-config' | 'custom', title, description }`), which the removed `WEB_SETTINGS_NAMESPACES` comment named as the intended direction. It keeps registration from crossing the transport by default and lets a plugin author self-serve in one line. Rejected because `surface` is browser-page vocabulary and `title`/`description` are presentation: a Service Definition carrying them is a seam shaped by one Consumer. Its fail-closed property is also worth less than it reads — see what the allowlist protected, above. + +**A separate exposure catalog**, a registry of its own that plugins join beside their settings registration, generalizing `ctx.llm.registerConfigurableProviders()`. Rejected because it makes one fact require two registrations that can drift: registering a namespace and forgetting the catalog entry produces a section nothing can edit, with no gate able to see the mistake. + +**A deny-list `Config` field on the api-proxy**, so a deployment could withhold a namespace. Rejected for having no consumer: every currently registered namespace is one a user may edit, and a genuinely sensitive field is answered per-field by `role('secret')`, which is the finer instrument. A namespace-wide switch invented ahead of its first use is the speculative option the package rules forbid. + +**A schema-driven generic card as the slot's `fallback`**, so a plugin with no browser half still got a form from `schema.toJSON()` (schemastery already carries `description`, `role`, `min`/`max`/`step` and serializes them). Rejected because client plugins load at runtime from mounted Loader entries, so a plugin author can ship a real card, and a reverse-rendered form was already judged worse than a hand-written one for the Models page. The `fallback` option remains available without a contract change if that judgment changes. + +**A client-side claim registry**, where each surface owning a namespace declares it so a generic card knows what is already covered. Rejected with the generic card: keyed dispatch already makes an unclaimed key render nothing, so the registry would restate what the slot ledger says. + +**Keeping the list slot and adding a namespace field to its options.** Rejected because the section would still enumerate entries rather than namespaces, keeping the empty-state defect and leaving a card for an uncomposed plugin to suppress itself. + +## Consequences + +A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`; the Host's description order is deliberately not the display order, because plugin activation can reorder it between boots and a settings page whose cards move between visits is worse than one whose order a registrant chose. + +The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect. + +Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md new file mode 100644 index 0000000000..ad06a25c5c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -0,0 +1,57 @@ +# Agent Note: 由插件自己拥有的设置表层 + +Status: implemented + +[English](2026-08-12-plugin-owned-settings-surface.md) | 中文 + +## Problem + +注册了 settings 命名空间的插件到不了浏览器配置页,而拦住它的两道门都在本仓库里。 + +`packages/host/apiproxy` 持有两份硬编码的命名空间清单。`settings.describe` 用它们过滤答复,每次写入也先对照它们,因此清单之外的命名空间即便其拥有方已注册,也只会得到 `settings-not-exposed`。于是把一个插件加进配置页,意味着要改一个插件作者并不拥有的包。 + +插件配置分区渲染的是注册进 `settings.plugin.item` 的卡片列表,无序。卡片携带的是不透明的 `id`,从不是它所编辑的命名空间,因此分区无从判断哪些被服务的命名空间已经有了归属。凡是"这个命名空间由谁渲染"的问题,都无法从分区看得见的账本里得到答案。 + +两者相加,用户自己写的插件就只能靠手改 `settings.yaml` 来配置。[web 插件配置 note](../feature/2026-08-10-web-plugin-configuration.md) 把白名单记为刻意为之,[配置面边界 note](2026-07-30-config-plane-boundaries.md) 则把「可在 Web 上配置」绑定到可配置提供方目录的成员资格。这两条结论恰恰挡住了那个通用 seam 本来要服务的插件作者。 + +## Decision + +**注册即暴露。** api-proxy 服务 `ctx.settings.describe()` 返回的每一个命名空间,写入不设门禁。`WEB_SETTINGS_NAMESPACES`、`PRODUCT_SETTINGS_NAMESPACES`、与 `ctx.llm.listConfigurableProviders()` 的并集,以及 `settings-not-exposed` 错误码,全部删除。没有任何注册应答的名字——未知的,或格式非法因而根本无法寻址到注册的——都折叠为 seam 自己的 `settings-rejected`,于是代理既不贡献边界,也不贡献自己的词汇。 + +**settings seam 不动。** 哪个客户端可以读某个命名空间、哪个页面渲染它,都是关于 Consumer 的事实;Service Definition 只要携带其中之一,就等于让一个 Consumer 决定它的契约。`SettingsRegisterOptions` 一个字段都没加。 + +**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。 + +**分区以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由分区的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。 + +以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:分区数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。 + +**不渲染任何未被交给它的表单。** 分区不提供兜底卡片。插件的浏览器半侧完整拥有自己的卡片——外观、控件与文案——而这正是 slot 的 `fallback` 选项会用一份 schema 反向渲染的表单取代掉的东西。 + +## 白名单实际护住了什么 + +被删掉的这道门并不是它读起来的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`。这道门挡住的读取,同一个浏览器早已能从插件清单页拿到——那一页列出每个已挂载插件及其 effective configuration。它挡住的写入,则是整个面上最无关紧要的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。 + +暴露状况真正发生变化的只有 `agent-default-model` 一个命名空间。它没有浏览器半侧,因此没有任何界面渲染它。 + +## Alternatives considered + +**在 `settings.register()` 上加声明**(`client: { surface: 'plugin-config' | 'custom', title, description }`),这也是被删掉的 `WEB_SETTINGS_NAMESPACES` 注释所点名的既定方向。它让注册默认不跨越传输边界,并让插件作者一行代码自助。否决的原因是 `surface` 是浏览器页面的词汇,而 `title`/`description` 属于呈现:Service Definition 一旦携带它们,就成了被单个 Consumer 塑形的 seam。它那条 fail-closed 性质的价值也不如读起来那么高——见上文「白名单实际护住了什么」。 + +**另设一份暴露目录**,插件在注册 settings 之外再加入这份自有注册表,即把 `ctx.llm.registerConfigurableProviders()` 一般化。否决的原因是它把一件事实拆成两处可能脱节的注册:注册了命名空间却忘了目录条目,产出的是一个谁都编辑不了的分节,而没有任何门禁看得见这个错误。 + +**给 api-proxy 加一个 deny-list `Config` 字段**,让部署方能扣下某个命名空间。因为没有消费者而否决:当前每一个已注册的命名空间都是用户可以编辑的,而真正敏感的字段由 `role('secret')` 逐字段作答,那是更精细的工具。在第一个用例出现之前就发明出来的整命名空间开关,正是包规则所禁止的投机选项。 + +**把 schema 驱动的通用卡片作为该 slot 的 `fallback`**,让没有浏览器半侧的插件也能从 `schema.toJSON()` 得到一份表单(schemastery 本就携带 `description`、`role`、`min`/`max`/`step` 并将其序列化)。否决的原因是客户端插件按已挂载的 Loader entries 在运行时加载,插件作者完全可以交付一张真正的卡片;而反向渲染的表单在模型页那次已被判定不如手写。若这个判断日后改变,`fallback` 选项无需改动契约即可启用。 + +**客户端认领注册表**,让每个拥有某命名空间的界面声明它,好让通用卡片知道哪些已经有人管。与通用卡片一并否决:keyed 派发本就让无人认领的键什么都不渲染,这份注册表只会把 slot 账本已经说过的话再说一遍。 + +**保留 list slot,只给它的 options 加一个命名空间字段。** 否决的原因是分区枚举的仍是 entry 而非命名空间,空态缺陷照旧,未组装插件的卡片也仍需自我抑制。 + +## Consequences + +在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`;Host 的描述顺序被刻意排除在展示顺序之外,因为插件激活时序会让它在不同次启动之间变化,而一个卡片会在两次访问之间移位的设置页,比一个顺序由注册方选定的设置页更糟。 + +分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。 + +对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index a27cb812e9..7e45b375a3 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: 7375f496c7af1a695243444fe56aca7262d3dedd -2026-08-10-web-plugin-configuration.zh.md: 59d65db39bcc2306983f2a26dcf252164d7a6f37 +2026-08-10-web-plugin-configuration.md: 146b0fc58f311dc48bd8eaa61d1658db1cf73bc2 +2026-08-10-web-plugin-configuration.zh.md: c728eb4e1edf69b43adee9fbf1d1e139da9868a4 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index 7375f496c7..146b0fc58f 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-10-web-plugin-configuration.zh.md) +> The three sections, the layering, and the staged-save form remain current. The Host allowlist and the unkeyed card list are superseded by the [plugin-owned settings surface](../architecture/2026-08-12-plugin-owned-settings-surface.md): every registered namespace is served, and cards are keyed on the namespace they edit. + ## Problem Everything a plugin can be configured with lived in `cordis.yml`. A user who wanted a longer shell timeout, a different search endpoint, or fewer parallel tool calls had to find the composition file, know its shape, and restart — while the Models page had shown for months that a settings namespace can be edited from the browser and take effect immediately. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index 59d65db39b..c728eb4e1e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-10-web-plugin-configuration.md) | 中文 +> 三个分节、分层解析与暂存保存表单依然有效。Host 白名单与无键卡片列表已被[由插件自己拥有的设置表层](../architecture/2026-08-12-plugin-owned-settings-surface.md)取代:每一个已注册的命名空间都被服务,卡片以它所编辑的命名空间为键。 + ## 问题 插件的一切可配置项都只存在于 `cordis.yml`。想要更长的 shell 超时、不同的搜索端点或更少的并行工具调用,用户必须找到组装文件、了解它的形状,然后重启——而 Models 页几个月来一直在证明:settings 命名空间可以在浏览器里编辑并立即生效。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d8caa93760..1ae95185ce 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/architecture.md -architecture.md: f5ebff879929079870c7936b424f03a02089c9d5 -architecture.zh.md: 1769f6febc4f156f6abccc5a19363f6eb55b6139 +architecture.md: 2e5e8dfdc0f6b66bb62f36c0276b9ab8d5af9973 +architecture.zh.md: 1b3da0d0dfebcb4cd4c57af307958189416561e5 diff --git a/docs/architecture.md b/docs/architecture.md index f5ebff8799..2e5e8dfdc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -197,4 +197,4 @@ New behavior attaches to a documented extension point; a loop change updates thi | Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` | | Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) | -[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [vendored packages](cookbook/adding-a-vendored-package.md). +[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), [settings cards](cookbook/adding-a-settings-card.md), and [vendored packages](cookbook/adding-a-vendored-package.md). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 1769f6febc..1b3da0d0df 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -197,4 +197,4 @@ idle inject: | fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` | | 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) | -[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 +[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)、[设置卡片](cookbook/adding-a-settings-card.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)。 diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml new file mode 100644 index 0000000000..a2149cd816 --- /dev/null +++ b/docs/cookbook/adding-a-settings-card.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 docs/cookbook/adding-a-settings-card.md +adding-a-settings-card.md: fa5524412e885677d6534702b16787abc135b30d +adding-a-settings-card.zh.md: 9670f815dd23e7f48e12902ca93fd436114431f0 diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md new file mode 100644 index 0000000000..fa5524412e --- /dev/null +++ b/docs/cookbook/adding-a-settings-card.md @@ -0,0 +1,100 @@ +# Cookbook: adding a settings card + +English | [中文](adding-a-settings-card.zh.md) + +How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the **Plugins** section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically. + +The two halves live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. [`packages/client/ui-theme`](../../packages/client/ui-theme) is a worked example of that packaging; the cards this section ships live in [`packages/client/ui-plugin-config`](../../packages/client/ui-plugin-config). + +## 1. Register the namespace (Host half) + +The namespace is the join key, so pick it once and spell it in both halves. A consumer that already has a `cordis.yml` entry should register through `installSettingsSection`, which layers the entry under the user document and keeps working when no settings provider is mounted: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import z from '@deepseek-ai/schemastery' + +declare function assertReachable(endpoint: string | undefined): void +declare function rebuildFromSettings(config: Config): void + +export const MY_PLUGIN_NS = settingsNamespace('my-plugin') + +export interface Config { + endpoint?: string + retries?: number +} + +export const Config: z = z.object({ + endpoint: z.string(), + retries: z.number().step(1).min(0).default(3), +}) + +export function apply(ctx: Context, config: Config) { + let source = () => config + installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, { + // Constraints the schema cannot express refuse the write, not the next use. + validate: value => void assertReachable(value.endpoint), + setSource: (current) => { source = current }, + onChange: () => { rebuildFromSettings(source()) }, + }) +} +``` + +`role('secret')` on a field keeps its value off every response; the card writes such a field into an `update`/`mutate` payload, or addresses a credential reference through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start. + +## 2. Register the card (browser half) + +The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read: + +```ts ignore-check +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes +// through cordis services; a value import fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-plugin-config/client' + +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] + +export function apply(ctx: ClientContext): void { + const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) + ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ + name: 'settings.plugin.item', + key: 'my-plugin', + locale: 'settings.myPlugin', + inject: () => card.inject(), + }, MyPluginCard), + ) +} +``` + +The scope snapshot carries what a form needs: the resolved `value`, the composition `base`, and the raw `user` layer, whose key **presence** — not its value — is what marks a field overridden. `scope.set(field, value)` stores one field and `scope.unset(field)` clears it back to the composition layer. + +## 3. What the section does with it + +The section reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (`ui-theme`, `permission`, `llm-*`) stay off this page. + +Cards appear in the order they registered into the slot; a keyed entry declares no `order` of its own. + +## Packaging + +The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export. So the plugin appears on the page as soon as a `cordis.yml` mounts it — no rebuild of the web application. + +```jsonc +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } + }, + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-plugin-config"] } } +} +``` + +The bundle must be the loader's lazy-CJS factory artifact. Inside this repository `tsdown.config.ts` is three lines over the shared preset: + +```ts ignore-check +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) +``` + +That preset is not published today, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under [the section's known limitations](../../packages/client/ui-plugin-config/README.md#known-limitations-and-deferred-work). diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md new file mode 100644 index 0000000000..9670f815dd --- /dev/null +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -0,0 +1,100 @@ +# Cookbook: 新增设置卡片 + +[English](adding-a-settings-card.md) | 中文 + +插件如何把自己的配置放上 Web 设置页。这条路径上没有任何一步需要改动本仓库:Host 服务每一个已注册的 settings 命名空间,而**插件配置**分区以卡片所编辑的命名空间为键,因此同时注册了两个半侧的插件会被自动配对。 + +两个半侧住在同一个包里——Host 半侧在 `src/`,浏览器半侧在 `src/client/`,以 `./client` 导出并用 `dsh.client` 声明。[`packages/client/ui-theme`](../../packages/client/ui-theme) 是这种打包方式的现成例子;本分区自带的卡片在 [`packages/client/ui-plugin-config`](../../packages/client/ui-plugin-config)。 + +## 1. 注册命名空间(Host 半侧) + +命名空间就是配对用的键,所以只挑一次,并在两个半侧都写出它。已经有 `cordis.yml` entry 的消费方应通过 `installSettingsSection` 注册——它把 entry 层叠在用户文档之下,并在没有挂载 settings provider 时照常工作: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import z from '@deepseek-ai/schemastery' + +declare function assertReachable(endpoint: string | undefined): void +declare function rebuildFromSettings(config: Config): void + +export const MY_PLUGIN_NS = settingsNamespace('my-plugin') + +export interface Config { + endpoint?: string + retries?: number +} + +export const Config: z = z.object({ + endpoint: z.string(), + retries: z.number().step(1).min(0).default(3), +}) + +export function apply(ctx: Context, config: Config) { + let source = () => config + installSettingsSection(ctx, MY_PLUGIN_NS, Config, config, { + // Constraints the schema cannot express refuse the write, not the next use. + validate: value => void assertReachable(value.endpoint), + setSource: (current) => { source = current }, + onChange: () => { rebuildFromSettings(source()) }, + }) +} +``` + +字段上的 `role('secret')` 让它的值不出现在任何响应里;卡片把这类字段写进 `update`/`mutate` 载荷,或改为经 `credentials` 领域寻址一个凭据引用。`applies: 'restart'` 告诉配置表层:拥有方要到下次启动才会对变更生效。 + +## 2. 注册卡片(浏览器半侧) + +卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅: + +```ts ignore-check +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes +// through cordis services; a value import fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-plugin-config/client' + +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] + +export function apply(ctx: ClientContext): void { + const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) + ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ + name: 'settings.plugin.item', + key: 'my-plugin', + locale: 'settings.myPlugin', + inject: () => card.inject(), + }, MyPluginCard), + ) +} +``` + +scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base`,以及原始的 `user` 层——字段是否被覆盖,取决于它在 `user` 层中是否**出现**,而非它的值。`scope.set(field, value)` 存一个字段,`scope.unset(field)` 把它清回组装层。 + +## 3. 分区拿它做什么 + +分区读取 Host 服务了哪些命名空间,并为每个命名空间派发一个 slot 键。当 Host 服务了某卡片的键时它被渲染,否则被跳过,因此从未组装过 Host 半侧的部署不会留下这张卡片的任何痕迹。被服务却无人认领的命名空间什么都不渲染——归其他页面所有的那些命名空间(`ui-theme`、`permission`、`llm-*`)正是这样留在本页之外的。 + +卡片按其注册进该 slot 的顺序出现;keyed entry 不声明自己的 `order`。 + +## 打包 + +浏览器半侧由[客户端模块系统](../../packages/client/modules)提供给页面:它扫描已启用的 Loader entries 中声明了 `dsh.client` 的包,并提供每个包构建出的 `./client` 导出。因此只要 `cordis.yml` 挂载了该插件,它就会出现在页面上——无需重新构建 Web 应用。 + +```jsonc +{ + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } + }, + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-plugin-config"] } } +} +``` + +bundle 必须是 loader 的 lazy-CJS factory 产物。在本仓库内,`tsdown.config.ts` 就是基于共享预设的三行: + +```ts ignore-check +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) +``` + +该预设目前未发布,因此本仓库之外的包得自行复刻同样的输出格式。bundle 纯净度门禁同时拒绝跨插件的值导入,所以卡片无法导入本分区的卡片外观或其暂存表单模型——它渲染自己的那一份,并自行拥有暂存与 revision 设栅。这两条限制都记在[本分区的已知限制](../../packages/client/ui-plugin-config/README.md#known-limitations-and-deferred-work)里。 diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 4f4e26bdf8..9589d5c2bb 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -216,7 +216,7 @@ export class AgentPresetSettingsController { // The roster says what may be chosen; `settings.describe` says whether // this browser may write the choice down. A non-loopback browser reaches // neither method, so a refused describe leaves the row read-only rather - // than offering a control whose write answers `settings-not-exposed`. + // than offering a control whose write the Host would refuse. const described = await this.api.settings.describe({}) this.set({ status: 'ready', diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 0a98138233..54fd600a60 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -67,8 +67,8 @@ describe('the agent-preset settings controller', () => { await controller.load() // `settings.describe` is loopback-only and reports a read-only provider; - // offering a control whose write answers `settings-not-exposed` would - // promise a switch the host refuses. + // offering a control whose write answers `settings-rejected` would promise + // a switch the host refuses. expect(controller.store.getSnapshot().writable).toBe(false) expect(controller.store.getSnapshot().currentValue).toBe('standard') }) diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index d112523b42..68ae116abf 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-plugin-config/README.md -README.md: 7e530d70f6573d619378e43b0245345b45d6db18 -README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc +README.md: 569f3a404f2b94fd6fb8dc5a4191cf66f37d55e2 +README.zh.md: 5eb96dc07ba7a438824ce6c6c3da707dd3d26285 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 7e530d70f6..569f3a404f 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -6,13 +6,13 @@ The **Plugins** settings section: one expandable card per Host plugin whose conf ## What appears here -A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs. +The section reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the section back from its empty line. Cards appear in the order they registered, not the order the Host describes their namespaces — plugin activation can reorder the description between boots. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". -The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). +The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). ## Extension point -The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`. +The section declares `settings.plugin.item`, a root keyed slot whose key is the settings namespace a card edits. A plugin that ships a browser half registers its own card under its own namespace and owns every part of it — chrome, controls, and copy; this package supplies no form it was not given and never learns what a namespace means. Keying on the namespace is what lets a plugin distributed outside this repository appear here: it registers the namespace on the Host and the card in the browser, and the section pairs the two. ## Writes @@ -35,6 +35,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job. -- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`. +- **A card still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. The bundle-purity gate also forbids importing this package's card chrome or form model as values, so such a card owns its own staging and revision fencing. +- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the section's read joins the list on the next document commit or reconnect. - **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card. -- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it. diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index fd4f980fcf..5eb96dc07b 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -6,13 +6,13 @@ ## 这里会出现什么 -只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。 +本分区读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装的插件不留任何痕迹,也不会挡住那行空态文案。卡片按自身注册的顺序出现,而非 Host 描述其命名空间的顺序——插件激活时序会让后者在不同次启动之间变化。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成"本部署没有可配置的插件"。 -第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ## 扩展点 -本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。 +本分区声明了根级 keyed slot `settings.plugin.item`,其键就是卡片所编辑的 settings 命名空间。带浏览器半侧的插件把自己的卡片注册在自己的命名空间上,并拥有它的全部——外观、控件与文案;本包不提供任何未被交给它的表单,也从不知道某个命名空间意味着什么。以命名空间为键,正是在本仓库之外分发的插件能出现在这里的原因:它在 Host 上注册命名空间、在浏览器里注册卡片,由本分区把两者配对。 ## 写入 @@ -35,6 +35,6 @@ ## 已知限制与暂缓事项 - **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。 -- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。 +- **卡片仍然需要一份浏览器 bundle**——浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。bundle 纯净度门禁同时禁止以值的形式导入本包的卡片外观与表单模型,所以这样的卡片要自行拥有暂存与 revision 设栅。 +- **被服务的命名空间只在两种信号上重读**——协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在本分区读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入列表。 - **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。 -- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face;之后注册的卡片不会让它变大。 diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx index 68de45eff3..d5d2104345 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -1,42 +1,48 @@ /** * Plugin configuration section: the shell around the per-plugin cards. It - * enumerates nothing itself — cards arrive through the `settings.plugin.item` - * slot it declares, so a plugin that ships a browser half owns its own card - * and this section never learns what a namespace means. + * enumerates settings namespaces but never interprets one — a card arrives + * through the `settings.plugin.item` slot keyed by the namespace it edits, so + * a plugin that ships a browser half owns its own card and this section only + * decides which keys to dispatch. */ +import { Fragment } from 'react' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from './slot-contract.ts' +import type { PluginConfigSectionFace } from './section-store.ts' import type { PluginConfigKey } from './locales.ts' import css from './PluginConfigSection.module.css' -/** Registration-side business face for the section. */ -export interface PluginConfigSectionInjected { - /** How many cards the slot ledger currently holds; zero renders the empty line. */ - cardCount: number -} - /** Props the renderer binds for the section. */ export type PluginConfigSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'settings.pluginConfig'> & PropsRenderSlots<'settings.plugin.item'> - & InjectFace + & InjectFace /** * Render the plugin configuration section. - * @param props - runtime slot rendering, locale copy, and the card count. + * @param props - runtime slot rendering, locale copy, and the namespaces to dispatch. * @returns the section. */ export function PluginConfigSection(props: PluginConfigSectionProps) { - const { t, renderSlot, cardCount } = props + const { t, renderSlot } = props + const { loaded, namespaces } = props.usePluginConfigSection(snapshot => snapshot) return (

{t('title')}

{t('intro')}

- {cardCount === 0 - ?

{t('empty')}

- :
    {renderSlot('settings.plugin.item', {})}
} + {namespaces.length > 0 + ? ( +
    + {namespaces.map(ns => ( + // One dispatch per namespace, so the list identity is the + // namespace rather than a position that shifts as cards arrive. + {renderSlot('settings.plugin.item', {}, { entryKey: ns })} + ))} +
+ ) + : loaded ?

{t('empty')}

: null}
) } diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index f3425634ef..aac61dcf04 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -2,12 +2,13 @@ * Plugin configuration surface, browser half — one settings section holding * an expandable card per Host plugin whose configuration a user owns. * - * The section owns no knowledge of any namespace: it declares the - * `settings.plugin.item` slot and renders whatever cards were registered into - * it, so a plugin that ships a browser half contributes its own card and its - * own controls. The three cards this package registers are the host-plane - * sections the deployment already exposes; each binds its namespace through - * the client settings scope, which keeps them unaware of one another. + * The section owns no knowledge of any namespace's meaning: it declares the + * `settings.plugin.item` slot, reads which namespaces the Host serves, and + * dispatches one key per namespace, so a plugin that ships a browser half + * contributes its own card under its own namespace and owns its controls. The + * three cards this package registers are the host-plane sections this + * repository ships; each binds its namespace through the client settings + * scope, which keeps them unaware of one another. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -26,16 +27,18 @@ import { PluginConfigSection } from './PluginConfigSection.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts' import { BASH_NS, BashCardController } from './bash-store.ts' +import { PluginConfigSectionController } from './section-store.ts' import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts' import { en, zh } from './locales.ts' -export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' +export type { PluginConfigSectionProps } from './PluginConfigSection.tsx' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' export type { FieldProps } from './fields.tsx' export type { CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell, } from './card-store.ts' +export type { PluginConfigSectionFace, PluginConfigSectionState } from './section-store.ts' export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts' export type { BashCardFace, BashCardState } from './bash-store.ts' export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts' @@ -58,6 +61,8 @@ export function apply(ctx: ClientContext): void { const bash = new BashCardController(ctx.settingsScope.bind({ namespace: BASH_NS })) const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), api) + const section = new PluginConfigSectionController(api, () => ctx.slots.entries('settings.plugin.item')) + ctx.effect(() => () => { section.dispose() }, 'ui-plugin-config: section directory') // The credential a card reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that @@ -67,42 +72,50 @@ export function apply(ctx: ClientContext): void { 'ui-plugin-config: credential invalidations', ) - // The section renders the empty line rather than an empty list when no plugin - // contributed a card. The count is read once: the renderer caches a root - // entry's inject face per registration, so this reports what was registered - // when the section mounted, not what is visible now. Both gaps are bounded by - // this deployment always registering the three cards below — a card that - // arrives later would not raise the count, and a namespace this deployment - // does not expose leaves its card rendering nothing inside a non-empty list. + // Which namespaces the Host serves is a registration fact the wire does not + // announce, so the directory re-reads on the two signals that can carry a + // changed composition: a settings document commit and a reconnect. + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { void section.load() }), + 'ui-plugin-config: served-namespace invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { void section.load() }), + 'ui-plugin-config: served-namespace reconnect', + ) + // A card registered after the first read joins the list without a wire call. + ctx.effect( + () => ctx.slots.subscribe('settings.plugin.item', () => { section.refresh() }), + 'ui-plugin-config: card ledger', + ) + void section.load() + ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'plugins', order: 30, label: () => t('nav'), locale: NS, - inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }), - children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + inject: () => section.inject(), + children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } }, }, PluginConfigSection)) ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', - id: 'bash', - order: 0, + key: BASH_NS, locale: NS, inject: () => bash.inject(), }, BashCard) yield ctx.slots.register({ name: 'settings.plugin.item', - id: 'agent-loop', - order: 10, + key: AGENT_LOOP_NS, locale: NS, inject: () => agentLoop.inject(), }, AgentLoopCard) yield ctx.slots.register({ name: 'settings.plugin.item', - id: 'web-search', - order: 20, + key: WEB_SEARCH_NS, locale: NS, inject: () => webSearch.inject(), }, WebSearchCard) diff --git a/packages/client/ui-plugin-config/src/client/section-store.ts b/packages/client/ui-plugin-config/src/client/section-store.ts new file mode 100644 index 0000000000..b12c014a0d --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/section-store.ts @@ -0,0 +1,110 @@ +/** + * The plugin configuration section's card list. + * + * The section dispatches its slot by settings namespace, so what it renders is + * the intersection of two ledgers: the namespaces the Host serves and the + * cards registered into `settings.plugin.item`. A served namespace no card + * claims renders nothing — another surface owns it, or this deployment ships + * no browser half for it — and a card whose namespace the Host does not serve + * is never dispatched, so a plugin this deployment did not compose leaves no + * trace and does not count toward the empty line. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** What the section renders. */ +export interface PluginConfigSectionState { + /** + * Whether the Host has answered once. The empty line waits for it: an + * unanswered read is not the same statement as "this deployment configures + * no plugin", and saying the second while the first is true would flash a + * wrong answer on every open. + */ + loaded: boolean + /** + * Namespaces to dispatch, in the order their cards registered, narrowed to + * those the Host serves. Card registration order rather than the Host's + * description order: the latter follows plugin activation, which async + * settings injection can reorder between boots, and a settings page whose + * cards move between visits is worse than one whose order a registrant + * chose. + */ + namespaces: string[] +} + +/** The registration-side face the section's slot entry injects. */ +export interface PluginConfigSectionFace { + hooks: { + /** Section snapshot bound by the renderer as usePluginConfigSection. */ + pluginConfigSection: SnapshotStore + } +} + +/** Reads the served namespaces and pairs them with the cards that claim them. */ +export class PluginConfigSectionController { + private readonly store = createSnapshotStore({ loaded: false, namespaces: [] }) + /** Last Host answer; kept so a slot mutation republishes without a wire read. */ + private served: readonly string[] = [] + private loaded = false + private generation = 0 + private disposed = false + + /** + * @param api - settings wire face. + * @param entries - reads the cards currently registered into the section's slot. + */ + constructor( + private readonly api: Pick, + private readonly entries: () => readonly StoredEntry[], + ) {} + + /** + * Re-read the served namespaces from the Host and republish. + * @returns settlement after the read, or immediately once disposed. + */ + async load(): Promise { + if (this.disposed) return + const generation = ++this.generation + let response: Awaited> + try { + response = await this.api.settings.describe({}) + } catch (_settingsReadFailure) { + // The section keeps the namespaces it last knew; the next invalidation + // or reconnect reads again. + return + } + if (this.disposed || generation !== this.generation || !response.result.ok) return + this.served = response.result.value.namespaces.map(view => view.ns) + this.loaded = true + this.publish() + } + + /** Republish after the slot ledger changed; a card registered late joins here. */ + refresh(): void { + if (this.disposed) return + this.publish() + } + + /** Stop publishing; an in-flight read settles without touching the store. */ + dispose(): void { + this.disposed = true + this.generation += 1 + } + + /** + * Build the face the section's slot registration injects. + * @returns the section's snapshot source. + */ + inject(): PluginConfigSectionFace { + return { hooks: { pluginConfigSection: this.store } } + } + + private publish(): void { + const served = new Set(this.served) + const namespaces = this.entries().flatMap(entry => + entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : []) + this.store.set({ loaded: this.loaded, namespaces }) + } +} diff --git a/packages/client/ui-plugin-config/src/client/slot-contract.ts b/packages/client/ui-plugin-config/src/client/slot-contract.ts index 02b00ea35b..c38568ac94 100644 --- a/packages/client/ui-plugin-config/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-config/src/client/slot-contract.ts @@ -1,19 +1,22 @@ /** * The `settings.plugin.item` slot type — one plugin's card inside the plugin - * configuration section. Options: `id` (card key), `order` (card position). - * A card draws its own internals; the section only stacks them and reports - * how many there are. + * configuration section, keyed by the settings namespace the card edits. + * Options: `key` (the namespace). A card draws its own internals; the section + * only decides which namespaces to dispatch and stacks what comes back. * - * TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span - * packages that cannot reference its declarer, every current registrant of - * this slot ships in this package, and a plugin registering its own card - * already depends on this package for the card chrome. The type therefore - * lives with the section that declares it at runtime. + * Keying on the namespace is what lets a plugin distributed outside this + * repository contribute a card: it registers its own settings namespace on the + * Host and its own card under that key in the browser, and the section pairs + * the two without ever learning what the namespace means. + * + * TYPE HOME RATIONALE: the section declares this slot at runtime, and a plugin + * registering its own card already depends on this package for the slot's + * declaration. The type therefore lives with its declarer. */ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** One plugin's card inside the plugin configuration section (see module JSDoc). */ - 'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps } + 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: SettingsPluginItemOwnerProps } } } diff --git a/packages/client/ui-plugin-config/src/invariant.ts b/packages/client/ui-plugin-config/src/invariant.ts index b65c7f8757..20fd064cbb 100644 --- a/packages/client/ui-plugin-config/src/invariant.ts +++ b/packages/client/ui-plugin-config/src/invariant.ts @@ -16,8 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this is a browser-side settings surface whose node half owns no event - * stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host - * contracts covered by the owning plugins and the api-proxy. + * stream or mutable runtime data; the layering and write refusals are Host contracts covered by + * the owning plugins and the api-proxy. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-plugin-config/tests/apply.client.spec.ts b/packages/client/ui-plugin-config/tests/apply.client.spec.ts index a880446e8c..a7eeb952b9 100644 --- a/packages/client/ui-plugin-config/tests/apply.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.client.spec.ts @@ -13,12 +13,31 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -async function bench() { +/** + * @param served - namespaces the Host describes; omitted answers a failed read, + * which is what most of these specs want (no card has anything to render). + */ +async function bench(served?: string[]) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { rpcId: 's', result: { ok: false, error: {} } } + : { + rpcId: 's', + result: { + ok: true, + value: { + writable: true, + hasDocument: true, + namespaces: served.map(ns => ({ + ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0, + })), + }, + }, + })) // The section binds its scopes through the Settings surface's service, and // forwarded Host events reach it through the same `$dispatch` handoff the // connection sink makes. @@ -26,12 +45,12 @@ async function bench() { ctx.provide('connection', { isLoopback: true, api: { - settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) }, + settings: { describe: describeSettings }, credentials: { describe: describeCredentials }, }, } as never) await ctx.plugin(SettingsScopeService).await() - return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials } + return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials, describeSettings } } function declareRoot(slots: SlotsService): () => void { @@ -56,26 +75,40 @@ describe('ui-plugin-config apply', () => { expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) // The nav label is a locale-following thunk; owners resolve it at read time. expect(resolveSlotLabel(section.options.label)).toBe('插件配置') - expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) + expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' }) }) - it('registers one card per host-plane section it ships, in a stable order', async () => { + it('keys each card it ships on the settings namespace that card edits', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - expect(slots.entries('settings.plugin.item').map(entry => entry.options.id)) - .toEqual(['bash', 'agent-loop', 'web-search']) + expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) + .toEqual(['bash', 'agent-loop', 'web-search-deepseek']) }) - it('injects a live card count and one business face per card', async () => { - const { ctx, slots } = await bench() + it('dispatches the served namespaces its cards claim, and no others', async () => { + // ui-theme is served but belongs to another surface, and a deployment + // composing no PowerShell/POSIX executor serves no `bash` at all. + const { ctx, slots } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! - expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 }) + const face = (section as { inject?: () => unknown }) + .inject?.() as { hooks: { pluginConfigSection: { getSnapshot: () => { namespaces: string[] } } } } + await vi.waitFor(() => { + expect(face.hooks.pluginConfigSection.getSnapshot().namespaces) + .toEqual(['agent-loop', 'web-search-deepseek']) + }) + }) + + it('injects one business face per card', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + for (const entry of slots.entries('settings.plugin.item')) { const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } // Each card injects exactly one snapshot store plus its own actions. diff --git a/packages/client/ui-plugin-config/tests/section.client.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx index 3945092587..40281070dc 100644 --- a/packages/client/ui-plugin-config/tests/section.client.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.client.spec.tsx @@ -20,6 +20,7 @@ import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' import type { BashCardState } from '../src/client/bash-store.ts' import type { CardFieldState, CardShell } from '../src/client/card-store.ts' +import type { PluginConfigSectionState } from '../src/client/section-store.ts' import type { WebSearchCardState } from '../src/client/web-search-store.ts' import { en } from '../src/client/locales.ts' @@ -46,11 +47,20 @@ function cardActions() { return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } } -function renderSection(cardCount: number, cards = 'cards') { +/** + * Render the section over the namespaces it was told to dispatch, with `cards` + * standing in for the slot ledger: a key it names renders that text, and one + * it does not renders nothing, exactly as an unclaimed key does. + */ +function renderSection(namespaces: string[], cards: Record = {}, loaded = true) { + const store = createSnapshotStore({ loaded, namespaces }) const props = { t, - cardCount, - renderSlot: () =>
  • {cards}
  • , + usePluginConfigSection: bindSnapshotSelector(store), + renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => { + const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey] + return card === undefined ? null :
  • {card}
  • + }, } as unknown as PluginConfigSectionProps render() } @@ -70,21 +80,30 @@ function renderBash(state: Partial = {}) { describe('PluginConfigSection', () => { it('says so when no plugin contributed a card', () => { - renderSection(0) + renderSection([], { bash: 'shell' }) expect(screen.getByText(en.empty)).toBeTruthy() - expect(screen.queryByText('cards')).toBeNull() + expect(screen.queryByText('shell')).toBeNull() }) - it('renders the card list once a plugin contributed one', () => { - renderSection(1) + it('withholds the empty line until the Host has answered once', () => { + // An unanswered read is not the statement that this deployment configures + // no plugin; saying it anyway would flash a wrong answer on every open. + renderSection([], { bash: 'shell' }, false) - expect(screen.getByText('cards')).toBeTruthy() + expect(screen.queryByText(en.empty)).toBeNull() + expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() + }) + + it('dispatches one card per namespace, keyed by it', () => { + renderSection(['bash', 'agent-loop'], { bash: 'shell', 'agent-loop': 'loop' }) + + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['shell', 'loop']) expect(screen.queryByText(en.empty)).toBeNull() }) it('leads with its own heading and intro', () => { - renderSection(1) + renderSection(['bash'], { bash: 'shell' }) expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() expect(screen.getByText(en.intro)).toBeTruthy() diff --git a/packages/client/ui-plugin-config/tests/stores.client.spec.ts b/packages/client/ui-plugin-config/tests/stores.client.spec.ts index 78e1ee90c7..09be456aa6 100644 --- a/packages/client/ui-plugin-config/tests/stores.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/stores.client.spec.ts @@ -8,6 +8,7 @@ import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-clie import { CardForm, numberField, textField } from '../src/client/card-store.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts' import { BashCardController, type BashSettings } from '../src/client/bash-store.ts' +import { PluginConfigSectionController } from '../src/client/section-store.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -538,3 +539,96 @@ describe('WebSearchCardController', () => { expect(credentials.set).not.toHaveBeenCalled() }) }) + +describe('PluginConfigSectionController', () => { + function settingsApi(namespaces: string[]) { + const describe = vi.fn(() => Promise.resolve({ + rpcId: 's-1' as never, + result: { + ok: true as const, + value: { + writable: true, + hasDocument: true, + namespaces: namespaces.map(ns => ({ + ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0, + })), + }, + }, + })) + return { api: { settings: { describe } } as never, describe } + } + + /** Slot ledger stand-in: one stored entry per registered card key. */ + function ledger(...keys: string[]) { + return keys.map(key => ({ component: null, options: { key } })) + } + + it('dispatches the served namespaces a card claims, in card registration order', async () => { + const settings = settingsApi(['bash', 'ui-theme', 'agent-loop']) + const controller = new PluginConfigSectionController(settings.api, () => ledger('agent-loop', 'bash')) + + await controller.load() + + // ui-theme is served but claimed by no card here — another surface owns + // it. The order is the cards', not the Host's: plugin activation can + // reorder the description between boots. + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces) + .toEqual(['agent-loop', 'bash']) + }) + + it('never dispatches a card whose namespace this deployment does not serve', async () => { + const settings = settingsApi(['bash']) + const controller = new PluginConfigSectionController(settings.api, () => ledger('bash', 'web-search-deepseek')) + + await controller.load() + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash']) + }) + + it('takes a card registered after the read without asking the Host again', async () => { + const settings = settingsApi(['bash']) + let entries = ledger() + const controller = new PluginConfigSectionController(settings.api, () => entries) + await controller.load() + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual([]) + + entries = ledger('bash') + controller.refresh() + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash']) + expect(settings.describe).toHaveBeenCalledOnce() + }) + + it('keeps the namespaces it knew when a read fails', async () => { + const settings = settingsApi(['bash']) + const controller = new PluginConfigSectionController(settings.api, () => ledger('bash')) + await controller.load() + settings.describe.mockRejectedValueOnce(new Error('offline') as never) + + await controller.load() + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash']) + }) + + it('publishes nothing once disposed, and never claims it was answered', async () => { + const settings = settingsApi(['bash']) + const controller = new PluginConfigSectionController(settings.api, () => ledger('bash')) + + controller.dispose() + await controller.load() + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot()) + .toEqual({ loaded: false, namespaces: [] }) + expect(settings.describe).not.toHaveBeenCalled() + }) + + it('reports the Host answered even when it serves nothing this section shows', async () => { + const settings = settingsApi(['ui-theme']) + const controller = new PluginConfigSectionController(settings.api, () => ledger('bash')) + + await controller.load() + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot()) + .toEqual({ loaded: true, namespaces: [] }) + }) +}) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f0409bebe2..c71155a43e 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/host/apiproxy/README.md -README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8 -README.zh.md: 17bbad0094bfac49d63d6076a01d4c5cd2c5aa6b +README.md: 79a1386b5eb7d61079c34da4ad1e392560f6414f +README.zh.md: 334a5edb49b795f8e036b71206c1d720542f3607 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 059c3eacbc..79a1386b5e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -58,7 +58,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, the host-plane plugin sections `agent-loop`, `bash`, and `web-search-deepseek` that the plugin configuration page edits, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves every registered namespace: a plugin distributed outside this repository becomes browser-configurable by registering its section, with no change here, and this proxy adds no boundary of its own — a name no registration answers folds into the seam's own `settings-rejected`. Which surface renders a namespace is the browser's decision (the plugin configuration page keys its cards on the namespace), never this proxy's. `settings.describe` returns each namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 17bbad0094..334a5edb49 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -58,7 +58,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`、插件配置页所编辑的宿主平面插件分节 `agent-loop`、`bash` 与 `web-search-deepseek`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于每一个已注册 namespace:在本仓库之外分发的插件只要注册自己的分节即可变得可从浏览器配置,无需改动这里;本代理也不再自设边界——没有任何注册应答的名字会折叠为 seam 自己的 `settings-rejected`。由哪个界面渲染某个 namespace 是浏览器的决定(插件配置页按 namespace 为其卡片编键),从不由本代理决定。`settings.describe` 为每个 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 9f4114c811..d2a5b554f5 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -30,8 +30,7 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { InvalidPresetIdError, PresetExistsError, PresetMountError, - PresetNotWritableError, resolveSessionPreset, - SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError, + PresetNotWritableError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-tools' @@ -108,20 +107,6 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native- /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 -/** - * Non-model settings namespaces intentionally served to the Web client. The - * plugin-owned entries (`agent-loop`, `bash`, `web-search-deepseek`) are the - * host-plane sections the plugin configuration page edits; a namespace absent - * here answers `settings-not-exposed` even when its owner registered it, so - * adding a section to that page is a decision made here rather than by the - * registering plugin. Moving that declaration to `settings.register()`, so a - * plugin can expose its own configuration without a change in this package, - * is deferred work. - */ -const WEB_SETTINGS_NAMESPACES = [ - 'agent-loop', 'bash', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek', -] as const - /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 @@ -238,16 +223,6 @@ function referencedImage(events: readonly SessionEvent[], attachmentId: string): return undefined } -/** - * Product settings intentionally exposed beside model-provider namespaces. - * - * The agent-preset namespace carries one field — which preset a session with - * no explicit choice is composed from — and both browser surfaces that offer - * that choice write it through `settings.update`, so it has to cross the - * configuration boundary or the pickers silently fail to persist. - */ -const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE]) - /** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ @@ -1857,39 +1832,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** Settings namespaces whose changes can invalidate the model catalog. */ - function modelProviderNamespaces(): Set { - return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs)) - } - - /** - * The settings namespaces this proxy serves: configurable model providers - * plus the small explicit Web preference and product-owned allowlists. The - * settings seam remains general; a future registration does not become - * remotely readable or writable by default. - */ - function exposedNamespaces(): Set { - const exposed = modelProviderNamespaces() - for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns) - for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns) - return exposed - } - - /** Refuse a namespace outside the explicit configuration-client boundary. */ - function notExposed(request: RpcRequest, ns: string): RpcResponse { - return err(request, { - code: 'settings-not-exposed', - message: `settings namespace "${ns}" is not exposed to configuration clients`, - details: { ns }, - }) - } - /** * Run one settings write (merge or wholesale replace) and acknowledge with - * the namespace's new redacted view. A namespace outside the configuration - * boundary is refused before the seam is touched; every seam refusal — - * unknown or invalid namespace, read-only provider, schema validation, - * storage — becomes one `settings-rejected` carrying the seam's own message. + * the namespace's new redacted view. Every seam refusal — unknown or invalid + * namespace, read-only provider, schema validation, storage — becomes one + * `settings-rejected` carrying the seam's own message. */ async function settingsWrite( request: RpcRequest, @@ -1920,11 +1867,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro try { branded = settingsNamespace(ns) } catch (error: unknown) { - // A malformed name is a client bug, reported as such; it could never be - // in the exposed set either, so naming the real fault costs no ground. + // A malformed name can address no registration, so it fails exactly as + // an unregistered one does. return rejected(error) } - if (!exposedNamespaces().has(ns)) return notExposed(request, ns) try { if (mode === 'update') await settings.update(branded, section, expectedRevision) else if (mode === 'replace') await settings.replace(branded, section, expectedRevision) @@ -3179,13 +3125,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro describe(request) { const settings = ctx.get('settings') if (settings === undefined) return Promise.resolve(err(request, settingsAbsent())) - const exposed = exposedNamespaces() return Promise.resolve(ok(request, { writable: settings.writable, hasDocument: settings.documentPath !== undefined, - namespaces: settings.describe({ redactSecrets: true }) - .filter(descriptor => exposed.has(String(descriptor.ns))) - .map(namespaceView), + namespaces: settings.describe({ redactSecrets: true }).map(namespaceView), })) }, async openDocument(request, signal) { diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 177f0ffd29..03cfdd1e15 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -59,7 +59,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), - z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }), z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }), z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }), z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 134a39eba9..0b5506b6b6 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -63,12 +63,6 @@ export interface RpcErrorDetailsMap { * read-only provider, or storage failure); the message is the seam's text. */ 'settings-rejected': { ns: string } - /** - * A settings namespace exists in the seam but is outside the configuration - * plane's model-provider boundary, so this proxy neither reads nor writes - * it; the message names the namespace. - */ - 'settings-not-exposed': { ns: string } /** * A settings write carried an `expectedRevision` the namespace has already * moved past: another writer (tab, editor, or an external file edit) landed diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 99e5796367..5f763ee207 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -321,12 +321,11 @@ describe('settings domain', () => { expect(opened).toEqual([]) }) - it('serves model-provider and explicitly allowlisted Web namespaces only', async () => { - // The settings seam is general: any plugin may register a namespace for - // its own configuration. The Web configuration plane remains opt-in, so a - // future internal plugin cannot become remotely configurable just by - // registering; locale, permission, conversation, theme, and the product - // onboarding namespace are intentionally admitted by this surface. + it('serves every registered namespace, including one this repository never named', async () => { + // Registering IS the exposure: a plugin distributed outside this + // repository configures itself from the browser without a change here. + // The plane stays loopback-only and secret-redacted, and which surface + // renders a namespace is the browser's decision, not this proxy's. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -357,8 +356,8 @@ describe('settings domain', () => { const value = expectOk(await api.settings.describe(request({}))) expect(value.namespaces.map(view => view.ns)).toEqual([ - 'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation', - 'bash', 'agent-loop', 'web-search-deepseek', + 'llm-deepseek', 'some-other-plugin', 'permission', 'ui-theme', 'locale', + 'ui-conversation', 'bash', 'agent-loop', 'web-search-deepseek', ]) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', @@ -396,16 +395,13 @@ describe('settings domain', () => { }))) expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' }) - for (const response of [ - await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), - await api.settings.replace(request({ ns: 'some-other-plugin', section: {} })), - ]) { - const error = expectErr(response) - expect(error.code).toBe('settings-not-exposed') - expect(error.details).toEqual({ ns: 'some-other-plugin' }) - } - // The write never reached the seam. - expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({}) + const other = expectOk(await api.settings.update(request({ + ns: 'some-other-plugin', + patch: { secretPath: '/etc/shadow' }, + }))) + expect(other.value).toEqual({ secretPath: '/etc/shadow' }) + expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value) + .toEqual({ secretPath: '/etc/shadow' }) }) it('serves product preference namespaces without invalidating the model catalog', async () => { @@ -445,13 +441,17 @@ describe('settings domain', () => { .toEqual({ default: 'minimal' }) }) - it('refuses even a model-provider namespace once its directory entry is gone', async () => { + it('keeps serving a provider namespace whose directory entry is gone', async () => { + // The configurable-provider directory says what the Models page can offer, + // not what a user may configure: a dormant route's stored section is still + // theirs to edit, and losing the entry must not strand it. const ctx = await harness({ configurableProviders: false }) ctx.settings.register(NS, AdapterConfig) const api = createApiProxy(ctx, DEFAULTS) - expect(expectOk(await api.settings.describe(request({}))).namespaces).toEqual([]) - expect(expectErr(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).code) - .toBe('settings-not-exposed') + expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) + .toEqual(['llm-deepseek']) + expect(expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://x' } }))).value) + .toMatchObject({ baseURL: 'https://x' }) }) it('forwards a provider settings change for model-catalog consumers', async () => { @@ -551,19 +551,18 @@ describe('settings domain', () => { expect(error.details).toEqual({ ns }) }) - it('answers an unregistered namespace exactly like an unexposed one', async () => { - // Deliberately indistinguishable: separating "does not exist" from - // "exists but is not yours to configure" would let a caller enumerate the - // registered namespaces one probe at a time. + it('answers an unregistered namespace as the seam does, and a malformed one alike', async () => { + // A name no registration answers and a name no registration could answer + // fold into the same rejection: the proxy adds no boundary of its own, so + // the seam's own refusal is the whole answer. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) - ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) const api = createApiProxy(ctx, DEFAULTS) const unknown = expectErr(await api.settings.update(request({ ns: 'unknown-ns', patch: {} }))) - const unexposed = expectErr(await api.settings.update(request({ ns: 'some-other-plugin', patch: {} }))) - expect(unknown.code).toBe('settings-not-exposed') - expect(unexposed.code).toBe(unknown.code) - expect(unexposed.message.replace('some-other-plugin', 'unknown-ns')).toBe(unknown.message) + const malformed = expectErr(await api.settings.update(request({ ns: 'Not A Namespace', patch: {} }))) + expect(unknown.code).toBe('settings-rejected') + expect(unknown.message).toContain('is not registered') + expect(malformed.code).toBe(unknown.code) }) it('maps a read-only provider refusal onto the same rejection', async () => { diff --git a/website/docs.ts b/website/docs.ts index 6c75635a80..dde04d1afe 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -392,6 +392,7 @@ const reference = [ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], + ['adding-a-settings-card.md', '新增设置卡片', 'Adding a settings card'], ['extension-cookbook.md', '扩展模式', 'Extension patterns'], ] as const).map(([file, rootLabel, enLabel], order): PairedPage => ({ source: `docs/cookbook/${file}`, @@ -407,7 +408,7 @@ const reference = [ label: { root: '新增 Conversation Node', en: 'Adding a Conversation Node' }, sidebar: { root: 'zh-reference', en: 'en-reference' }, section: { root: '开发手册', en: 'Cookbook' }, - order: 4, + order: 5, }]), ] From d8035680b9d642e619311b23b4a3a14bd7955d85 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 21:24:53 +0800 Subject: [PATCH 045/146] test(settings): cover the section directory's disposal, stale-read, and invalidation paths The per-file coverage gate flagged four uncovered locations the new section directory introduced: refresh() after disposal, a read superseded by a newer one, and the two invalidation handlers that make the served namespaces re-read (settings/document-updated and connection/reset). --- .../tests/apply.client.spec.ts | 27 ++++++++++++++ .../tests/stores.client.spec.ts | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/client/ui-plugin-config/tests/apply.client.spec.ts b/packages/client/ui-plugin-config/tests/apply.client.spec.ts index a7eeb952b9..63b3f30406 100644 --- a/packages/client/ui-plugin-config/tests/apply.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.client.spec.ts @@ -116,6 +116,33 @@ describe('ui-plugin-config apply', () => { } }) + it('re-reads the served namespaces when the Host commits a settings document', async () => { + // Which namespaces the Host serves is a registration fact the wire never + // announces on its own, so the section rides the invalidation that can + // accompany a changed composition. + const { ctx, slots, describeSettings } = await bench(['bash']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + describeSettings.mockClear() + + ctx.remote.$dispatch('settings/document-updated', ['bash', 1]) + + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + }) + + it('re-reads the served namespaces after a reconnect', async () => { + const { ctx, slots, describeSettings } = await bench(['bash']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + describeSettings.mockClear() + + ctx.emit('connection/reset') + + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + }) + it('re-reads the credential when the Host reports the watched reference changed', async () => { const { ctx, slots, describeCredentials } = await bench() declareRoot(slots) diff --git a/packages/client/ui-plugin-config/tests/stores.client.spec.ts b/packages/client/ui-plugin-config/tests/stores.client.spec.ts index 09be456aa6..30a537a38f 100644 --- a/packages/client/ui-plugin-config/tests/stores.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/stores.client.spec.ts @@ -622,6 +622,41 @@ describe('PluginConfigSectionController', () => { expect(settings.describe).not.toHaveBeenCalled() }) + it('ignores a slot-ledger change that arrives after disposal', async () => { + const settings = settingsApi(['bash']) + let entries = ledger() + const controller = new PluginConfigSectionController(settings.api, () => entries) + await controller.load() + + controller.dispose() + entries = ledger('bash') + controller.refresh() + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual([]) + }) + + it('drops a read a newer one superseded', async () => { + // The section re-reads on every settings-document invalidation, so a slow + // first answer must not overwrite the newer one that already landed. + const settings = settingsApi(['bash']) + const controller = new PluginConfigSectionController(settings.api, () => ledger('bash', 'agent-loop')) + const slow = Promise.withResolvers() + settings.describe.mockReturnValueOnce(slow.promise as never) + const stale = controller.load() + + await controller.load() + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash']) + slow.resolve({ + rpcId: 's-0', + result: { ok: true, value: { writable: true, hasDocument: true, namespaces: [ + { ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 0 }, + ] } }, + }) + await stale + + expect(controller.inject().hooks.pluginConfigSection.getSnapshot().namespaces).toEqual(['bash']) + }) + it('reports the Host answered even when it serves nothing this section shows', async () => { const settings = settingsApi(['ui-theme']) const controller = new PluginConfigSectionController(settings.api, () => ledger('bash')) From 5d9f026e553c8ca950862f86f77606c4f38ef082 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 21:35:21 +0800 Subject: [PATCH 046/146] fix(settings): correct the exposure analysis and satisfy the coverage and lint gates The Agent Note claimed the plugin inventory page already exposed every mounted plugin's effective configuration to the same browser, so the removed allowlist blocked nothing a caller could not already read. That is false: PluginInventoryEntry carries entryId, moduleName, enabled, and fiberPhase, and the page's configuration row renders an enabled tag, not a stored value. The allowlist did keep an unlisted namespace's resolved, base, and user values off the wire; the note now says so and rests the decision on the loopback pin, secret redaction, the user's own document, and the fact that permission and agent-presets were already served. The post-await disposal check reads through an opaque method, mirroring the settings seam's isStopped(): control flow narrowed the field to false across the await, so the lint gate saw the guard as dead. --- .../2026-08-12-plugin-owned-settings-surface.i18n.yaml | 4 ++-- .../2026-08-12-plugin-owned-settings-surface.md | 6 ++++-- .../2026-08-12-plugin-owned-settings-surface.zh.md | 6 ++++-- .../client/ui-plugin-config/src/client/section-store.ts | 9 +++++++-- .../client/ui-plugin-config/tests/stores.client.spec.ts | 2 +- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index 6abe3f25e7..9b3c3a967c 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: 3e6b75e8516312dc72313541b05e3dfb9f57140f -2026-08-12-plugin-owned-settings-surface.zh.md: ad06a25c5cb9023f15ca39d6302049c30fa36ce3 +2026-08-12-plugin-owned-settings-surface.md: dd044659bb336a5a8add19650b968c069ce24ecc +2026-08-12-plugin-owned-settings-surface.zh.md: ebe7dc87cb595628b5a072ec923768a07797eff0 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index 3e6b75e851..dd044659bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -30,9 +30,11 @@ Keying makes absence the signal, and that is what removes the bookkeeping the pr ## What the allowlist protected -The removed gate was not the boundary it read as. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`. The read the gate blocked was already available to the same browser through the plugin inventory page, which lists every mounted plugin with its effective configuration. The writes it blocked were the least consequential ones on the plane: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served. +The gate did keep one thing off the wire, and this note states it plainly because the decision has to survive the accurate version: a registered namespace the list did not name never had its resolved, `base`, or `user` values reach the browser at all. The plugin inventory page is not a substitute — `PluginInventoryEntry` carries `entryId`, `moduleName`, `enabled`, and `fiberPhase`, and its "configuration" row renders an enabled/disabled tag, never a stored value. -The one namespace whose exposure actually changes is `agent-default-model`. It has no browser half, so nothing renders it. +What the gate was not is the boundary its position suggested. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served. + +So the exposure this change actually adds, in this repository, is one namespace: `agent-default-model`, whose two fields name a provider and a model and which no browser half renders. A future namespace whose values genuinely must not cross the wire is answered per field by `role('secret')` — finer than a namespace switch, and already enforced. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index ad06a25c5c..ebe7dc87cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -30,9 +30,11 @@ Status: implemented ## 白名单实际护住了什么 -被删掉的这道门并不是它读起来的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`。这道门挡住的读取,同一个浏览器早已能从插件清单页拿到——那一页列出每个已挂载插件及其 effective configuration。它挡住的写入,则是整个面上最无关紧要的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。 +这道门确实挡住了一样东西,本 note 如实写出,因为这个决策必须在准确版本下也站得住:不在名单上的已注册命名空间,其 resolved、`base` 与 `user` 值根本不会抵达浏览器。插件清单页不能替代它——`PluginInventoryEntry` 携带的是 `entryId`、`moduleName`、`enabled` 与 `fiberPhase`,它那一行「configuration」渲染的是启用/停用标签,从不是任何已存值。 -暴露状况真正发生变化的只有 `agent-default-model` 一个命名空间。它没有浏览器半侧,因此没有任何界面渲染它。 +这道门不是的,是它所处位置暗示的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。 + +因此本次改动在本仓库实际新增的暴露面是一个命名空间:`agent-default-model`——它的两个字段指明一个提供方与一个模型,且没有任何浏览器半侧渲染它。将来若某个命名空间的值确实不该跨越协议,由 `role('secret')` 逐字段作答:比整命名空间开关更精细,而且已经在执行。 ## Alternatives considered diff --git a/packages/client/ui-plugin-config/src/client/section-store.ts b/packages/client/ui-plugin-config/src/client/section-store.ts index b12c014a0d..367243822e 100644 --- a/packages/client/ui-plugin-config/src/client/section-store.ts +++ b/packages/client/ui-plugin-config/src/client/section-store.ts @@ -60,12 +60,17 @@ export class PluginConfigSectionController { private readonly entries: () => readonly StoredEntry[], ) {} + /** Opaque read of {@link disposed}: control flow cannot narrow it across awaits. */ + private isDisposed(): boolean { + return this.disposed + } + /** * Re-read the served namespaces from the Host and republish. * @returns settlement after the read, or immediately once disposed. */ async load(): Promise { - if (this.disposed) return + if (this.isDisposed()) return const generation = ++this.generation let response: Awaited> try { @@ -75,7 +80,7 @@ export class PluginConfigSectionController { // or reconnect reads again. return } - if (this.disposed || generation !== this.generation || !response.result.ok) return + if (this.isDisposed() || generation !== this.generation || !response.result.ok) return this.served = response.result.value.namespaces.map(view => view.ns) this.loaded = true this.publish() diff --git a/packages/client/ui-plugin-config/tests/stores.client.spec.ts b/packages/client/ui-plugin-config/tests/stores.client.spec.ts index 30a537a38f..54257f74fc 100644 --- a/packages/client/ui-plugin-config/tests/stores.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/stores.client.spec.ts @@ -603,7 +603,7 @@ describe('PluginConfigSectionController', () => { const settings = settingsApi(['bash']) const controller = new PluginConfigSectionController(settings.api, () => ledger('bash')) await controller.load() - settings.describe.mockRejectedValueOnce(new Error('offline') as never) + settings.describe.mockRejectedValueOnce(new Error('offline')) await controller.load() From fdb1c47896071d216b6a67265a1e2b4ed4035668 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 21:41:23 +0800 Subject: [PATCH 047/146] docs(settings): scope the ordering claim and record the deferred wire gaps Card registration order is stable only for cards one package installs together: apply order between packages is unconstrained, so several external cards can still reorder between boots. The note and README said otherwise. Recorded alongside it: the redactor returns a secret reachable only through a union, intersection, or transform verbatim, and serving every registered namespace widens that gap to third-party schemas; and the headline capability still lacks an assembled-composition test. publish() now keeps its snapshot reference when neither the loaded flag nor the dispatched namespaces moved, so an unrelated settings commit no longer re-renders the card list. --- .../2026-08-12-plugin-owned-settings-surface.i18n.yaml | 4 ++-- .../2026-08-12-plugin-owned-settings-surface.md | 4 +++- .../2026-08-12-plugin-owned-settings-surface.zh.md | 4 +++- packages/client/ui-plugin-config/README.i18n.yaml | 4 ++-- packages/client/ui-plugin-config/README.md | 2 +- packages/client/ui-plugin-config/README.zh.md | 2 +- .../client/ui-plugin-config/src/client/section-store.ts | 8 ++++++++ 7 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml index 9b3c3a967c..7dead37b13 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md -2026-08-12-plugin-owned-settings-surface.md: dd044659bb336a5a8add19650b968c069ce24ecc -2026-08-12-plugin-owned-settings-surface.zh.md: ebe7dc87cb595628b5a072ec923768a07797eff0 +2026-08-12-plugin-owned-settings-surface.md: 2cc78986906e83132f4401fc80c0cd601b71827c +2026-08-12-plugin-owned-settings-surface.zh.md: 8f076e2690f5eaf0129ff209883b57fe44ffedb6 diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md index dd044659bb..2cc7898690 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md @@ -52,7 +52,9 @@ So the exposure this change actually adds, in this repository, is one namespace: ## Consequences -A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`; the Host's description order is deliberately not the display order, because plugin activation can reorder it between boots and a settings page whose cards move between visits is worse than one whose order a registrant chose. +A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`. That is stable for the cards this package registers, which install from one generator, and **not** stable across plugins: apply order between packages is unconstrained (`packages/client/AGENTS.md`), so several external cards can still reorder between boots. Ordering them needs an explicit key the section can sort on, which the keyed registration does not carry today. + +Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path. The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect. diff --git a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md index ebe7dc87cb..8f076e2690 100644 --- a/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.zh.md @@ -52,7 +52,9 @@ Status: implemented ## Consequences -在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`;Host 的描述顺序被刻意排除在展示顺序之外,因为插件激活时序会让它在不同次启动之间变化,而一个卡片会在两次访问之间移位的设置页,比一个顺序由注册方选定的设置页更糟。 +在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`。对本包注册的这几张卡它是稳定的——它们从同一个 generator 安装;对**跨插件**的卡片它并不稳定:包与包之间的 apply 顺序是无约束的(`packages/client/AGENTS.md`),因此多个外部卡片仍可能在不同次启动之间重排。要为它们定序,需要一个 section 可排序的显式键,而 keyed 注册今天并不携带。 + +以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema,因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有:对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件(Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。 分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。 diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index 68ae116abf..9b9205c825 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-plugin-config/README.md -README.md: 569f3a404f2b94fd6fb8dc5a4191cf66f37d55e2 -README.zh.md: 5eb96dc07ba7a438824ce6c6c3da707dd3d26285 +README.md: c4cda446b03d98eca01264a6a476999572f9e21f +README.zh.md: 9ddef246f1f80b310ac6f1b292cbd85f5306da49 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 569f3a404f..c4cda446b0 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -6,7 +6,7 @@ The **Plugins** settings section: one expandable card per Host plugin whose conf ## What appears here -The section reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the section back from its empty line. Cards appear in the order they registered, not the order the Host describes their namespaces — plugin activation can reorder the description between boots. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". +The section reads which settings namespaces the Host serves and dispatches one slot key per namespace, so what renders is the intersection of two ledgers: the namespaces a live Host plugin registered, and the cards registered under those keys. A served namespace no card claims renders nothing — another surface owns it, or this deployment ships no browser half for it — and a card whose namespace this deployment does not serve is never dispatched, so an uncomposed plugin leaves no trace and does not hold the section back from its empty line. Cards appear in the order they registered, which is stable for the cards one package installs together and not stable across plugins: apply order between packages is unconstrained. The empty line waits for the Host's first answer, so an unanswered read never reads as "this deployment configures no plugin". The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index 5eb96dc07b..9ddef246f1 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -6,7 +6,7 @@ ## 这里会出现什么 -本分区读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装的插件不留任何痕迹,也不会挡住那行空态文案。卡片按自身注册的顺序出现,而非 Host 描述其命名空间的顺序——插件激活时序会让后者在不同次启动之间变化。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成"本部署没有可配置的插件"。 +本分区读取 Host 服务了哪些 settings 命名空间,并为每个命名空间派发一个 slot 键,因此渲染出来的是两份账本的交集:存活 Host 插件注册的命名空间,以及注册在这些键上的卡片。被服务却无人认领的命名空间什么都不渲染——它归别的界面所有,或本部署没有为它提供浏览器半侧;而命名空间未被本部署服务的卡片根本不会被派发,因此未组装的插件不留任何痕迹,也不会挡住那行空态文案。卡片按自身注册的顺序出现;对同一个包一起安装的卡片这是稳定的,对跨插件的卡片则不稳定:包与包之间的 apply 顺序是无约束的。空态文案要等 Host 的第一次答复,因此一次尚未答复的读取绝不会被读成"本部署没有可配置的插件"。 本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 diff --git a/packages/client/ui-plugin-config/src/client/section-store.ts b/packages/client/ui-plugin-config/src/client/section-store.ts index 367243822e..75e1c51aa4 100644 --- a/packages/client/ui-plugin-config/src/client/section-store.ts +++ b/packages/client/ui-plugin-config/src/client/section-store.ts @@ -110,6 +110,14 @@ export class PluginConfigSectionController { const served = new Set(this.served) const namespaces = this.entries().flatMap(entry => entry.options.key !== undefined && served.has(entry.options.key) ? [entry.options.key] : []) + const previous = this.store.getSnapshot() + // Every settings-document commit re-reads, and most of them change nothing + // this section shows. An observable source must keep its snapshot + // reference until the fact moves, or each unrelated save re-renders the + // whole card list (packages/client/AGENTS.md reactive rule 5). + if (previous.loaded === this.loaded + && previous.namespaces.length === namespaces.length + && previous.namespaces.every((ns, index) => ns === namespaces[index])) return this.store.set({ loaded: this.loaded, namespaces }) } } From 9523dff0fd5997c2e3495915abecf5037ab03b38 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 12 Aug 2026 22:32:11 +0800 Subject: [PATCH 048/146] fix(web): resolve the pwsh-terminal overlay duplicate-loader entry The web E2E seed lane for the pwsh terminal card failed on every platform with `duplicate loader entry id: tool-pwsh`. The overlay inserted a new tool-pwsh row, but the base bundle has declared that id since the shell platform layer moved into it, so the overlay's insert delivered a second row with the same id and the loader rejected the pair at boot. Enable the existing tool-pwsh row by id instead of inserting it, and disable pwsh-sandbox so the inserted pwsh-local is the lone executor on every platform (the base gates pwsh-sandbox on win32, which would otherwise collide with the inserted executor there). --- ...12-fix-pwsh-terminal-overlay-dup.i18n.yaml | 6 ++ ...026-08-12-fix-pwsh-terminal-overlay-dup.md | 55 +++++++++++++++++++ ...-08-12-fix-pwsh-terminal-overlay-dup.zh.md | 55 +++++++++++++++++++ apps/web/tests/pwsh-terminal.overlay.yml | 30 ++++++---- 4 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.i18n.yaml new file mode 100644 index 0000000000..1c650cc79c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.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 .agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.md +2026-08-12-fix-pwsh-terminal-overlay-dup.md: 7a214ea093faf49141c66bfc2cbb065fbeb29825 +2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md: 6d38ca58ad97e5ad04992e54a7938c9fea300863 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.md b/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.md new file mode 100644 index 0000000000..7a214ea093 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.md @@ -0,0 +1,55 @@ +# Agent Note: fix the pwsh terminal overlay duplicate-loader collision + +Status: implemented + +English | [中文](2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md) + +## Problem + +`apps/web/tests/pwsh-terminal.e2e.ts` fails on every platform with `TypeError: duplicate loader entry id: tool-pwsh`, thrown from `vendor/loader/src/config/group.ts:64` while applying the web composition. The failing seed lane boots the full shipped bundle plus a test overlay, so the E2E never reaches its rendering assertion and every `check:ci:snapshot`/`test:web` run reports a red web test even though the feature under test is unrelated to the change under review. + +The web E2E scaffold applies an `extraOverlayPath` after the shipped Web surface and base patches. `pwsh-terminal.overlay.yml` used an `insert` block to add a `tool-pwsh` row: + +```yaml +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' +``` + +`insert` is correct only while `tool-pwsh` is absent from the composition. The id exists because `86b6979bdc` (refactor(bundle): fold the Windows shell platform layer into the base rows) moved both shell stacks into the base bundle with inverted platform gates — `packages/bundle/base/cordis.patch.yml` declares `tool-pwsh` with `disabled: !!js process.platform !== 'win32'`, so the row is present in the composition on every platform. Later, `42fc7c5ffb` (refactor(preset): gate tool-pwsh by platform alongside tool-bash) added a web-app patch row that disables `tool-pwsh` for surfaces that use presets; a patch row cannot introduce an id, so it is not the source of the collision. The overlay's `insert` delivers a second row with the same id in the same loader group, and the loader rejects the pair at boot. + +## Decision + +Replace the overlay's `insert` of `tool-pwsh` with a top-level id-targeted override: + +```yaml +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: false +``` + +The effective `tool-pwsh` state is a three-layer stack: the base row gates `disabled` on `process.platform !== 'win32'`, the web-app overlay sets `disabled: true` unconditionally for preset surfaces, and this lane's override clears it back to `disabled: false` regardless of platform. An `id`-targeted top-level override replaces the composed row; only an `insert` would collide. + +The lane also now disables `pwsh-sandbox` by id, symmetric with the existing `bash-sandbox` disable: the base gates `pwsh-sandbox` with `disabled: !!js process.platform !== 'win32'`, so on Windows it would otherwise mount beside the inserted `pwsh-local` and both would register the same executor service. Disabling it keeps `pwsh-local` the lone executor on every platform. + +The overlay header comment was updated to describe the full selection and the `tool-pwsh` inline comment now names the base row as the source of the id. + +## Alternatives considered + +**Keep the `insert` and change the web composition instead.** Rejected, because the shipped web composition should keep the host `tool-pwsh` row disabled for every surface that uses presets; the overlay is the lane that deliberately needs it, so the by-id enable belongs there. The base row itself cannot be removed either: it is the platform-gated shell-stack declaration shared by every bundle. + +**Enable `tool-pwsh` in the `insert` block.** Not possible: an `insert` of an id that already exists is the very duplicate being fixed. The row must be targeted by id, which is the top-level override form, not `insert`. + +**Patch `tool-pwsh` by id without setting `disabled: false`.** Insufficient: the web-app overlay sets `disabled: true` unconditionally, and the base row's platform gate only applies where the web-app override is absent, so an override that only restates `name` leaves the row disabled and the lane renders no terminal card. The `disabled: false` is required. + +**Only disable `bash-sandbox` and rely on the platform gate to keep `pwsh-sandbox` off.** Rejected: that holds on POSIX but breaks on Windows, where the base row leaves `pwsh-sandbox` enabled and it would collide with the inserted `pwsh-local` on the shared executor service. The lane's `pwsh-sandbox` disable keeps one executor on every platform. + +## Verification + +Reverting the fix (restoring the `insert` of `tool-pwsh`) reproduces the exact `duplicate loader entry id: tool-pwsh` boot failure, confirming the override is load-bearing. With the fix in place `pwsh-terminal.e2e.ts` passes 2/2 on the same head — this exercises the POSIX seam, where the seeded pwsh call renders through the enabled `tool-pwsh` and the inserted `pwsh-local`. The seed lane requires a usable `pwsh`, so it skips on hosts without one; a `pwsh` binary is present on this machine and the test ran. The Windows path (base `pwsh-sandbox` mounted beside the inserted `pwsh-local`) is not exercised by any CI lane, whose `test:web` runs only on Linux; the overlay disables `pwsh-sandbox` to keep that path composable if it ever runs on a Windows dev machine. + +## Consequences + +The web E2E seed lane that exercises PowerShell boot now composes instead of colliding, so `check:ci:snapshot` and `test:web` stop failing on the duplicate independently of the change under test. The pattern is general: a `--patch`/`extraOverlayPath` overlay must probe whether a row already exists in the bundle it augments before choosing `insert` over an id-targeted override; `insert` of an id that the base or shipped Web surface already declares is a boot-time duplicate. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md new file mode 100644 index 0000000000..6d38ca58ad --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md @@ -0,0 +1,55 @@ +# Agent Note:修复 pwsh 终端 overlay 的重复 loader 冲突 + +Status: implemented + +[English](2026-08-12-fix-pwsh-terminal-overlay-dup.md) | 中文 + +## Problem + +`apps/web/tests/pwsh-terminal.e2e.ts` 在所有平台上都以 `TypeError: duplicate loader entry id: tool-pwsh` 失败,由 `vendor/loader/src/config/group.ts:64` 在应用 web 组合时抛出。该失败的 seed 通道会启动完整发布的 bundle 加一个测试 overlay,因此 E2E 永远到不了渲染断言,导致 `check:ci:snapshot` 与 `test:web` 每次运行都报一个红的 web 测试,即便被测功能与评审中的改动无关。 + +web E2E scaffold 在已发布的 Web 表面与 base patches 之后应用 `extraOverlayPath`。`pwsh-terminal.overlay.yml` 用 `insert` 块新增 `tool-pwsh` 行: + +```yaml +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' +``` + +`insert` 仅在组合中不存在 `tool-pwsh` 时才正确。该 id 存在是因为 `86b6979bdc`(refactor(bundle): fold the Windows shell platform layer into the base rows)把两套 shell 栈以互逆的平台门移进了 base bundle —— `packages/bundle/base/cordis.patch.yml` 声明 `tool-pwsh` 且 `disabled: !!js process.platform !== 'win32'`,于是该行在每个平台都存在于组合中。随后 `42fc7c5ffb`(refactor(preset): gate tool-pwsh by platform alongside tool-bash)往 web-app patch 里加了一行对使用 preset 的表面禁用 `tool-pwsh` 的行;patch 不能引入 id,因此它不是冲突来源。overlay 的 `insert` 于是在同一个 loader 组里再送一个同 id 的行,loader 在启动时拒绝这对重复。 + +## Decision + +把 overlay 对 `tool-pwsh` 的 `insert` 替换成顶层按 id override: + +```yaml +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: false +``` + +有效的 `tool-pwsh` 状态是三层栈:base 行把 `disabled` 门在 `process.platform !== 'win32'` 上,web-app overlay 对 preset 表面无条件设 `disabled: true`,本通道的 override 无论平台都把 `disabled: false` 还回去。`id` 定位的顶层 override 替换组合后的行;只有 `insert` 才会相撞。 + +该通道现在也按 id 禁用 `pwsh-sandbox`,与既有的 `bash-sandbox` 禁用对称:base 以 `disabled: !!js process.platform !== 'win32'` 门住 `pwsh-sandbox`,因此在 Windows 上它本会与插入的 `pwsh-local` 并存,两者会注册同一个 executor 服务。禁用它让 `pwsh-local` 在每个平台上都是唯一 executor。 + +overlay 头部注释已更新为完整描述选择,`tool-pwsh` 行内注释现在把 base 行标为该 id 的来源。 + +## Alternatives considered + +**保留 `insert`、改 web 组合。** 拒绝。已发布的 web 组合应在所有使用 preset 的表面上保持 host `tool-pwsh` 行禁用;overlay 才是那条刻意需要该行的通道,因此按 id 启用应放在那里。base 行本身也不能移除:它是所有 bundle 共享的平台门 shell 栈声明。 + +**在 `insert` 块里启用 `tool-pwsh`。** 不可行。对已存在的 id 做 `insert` 正是这里要修的重复。该行必须按 id 定位,即顶层 override 形式,而非 `insert`。 + +**只按 id 改 `tool-pwsh` 而不设 `disabled: false`。** 不充分。web-app 无条件设 `disabled: true`,base 行的平台门只在 web-app override 缺失处生效,因此只重申 `name` 的 override 会让行保持禁用,通道渲染不出终端卡。`disabled: false` 是必需的。 + +**只禁用 `bash-sandbox`、依赖平台门让 `pwsh-sandbox` 保持关闭。** 拒绝。在 POSIX 上成立,但在 Windows 上会失败:base 行让 `pwsh-sandbox` 启用,它会与插入的 `pwsh-local` 在共享 executor 服务上相撞。本通道禁用 `pwsh-sandbox` 让每个平台只有唯一 executor。 + +## Verification + +把修复还原(恢复对 `tool-pwsh` 的 `insert`)即复现同样的 `duplicate loader entry id: tool-pwsh` 启动失败,证实 override 是有效的。修复后同一 head 上 `pwsh-terminal.e2e.ts` 2/2 通过 —— 这作用于 POSIX seam,播种的 pwsh 调用经启用的 `tool-pwsh` 与插入的 `pwsh-local` 渲染出来。该 seed 通道需要可用的 `pwsh`,无此二进制的主机会跳过;本机有 `pwsh`,测试实际跑过。Windows 路径(base `pwsh-sandbox` 与插入的 `pwsh-local` 并存)没有任何 CI lane 覆盖,其 `test:web` 只在 Linux 上跑;overlay 禁用 `pwsh-sandbox` 让该路径在真到 Windows 开发机运行时可组合。 + +## Consequences + +用于执行 PowerShell 启动的 web E2E seed 通道现在能组合而非相撞,因此 `check:ci:snapshot` 与 `test:web` 不再与被测改动无关地在该 duplicate 上失败。该模式具有通用性:`--patch`/`extraOverlayPath` overlay 在决定用 `insert` 还是按 id override 之前,必须探测目标 bundle 是否已存在该行;对已由 base 或已发布 Web 表面声明的 id 做 `insert`,是启动期重复。 diff --git a/apps/web/tests/pwsh-terminal.overlay.yml b/apps/web/tests/pwsh-terminal.overlay.yml index 59830e3274..ad194dd703 100644 --- a/apps/web/tests/pwsh-terminal.overlay.yml +++ b/apps/web/tests/pwsh-terminal.overlay.yml @@ -1,20 +1,30 @@ # The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell -# twin: the bash executor row is disabled (patches cannot rename a row — `name` -# is a guard) and the pwsh executor + tool are inserted. The permission service -# refuses an unconfined executor by design (presets bundle a sandbox mode), so -# its row is disabled too — this lane renders a seeded session, never a -# permission decision. The seeded scenario renders the logged pwsh call/result -# through the real tools on replay; no command executes, but the composition -# must boot the pwsh executor, so the lane skips on hosts without a usable -# `pwsh`. +# twin: the bash executor row is disabled, the pwsh executor is inserted, and +# the host tool-pwsh row is enabled by id. The pwsh-sandbox row is disabled +# too so the inserted pwsh-local is the lone executor on every platform. The +# permission service refuses an unconfined executor by design (presets bundle +# a sandbox mode), so its row is disabled as well — this lane renders a seeded +# session, never a permission decision. The seeded scenario renders the logged +# pwsh call/result through the real tools on replay; no command executes, but +# the composition must boot the pwsh executor, so the lane skips on hosts +# without a usable `pwsh`. - id: bash-sandbox name: '@deepseek-ai/dsh-bash-sandbox' disabled: true +- id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' + disabled: true - id: permission name: '@deepseek-ai/dsh-permission' disabled: true - insert: - id: pwsh-local name: '@deepseek-ai/dsh-pwsh-local' - - id: tool-pwsh - name: '@deepseek-ai/dsh-tool-pwsh' + +# tool-pwsh already exists in the shipped composition: the base bundle declares +# it platform-gated on every platform, and the web-app overlay disables it for +# surfaces that use presets. So this lane enables it by id rather than +# inserting a second row to the same id. +- id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: false From 82c168ff7f0a6020ec3c86e24027a6fba242df60 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 13 Aug 2026 15:54:35 +0800 Subject: [PATCH 049/146] ci: raise Windows native timeout to 120 minutes --- .../2026-08-08-native-windows-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-08-08-native-windows-pull-request-ci.md | 2 +- .../process/2026-08-08-native-windows-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index dcdbff1208..311f39f0ff 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 33fbf1ae378112b4fd82633a77afa52056d93d98 -2026-08-08-native-windows-pull-request-ci.zh.md: 552e5cd3129011198fe442ba747cf2fdb7d97365 +2026-08-08-native-windows-pull-request-ci.md: d7db73b2049ae2d08f8996bcfd5b54fa15901478 +2026-08-08-native-windows-pull-request-ci.zh.md: 474b7f71aca8fbb5e0082fd2e4faf8e462bf0c0f diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 33fbf1ae37..d7db73b204 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 60-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. +Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline. The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 552e5cd312..474b7f71ac 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,7 +14,7 @@ Status: implemented [ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,60 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。 原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7775827c66..5e0c006355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,7 +452,7 @@ jobs: && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} name: windows node 24 / native complete - timeout-minutes: 60 + timeout-minutes: 120 env: DSH_COVERAGE_MAX_WORKERS: '2' DSH_GATE_CONCURRENCY: '2' @@ -659,7 +659,7 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' name: serial / windows (self-hosted standby) runs-on: [self-hosted, dsh-win-ci, windows] - timeout-minutes: 60 + timeout-minutes: 120 steps: - uses: actions/checkout@v6 From 078dd2b6dffd67b41de0b93e48a680048e3b5892 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 17:57:58 +0800 Subject: [PATCH 050/146] chore(subprocess-local): bump node-pty beta --- .../subprocess/subprocess-local/package.json | 2 +- patches/node-pty@1.1.0.patch | 62 ------------------- patches/node-pty@1.2.0-beta.15.patch | 32 ++++++++++ pnpm-lock.yaml | 12 ++-- pnpm-workspace.yaml | 2 +- 5 files changed, 40 insertions(+), 70 deletions(-) delete mode 100644 patches/node-pty@1.1.0.patch create mode 100644 patches/node-pty@1.2.0-beta.15.patch diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 74b480ae82..9c46d81225 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -42,7 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "node-pty": "^1.1.0" + "node-pty": "1.2.0-beta.15" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/patches/node-pty@1.1.0.patch b/patches/node-pty@1.1.0.patch deleted file mode 100644 index 56892a3d58..0000000000 --- a/patches/node-pty@1.1.0.patch +++ /dev/null @@ -1,62 +0,0 @@ -diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js -index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 ---- a/lib/unixTerminal.js -+++ b/lib/unixTerminal.js -@@ -26,10 +26,23 @@ var terminal_1 = require("./terminal"); - var utils_1 = require("./utils"); - var native = utils_1.loadNativeModule('pty'); - var pty = native.module; --var helperPath = native.dir + '/spawn-helper'; --helperPath = path.resolve(__dirname, helperPath); --helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); --helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); -+// A current external embedded-runtime consumer supplies a non-sibling helper. -+var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; -+if (helperPath) { -+ helperPath = path.resolve(helperPath); -+} -+else { -+ var executableSibling = process.execPath + '-spawn-helper'; -+ if (fs.existsSync(executableSibling)) { -+ helperPath = executableSibling; -+ } -+ else { -+ helperPath = native.dir + '/spawn-helper'; -+ helperPath = path.resolve(__dirname, helperPath); -+ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -+ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); -+ } -+} - var DEFAULT_FILE = 'sh'; - var DEFAULT_NAME = 'xterm'; - var DESTROY_SOCKET_TIMEOUT_MS = 200; -diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts -index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 ---- a/src/unixTerminal.ts -+++ b/src/unixTerminal.ts -@@ -14,10 +14,21 @@ import { assign, loadNativeModule } from './utils'; - - const native = loadNativeModule('pty'); - const pty: IUnixNative = native.module; --let helperPath = native.dir + '/spawn-helper'; --helperPath = path.resolve(__dirname, helperPath); --helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); --helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); -+// A current external embedded-runtime consumer supplies a non-sibling helper. -+let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; -+if (helperPath) { -+ helperPath = path.resolve(helperPath); -+} else { -+ const executableSibling = process.execPath + '-spawn-helper'; -+ if (fs.existsSync(executableSibling)) { -+ helperPath = executableSibling; -+ } else { -+ helperPath = native.dir + '/spawn-helper'; -+ helperPath = path.resolve(__dirname, helperPath); -+ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -+ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); -+ } -+} - - const DEFAULT_FILE = 'sh'; - const DEFAULT_NAME = 'xterm'; diff --git a/patches/node-pty@1.2.0-beta.15.patch b/patches/node-pty@1.2.0-beta.15.patch new file mode 100644 index 0000000000..74eecb16cd --- /dev/null +++ b/patches/node-pty@1.2.0-beta.15.patch @@ -0,0 +1,32 @@ +diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js +index 6966d24..18f1d25 100644 +--- a/lib/unixTerminal.js ++++ b/lib/unixTerminal.js +@@ -28,10 +28,23 @@ var terminal_1 = require("./terminal"); + var utils_1 = require("./utils"); + var native = (0, utils_1.loadNativeModule)('pty'); + var pty = native.module; +-var helperPath = native.dir + '/spawn-helper'; +-helperPath = path.resolve(__dirname, helperPath); +-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); +-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++// A current external embedded-runtime consumer supplies a non-sibling helper. ++var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; ++if (helperPath) { ++ helperPath = path.resolve(helperPath); ++} ++else { ++ var executableSibling = process.execPath + '-spawn-helper'; ++ if (fs.existsSync(executableSibling)) { ++ helperPath = executableSibling; ++ } ++ else { ++ helperPath = native.dir + '/spawn-helper'; ++ helperPath = path.resolve(__dirname, helperPath); ++ helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); ++ helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); ++ } ++} + var DEFAULT_FILE = 'sh'; + var DEFAULT_NAME = 'xterm'; + var DESTROY_SOCKET_TIMEOUT_MS = 200; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1490a0f9f7..0c3f2c4338 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: '@deepseek-ai/schemastery': link:vendor/schemastery patchedDependencies: - node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 + node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 importers: @@ -7493,8 +7493,8 @@ importers: packages/subprocess/subprocess-local: dependencies: node-pty: - specifier: ^1.1.0 - version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) + specifier: 1.2.0-beta.15 + version: 1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -13164,8 +13164,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-pty@1.1.0: - resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + node-pty@1.2.0-beta.15: + resolution: {integrity: sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==} node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} @@ -18588,7 +18588,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-pty@1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6): + node-pty@1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0): dependencies: node-addon-api: 7.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e8d8ee5bec..ec6cfd3af9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -69,4 +69,4 @@ minimumReleaseAgeExclude: - node-addon-require-builtin@0.1.4 patchedDependencies: - node-pty@1.1.0: patches/node-pty@1.1.0.patch + node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch From 348a49b62c25aef662cbb0d547b0ba27b6802ff8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 18:02:39 +0800 Subject: [PATCH 051/146] docs: update node-pty patch notice --- THIRD_PARTY_NOTICES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 92b218ff33..d672d46ac6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -93,7 +93,7 @@ External packages that a workspace package resolves at runtime. The tier covers pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: -- `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch) +- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) ## Official Claude Code platform payloads From 1106b0b03df995720ddf7aea75681ba2b0bd654e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 18:06:36 +0800 Subject: [PATCH 052/146] ci: rebuild node-pty for manylinux --- .github/workflows/build-exe-for-python-sdk.yml | 1 + scripts/ci-workflow.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index a8aec21262..63779282b4 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -194,6 +194,7 @@ jobs: *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)" + (cd "$addon_dir" && npm_config_build_from_source=true npm run install) addon="$addon_dir/build/Release/pty.node" [ -f "$addon_dir/build/Makefile" ] || { echo "::error::node-pty install did not generate $addon_dir/build/Makefile" diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 84ac580f31..63904dc265 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -342,6 +342,7 @@ describe('Python release workflows', () => { expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') + expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true npm run install') expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro') expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') From a785eb80f7a82b4b5e5f585204441db01981c029 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 18:12:36 +0800 Subject: [PATCH 053/146] fix(python-runtime): fall back to node-pty prebuild --- ...uild-exe-for-python-sdk-native-pty.spec.ts | 49 +++++++++++++++++++ .../build-exe-for-python-sdk-native-pty.ts | 23 +++++++++ scripts/build-exe-for-python-sdk.ts | 15 ++++-- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 scripts/build-exe-for-python-sdk-native-pty.spec.ts create mode 100644 scripts/build-exe-for-python-sdk-native-pty.ts diff --git a/scripts/build-exe-for-python-sdk-native-pty.spec.ts b/scripts/build-exe-for-python-sdk-native-pty.spec.ts new file mode 100644 index 0000000000..5dd6588955 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-native-pty.spec.ts @@ -0,0 +1,49 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('resolveLinuxNodePtyAddon', () => { + it('prefers the manylinux build produced by the release workflow', () => { + const root = temporaryPackage() + const built = createAddon(root, 'build', 'Release', 'pty.node') + createAddon(root, 'prebuilds', 'linux-x64', 'pty.node') + + expect(resolveLinuxNodePtyAddon(root, 'x64')).toBe(built) + }) + + it('uses the target prebuild after an ordinary beta install', () => { + const root = temporaryPackage() + const prebuilt = createAddon(root, 'prebuilds', 'linux-arm64', 'pty.node') + + expect(resolveLinuxNodePtyAddon(root, 'arm64')).toBe(prebuilt) + }) + + it('reports both expected locations when no addon is installed', () => { + const root = temporaryPackage() + + expect(() => resolveLinuxNodePtyAddon(root, 'x64')).toThrow( + `node-pty addon is absent from both ${join(root, 'build', 'Release', 'pty.node')} and ${join(root, 'prebuilds', 'linux-x64', 'pty.node')}`, + ) + }) +}) + +function temporaryPackage(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-node-pty-addon-')) + roots.push(root) + return root +} + +function createAddon(root: string, ...segments: string[]): string { + const path = join(root, ...segments) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, '') + return path +} diff --git a/scripts/build-exe-for-python-sdk-native-pty.ts b/scripts/build-exe-for-python-sdk-native-pty.ts new file mode 100644 index 0000000000..02fa864d73 --- /dev/null +++ b/scripts/build-exe-for-python-sdk-native-pty.ts @@ -0,0 +1,23 @@ +/** Resolve the native node-pty input used by the Python SDK runtime builder. */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Prefer the workflow's manylinux build and fall back to node-pty's target prebuild. + * @param packageDirectory - installed node-pty package directory. + * @param arch - Linux target architecture. + * @returns the existing addon path. + */ +export function resolveLinuxNodePtyAddon( + packageDirectory: string, + arch: 'x64' | 'arm64', +): string { + const built = join(packageDirectory, 'build', 'Release', 'pty.node') + if (existsSync(built)) return built + const prebuilt = join(packageDirectory, 'prebuilds', `linux-${arch}`, 'pty.node') + if (existsSync(prebuilt)) return prebuilt + throw new Error( + `build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`, + ) +} diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index da1cea67c4..801a004fd6 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -11,6 +11,7 @@ import { existsSync, statSync } from 'node:fs' import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' +import { resolveLinuxNodePtyAddon } from './build-exe-for-python-sdk-native-pty.ts' const root = resolve(import.meta.dirname, '..') @@ -409,8 +410,8 @@ class SingleExeBuild { } /** - * Put the target node-pty addon in the staged closure. Linux npm installs - * build it from source, but legacy deploy omits that side-effect directory. + * Put the target node-pty addon in the staged closure. The release workflow + * provides a manylinux build; ordinary installs use node-pty's target prebuild. * @param target - the pkg target whose native addon is being staged. */ private async prepareNativePty(target: Target): Promise { @@ -418,8 +419,16 @@ class SingleExeBuild { if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${stagedBuild}`) else await rm(stagedBuild, { recursive: true, force: true }) if (target.platform !== 'linux') return - const source = join(root, 'packages', 'subprocess', 'subprocess-local', 'node_modules', 'node-pty', 'build', 'Release', 'pty.node') + const packageDirectory = join( + root, + 'packages', + 'subprocess', + 'subprocess-local', + 'node_modules', + 'node-pty', + ) const destination = join(stagedBuild, 'Release', 'pty.node') + const source = resolveLinuxNodePtyAddon(packageDirectory, target.arch) if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) return From b11b5359f9b5c345b8e71cfcb1c2ad05da483714 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 18:13:03 +0800 Subject: [PATCH 054/146] ci: use pnpm node-gyp for manylinux rebuild --- .github/workflows/build-exe-for-python-sdk.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 63779282b4..8c6569fa07 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -194,7 +194,7 @@ jobs: *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)" - (cd "$addon_dir" && npm_config_build_from_source=true npm run install) + (cd "$addon_dir" && npm_config_build_from_source=true pnpm run install) addon="$addon_dir/build/Release/pty.node" [ -f "$addon_dir/build/Makefile" ] || { echo "::error::node-pty install did not generate $addon_dir/build/Makefile" diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 63904dc265..df3e983828 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -342,7 +342,7 @@ describe('Python release workflows', () => { expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') - expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true npm run install') + expect(JSON.stringify(manylinuxAddon)).toContain('npm_config_build_from_source=true pnpm run install') expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro') expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') From 3a46bd67985136d0ba90dd9225f5ce65140cf71c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 13 Aug 2026 20:56:05 +0800 Subject: [PATCH 055/146] test(web): assert the whole turn/end reason in the Cordis lifecycle The assertion compared a rebuilt object that carried only reason.kind, so a failing turn reported "expected { kind: 'error' }" with no payload. TurnEndReasonMap is merge-extensible and several variants carry the only record of why the turn ended: error holds LlmFailure (message, code) and aborted holds its TurnEndCancelCause. Compare the reason itself. completed declares kind as its only field, so the passing path is unchanged, and any failure prints the full variant payload without a per-variant branch. --- apps/web/tests/cordis-tool-round.e2e.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index a702a7f1ea..7d1d6ab3b5 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -47,8 +47,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void { (event): event is Extract => event.type === 'turn/end', ) const reason = turnEnd?.data.reason - const reasonSummary = { kind: reason?.kind } - expect(reasonSummary).toEqual({ kind: 'completed' }) + expect(reason).toEqual({ kind: 'completed' }) const calls = events.filter( (event): event is Extract => event.type === 'tool/call', From 692ca590d30456d8beefc215e5d1886414c64a42 Mon Sep 17 00:00:00 2001 From: j-xiang Date: Fri, 14 Aug 2026 12:47:37 +0800 Subject: [PATCH 056/146] docs(i18n): polish the Web UI guide --- docs/user/guide/index.i18n.yaml | 4 ++-- docs/user/guide/index.md | 2 +- docs/user/guide/index.zh.md | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index f6727b6bc4..c4fba5f0b3 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/user/guide/index.md -index.md: 282a5c11b317a8fb8706bb03f41cf03fb2aca49d -index.zh.md: 4ec53b16fc8987b5eb40ef4854cb41d1436a9015 +index.md: 2ae8847e3e04e131517e505bbc3c5f2c1047a45c +index.zh.md: d73344c529e7c7dd11afd1a0b02a9e0c8a2586c3 diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index 282a5c11b3..2ae8847e3e 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -6,7 +6,7 @@ Start the Web UI through the [root README](../../../README.md#run); the command ## Configure a model -Open **Settings → Models**, enter a DeepSeek API key, and save it. The model route becomes usable immediately without restarting the server. +Open **Settings → Models**, enter a [DeepSeek API key](https://platform.deepseek.com/), and save it. The model route becomes usable immediately without restarting the server. The [model configuration guide](./providers.md) covers other providers and custom OpenAI-compatible endpoints. diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 4ec53b16fc..d73344c529 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -2,17 +2,17 @@ [English](index.md) | 中文 -先按照[根 README](../../../README.md#run)启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把调用目录作为默认文件系统位置,但新的 Web UI 在添加工作区前不会选中任何工作区。 +请先按照 [根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置,但新的 Web UI 在添加工作区前不会选中任何工作区。 ## 配置模型 -打开**设置 → 模型**,输入 DeepSeek API 密钥并保存。模型路由会立即可用,不需要重启服务器。 +打开**设置 → 模型**,输入 [DeepSeek API 密钥](https://platform.deepseek.com/)并保存。模型路由会立即可用,不需要重启服务器。 [模型配置指南](./providers.md)介绍其他提供方和自定义 OpenAI 兼容端点。 ## 选择工作区 -点击**选择工作区**,添加启动 `dsh` 时所在的项目目录,然后选中它。选中工作区前,会话输入框不可用。 +点击**选择工作区**,添加启动 `dsh` 时所在的项目目录,然后选中它。选中工作区前,会话输入区不可用。 ## 运行任务 @@ -20,7 +20,7 @@ > Summarize this repository and identify its main packages. -agent 可以读取和编辑工作区文件、运行命令、委派工作并维护计划。当操作在当前权限策略下需要审批时,Web UI 会先询问你。 +Agent(智能体)可以读取和编辑工作区文件、运行命令、委派工作并维护计划。如果根据当前权限策略,某项操作需要审批,Web UI 会先询问你。 ## 继续使用 From e437afecce78404e931040943b6f1f84d480e12b Mon Sep 17 00:00:00 2001 From: j-xiang Date: Fri, 14 Aug 2026 13:26:02 +0800 Subject: [PATCH 057/146] docs(i18n): clarify initial workspace state --- docs/user/guide/index.i18n.yaml | 2 +- docs/user/guide/index.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index c4fba5f0b3..2ee8bc7b7b 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/index.md index.md: 2ae8847e3e04e131517e505bbc3c5f2c1047a45c -index.zh.md: d73344c529e7c7dd11afd1a0b02a9e0c8a2586c3 +index.zh.md: 259189fab8f252b77411e5900d39cc1b03a4be1b diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index d73344c529..259189fab8 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -2,7 +2,7 @@ [English](index.md) | 中文 -请先按照 [根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置,但新的 Web UI 在添加工作区前不会选中任何工作区。 +请先按照 [根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置,但全新的 Web UI 不会选中任何工作区,你需要添加一个工作区。 ## 配置模型 From e87c8d094ba5d0ae2ea75103e044365077977b48 Mon Sep 17 00:00:00 2001 From: j-xiang Date: Fri, 14 Aug 2026 13:37:53 +0800 Subject: [PATCH 058/146] docs(i18n): align Web UI guide wording --- docs/user/guide/index.i18n.yaml | 2 +- docs/user/guide/index.zh.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 2ee8bc7b7b..7627ad57fc 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/index.md index.md: 2ae8847e3e04e131517e505bbc3c5f2c1047a45c -index.zh.md: 259189fab8f252b77411e5900d39cc1b03a4be1b +index.zh.md: dbb33f4860e6170be0e134c5968df91c6b0ac5da diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 259189fab8..dbb33f4860 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -2,7 +2,7 @@ [English](index.md) | 中文 -请先按照 [根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置,但全新的 Web UI 不会选中任何工作区,你需要添加一个工作区。 +请先按照[根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置,但全新的 Web UI 不会选中任何工作区,你需要添加一个工作区。 ## 配置模型 @@ -12,7 +12,7 @@ ## 选择工作区 -点击**选择工作区**,添加启动 `dsh` 时所在的项目目录,然后选中它。选中工作区前,会话输入区不可用。 +点击**选择工作区**,添加启动 `dsh` 时所在的项目目录,然后选中它。选中工作区前,会话输入框不可用。 ## 运行任务 From 09432d644b1ccdf76b8a2524d982cbe73da946a1 Mon Sep 17 00:00:00 2001 From: j-xiang Date: Fri, 14 Aug 2026 13:43:38 +0800 Subject: [PATCH 059/146] docs(i18n): sharpen workspace contrast --- docs/user/guide/index.i18n.yaml | 2 +- docs/user/guide/index.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index 7627ad57fc..bff88e1dff 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/index.md index.md: 2ae8847e3e04e131517e505bbc3c5f2c1047a45c -index.zh.md: dbb33f4860e6170be0e134c5968df91c6b0ac5da +index.zh.md: 1f7b9862d9785281dad25891c4c86934eac0cd06 diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index dbb33f4860..1f7b9862d9 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -2,7 +2,7 @@ [English](index.md) | 中文 -请先按照[根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置,但全新的 Web UI 不会选中任何工作区,你需要添加一个工作区。 +请先按照[根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置;全新的 Web UI 则不会选中任何工作区,你需要添加一个工作区。 ## 配置模型 From fd24df156dde2e93edd6d62719f0913287f56c13 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 14:09:35 +0800 Subject: [PATCH 060/146] fix(ui-agent-preset): rename code preset to PTC Mode --- .../web/tests/snapshots/agent-preset-selection/menu.expected.md | 2 +- packages/client/ui-agent-preset/src/client/locales.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 7e0d1ae032..d2d9e1d0d2 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -2,6 +2,6 @@ - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." + - menuitem "PTC Mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 54acc298a1..9244fea931 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -37,7 +37,7 @@ export const en: Record = { presetStandardName: 'Standard mode', presetStandardDescription: 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', - presetCodeName: 'Code mode', + presetCodeName: 'PTC Mode', presetCodeDescription: 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', From 3a793a0f7b0a2a0a4e34c920e50488196792b0b1 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 14:36:17 +0800 Subject: [PATCH 061/146] fix(ui-agent-preset): use sentence case for PTC mode --- .../web/tests/snapshots/agent-preset-selection/menu.expected.md | 2 +- packages/client/ui-agent-preset/src/client/locales.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index d2d9e1d0d2..9e3116d353 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -2,6 +2,6 @@ - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - - menuitem "PTC Mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." + - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 9244fea931..de19803a29 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -37,7 +37,7 @@ export const en: Record = { presetStandardName: 'Standard mode', presetStandardDescription: 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', - presetCodeName: 'PTC Mode', + presetCodeName: 'PTC mode', presetCodeDescription: 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', From 631d68713f3532af64745240cfc5c4a0030684e6 Mon Sep 17 00:00:00 2001 From: j-xiang Date: Fri, 14 Aug 2026 14:43:05 +0800 Subject: [PATCH 062/146] docs(i18n): clarify root README reference --- docs/user/guide/index.i18n.yaml | 2 +- docs/user/guide/index.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index bff88e1dff..ba745feed8 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/index.md index.md: 2ae8847e3e04e131517e505bbc3c5f2c1047a45c -index.zh.md: 1f7b9862d9785281dad25891c4c86934eac0cd06 +index.zh.md: 7e4a140e11ca409755d63ade41a31d9b3cc4d4b6 diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 1f7b9862d9..7e4a140e11 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -2,7 +2,7 @@ [English](index.md) | 中文 -请先按照[根 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置;全新的 Web UI 则不会选中任何工作区,你需要添加一个工作区。 +请先按照[根目录 README](../../../README.md#run) 中的说明启动 Web UI;命令会打印其访问地址。本指南从服务器已经运行的状态开始。`dsh` 进程会把启动时所在的目录作为默认文件系统位置;全新的 Web UI 则不会选中任何工作区,你需要添加一个工作区。 ## 配置模型 From 47399764c5e245f1066a68f87bd5a65206d75d7f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:27:13 +0800 Subject: [PATCH 063/146] fix(release): order publication by every installed dependency section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish order exists to make a partial publication self-consistent: an interrupted run should leave a prefix whose packages never point at a version absent from the registry. It read only dependencies and optionalDependencies, so peer declarations — how sibling harness packages reference each other, 1088 edges in the dsh family — constrained nothing. Peer edges now order the publication too. devDependencies still do not: a dev dependency is absent from the published package. Peers cannot constrain it absolutely. Sibling packages declare each other as peers, which is what closes the two cycles here, and npm treats an unmet peer as a warning rather than a resolution failure. Install edges therefore win: a peer edge is dropped where the peer installs the member declaring it, or where following it would revisit a member already being visited. One peer edge is dropped in the dsh family and two in the vendored family; every install edge is honoured. A cycle among install edges stays a defect rather than something to order around, and release:verify now reports it before the build instead of letting it surface once pack is already writing tarballs. Install-edge acyclicity is checked on its own graph, because a peer edge leading into an install edge otherwise reads as a cycle where the install edges are perfectly orderable. --- scripts/release/families.spec.ts | 62 +++++++++++++++++++ scripts/release/families.ts | 103 ++++++++++++++++++++++++------- scripts/release/verify.ts | 11 +++- 3 files changed, 154 insertions(+), 22 deletions(-) diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 369fc62b16..2c872d4487 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -74,6 +74,68 @@ describe('release families', () => { expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/) }) + it('publishes a peer before its consumer', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { peerDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }), + member('packages/a/zebra', '@deepseek-ai/dsh-zebra'), + ] + + // Name order alone would place the consumer first; the peer edge moves it. + expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + '@deepseek-ai/dsh-zebra', + '@deepseek-ai/dsh-consumer', + ]) + }) + + it('orders around a peer cycle rather than refusing to publish', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/left', '@deepseek-ai/dsh-left', { peerDependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }), + member('packages/a/right', '@deepseek-ai/dsh-right', { peerDependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }), + ] + + // Sibling packages declare each other as peers, and npm treats an unmet peer + // as a warning, so this pair has to publish rather than fail the release. + expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + '@deepseek-ai/dsh-right', + '@deepseek-ai/dsh-left', + ]) + }) + + it('honours an install edge even when a peer cycle surrounds it', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/base', '@deepseek-ai/dsh-base', { peerDependencies: { '@deepseek-ai/dsh-consumer': 'workspace:^' } }), + member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { + dependencies: { '@deepseek-ai/dsh-base': 'workspace:^' }, + peerDependencies: { '@deepseek-ai/dsh-base': 'workspace:^' }, + }), + ] + + // The install edge is absolute: base publishes first, and the peer edge that + // would reverse it is the one dropped. + expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-consumer', + ]) + }) + + it('ignores devDependencies when ordering', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { devDependencies: { '@deepseek-ai/dsh-zebra': 'workspace:^' } }), + member('packages/a/zebra', '@deepseek-ai/dsh-zebra'), + ] + + // A dev dependency is absent from the published package, so it must not move + // the consumer behind it. + expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + '@deepseek-ai/dsh-alpha', + '@deepseek-ai/dsh-zebra', + ]) + }) + it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => { const dsh = releaseFamily('dsh') const vendor = releaseFamily('vendor') diff --git a/scripts/release/families.ts b/scripts/release/families.ts index d39552636d..2135920047 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -13,8 +13,21 @@ import { globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { validateTarballPayload } from '../publication-payload.ts' -/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */ -const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const +/** + * Dependency sections a consumer must publish after, because npm resolves them + * when the package is installed: publishing a consumer first would leave a + * window where its own tree cannot be assembled. + */ +const INSTALL_SECTIONS = ['dependencies', 'optionalDependencies'] as const + +/** + * Peer declarations also order the publication, but they cannot constrain it. + * npm never installs a peer on the package's behalf — an unmet peer is a + * warning, not a resolution failure — and sibling packages legitimately declare + * each other as peers, which makes these edges the ones that close cycles. They + * order what they can and are dropped where they would deadlock. + */ +const PEER_SECTIONS = ['peerDependencies'] as const /** The workspace root manifest, which is never a release member. */ const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root' @@ -107,45 +120,93 @@ export abstract class ReleaseFamily { } /** - * Order members so every package publishes after the family members it depends on. + * Order members so every package publishes after the family members it + * depends on, which is what makes a partial publication self-consistent: an + * interrupted run leaves a prefix whose packages never point at something + * absent from the registry. + * + * Install edges are honoured absolutely — a cycle among them is a defect this + * reports rather than works around. Peer edges order what they can and are + * dropped where honouring one would deadlock: sibling packages declare each + * other as peers, and npm treats an unmet peer as a warning rather than a + * resolution failure ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * @param members - this family's members. * @returns The same members in publish order; ties break by name for determinism. */ publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] { const byName = new Map(members.map(member => [member.name, member])) - const ordered: ReleaseMember[] = [] - const placed = new Set() - const visiting = new Set() + const byNameSorted = [...members].sort((left, right) => left.name.localeCompare(right.name)) + const edges = (member: ReleaseMember, sections: readonly string[]): ReleaseMember[] => + this.orderEdges(member, byName, sections) - const visit = (member: ReleaseMember, path: readonly string[]): void => { - if (placed.has(member.name)) return - if (visiting.has(member.name)) { + // Install edges alone must be acyclic, and that is checked on its own graph: + // a peer edge leading into an install edge would otherwise read as a cycle + // where the install edges are perfectly orderable. + const installVisiting = new Set() + const installDone = new Set() + const checkInstall = (member: ReleaseMember, path: readonly string[]): void => { + if (installDone.has(member.name)) return + if (installVisiting.has(member.name)) { throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`) } - visiting.add(member.name) - for (const dependency of this.orderEdges(member, byName)) { - visit(dependency, [...path, member.name]) + installVisiting.add(member.name) + for (const dependency of edges(member, INSTALL_SECTIONS)) checkInstall(dependency, [...path, member.name]) + installVisiting.delete(member.name) + installDone.add(member.name) + } + for (const member of byNameSorted) checkInstall(member, []) + + // Emit the order over both kinds of edge. A node already on the stack is a + // cycle only peer edges can form, and skipping it drops just that edge. + const ordered: ReleaseMember[] = [] + const placed = new Set() + const onStack = new Set() + // Members reachable from one member through install edges. A peer edge is + // dropped when the peer installs the member declaring it: honouring it would + // emit a package before something it installs, and the install edge wins. + const installClosure = (member: ReleaseMember): Set => { + const reached = new Set() + const walk = (current: ReleaseMember): void => { + for (const dependency of edges(current, INSTALL_SECTIONS)) { + if (reached.has(dependency.name)) continue + reached.add(dependency.name) + walk(dependency) + } } - visiting.delete(member.name) + walk(member) + return reached + } + const visit = (member: ReleaseMember): void => { + if (placed.has(member.name) || onStack.has(member.name)) return + onStack.add(member.name) + for (const dependency of edges(member, INSTALL_SECTIONS)) visit(dependency) + for (const peer of edges(member, PEER_SECTIONS)) { + if (installClosure(peer).has(member.name)) continue + visit(peer) + } + onStack.delete(member.name) + if (placed.has(member.name)) return placed.add(member.name) ordered.push(member) } - - for (const member of [...members].sort((left, right) => left.name.localeCompare(right.name))) { - visit(member, []) - } + for (const member of byNameSorted) visit(member) return ordered } /** - * The family members one member depends on at runtime. + * The family members one member declares in the given sections. * @param member - the dependent member. * @param byName - every family member by package name. - * @returns Dependencies inside this family, sorted by name. + * @param sections - manifest sections to read. + * @returns Members of this family named there, sorted by name. */ - private orderEdges(member: ReleaseMember, byName: ReadonlyMap): ReleaseMember[] { + private orderEdges( + member: ReleaseMember, + byName: ReadonlyMap, + sections: readonly string[], + ): ReleaseMember[] { const edges: ReleaseMember[] = [] - for (const section of ORDER_SECTIONS) { + for (const section of sections) { const dependencies = member.manifest[section] if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue for (const name of Object.keys(dependencies)) { diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts index 1bd74c84d6..bd906bcc9e 100644 --- a/scripts/release/verify.ts +++ b/scripts/release/verify.ts @@ -55,6 +55,15 @@ function main(): void { const family = releaseFamily(values.family) const members = family.members(process.cwd()) family.verifyVersions(members) + // Resolve the publish order here, before the build: an install-edge cycle + // makes the order unrepresentable, and that has to surface at the first gate + // rather than when pack is already writing tarballs. + const ordered = family.publishOrder(members) + if (ordered.length !== members.length) { + throw new Error( + `release family ${family.id}: publish order covers ${String(ordered.length)} of ${String(members.length)} members`, + ) + } const publishing = process.env.RELEASE_PUBLISH === 'true' if (publishing) { @@ -64,7 +73,7 @@ function main(): void { const versions = [...new Set(members.map(member => member.version))] const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions` - console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`) + console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}, publish order resolved${publishing ? ', publish gates passed' : ''}`) } if (isEntry(import.meta.url)) main() From 70eb76eaecb73b3cd953ce9b4fcbbf78d1f6cc6d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:27:14 +0800 Subject: [PATCH 064/146] fix(release): keep npm's own output in the publish log Retry classification needs npm's failure text, so the publish call captured its streams instead of inheriting them. That silenced npm on the success path: the log lost the tarball contents, the notices, and the '+ name@version' confirmation for every package. Pipe the streams and echo them, so the log shows what npm reported and the caller still gets the text it classifies. The registry probe behind it keeps its streams captured, since its JSON and its E404 are internal queries rather than progress. --- scripts/release/process.ts | 26 ++++++++++++++++++++++++++ scripts/release/publish.ts | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/release/process.ts b/scripts/release/process.ts index 746f24ac36..acec98feae 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -38,6 +38,32 @@ export function attempt(command: string, args: readonly string[], options: RunOp return { status: result.status, stdout: result.stdout, stderr: result.stderr } } +/** + * Run a command, letting its output reach the log while also returning it. + * + * A step that both shows progress and classifies its own failure needs both: the + * output has to appear in the workflow log as the command produces it, and the + * caller has to read it to decide whether a failure is worth retrying. + * @param command - executable name. + * @param args - command arguments. + * @param options - working directory and environment. + * @returns The exit status and captured streams. + */ +export function attemptStreaming(command: string, args: readonly string[], options: RunOptions = {}): CommandResult { + const result = spawnSync(command, [...args], { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + // 'inherit' would leave nothing to capture, so the streams are piped and + // echoed instead. + stdio: ['inherit', 'pipe', 'pipe'], + }) + if (result.error !== undefined) throw result.error + if (result.stdout !== '') process.stdout.write(result.stdout) + if (result.stderr !== '') process.stderr.write(result.stderr) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } +} + /** * Run a command, capture its standard output, and fail on a non-zero exit. * @param command - executable name. diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 11ad01173c..f861da18c2 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -18,7 +18,7 @@ import { join, resolve } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' -import { attempt, isEntry } from './process.ts' +import { attempt, attemptStreaming, isEntry } from './process.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' /** @@ -102,7 +102,7 @@ async function publishTarball(tarball: string, name: string, version: string): P // command-line flag could not serve both and would override the manifest // that does. Each packed manifest decides, and // check-workspace-constraints holds every manifest to its sequence's level. - const result = attempt('npm', ['publish', tarball, ...tagArgs]) + const result = attemptStreaming('npm', ['publish', tarball, ...tagArgs]) const output = `${result.stdout}${result.stderr}` if (result.status === 0) return From 9fa0575ccc05970974a4cdbb29b096fc70848cba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:38:35 +0800 Subject: [PATCH 065/146] fix(release): print the publish order and the peer edges it drops The verify step resolved the publish order and said only that it had: the order a release actually follows, and the ordering it could not honour, stayed invisible until a publication was already running. publishOrder now returns that order together with the peer edges it dropped, verify prints both, and pack reads the order off the plan. The dropped edges are part of the result rather than a detail of forming it: the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the vendored family drops two (cordis-plugin-include and cordis-plugin-loader, which cordis declares as peers in return), and only whoever reads the log can judge whether a newly dropped edge is expected. Because pack runs on every pull request and master push, a change to the order is now reviewable there rather than observable only at publish time. The order is also checked against the edges it exists to honour. A cycle mixing peer and dependency declarations can put a dependency on the traversal stack, where it is skipped like a peer edge, emitting a consumer before something it installs; no later step can detect that, and it would surface as an unresolvable install for a consumer of the published packages. No family has that shape today, and the new test pins the three-package case that would. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 4 +- .../2026-08-10-npm-release-sequences.zh.md | 4 +- scripts/release/families.spec.ts | 36 ++++++++++-- scripts/release/families.ts | 57 +++++++++++++++++-- scripts/release/pack.ts | 2 +- scripts/release/verify.ts | 41 +++++++++++-- 7 files changed, 126 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 59b51bbe6b..4b14d3b8aa 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: e74a4ac8f2aadd8665ec0db198c6a317a0c201bc -2026-08-10-npm-release-sequences.zh.md: e152163976f945224f2524fccd7f831ba98e8161 +2026-08-10-npm-release-sequences.md: 2c46fb9b3e3fb8ddd90131e3c3113166580608d4 +2026-08-10-npm-release-sequences.zh.md: edbb2a8884f658b6c87ceb5762c01551a81a249d diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index e74a4ac8f2..2c46fb9b3e 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -88,9 +88,9 @@ The entity in this domain is a **release family**: a set of packages sharing one |---|---| | `ReleaseFamily` | a family's identity: member discovery, version baseline, tag prefix, packed-payload rule, installed entry | | `ReleaseMember` | one publishable package: directory, name, version, manifest | -| `publishOrder` | topological order over runtime dependencies, ties broken by package name; a cycle is reported rather than resolved arbitrarily | +| `publishOrder` | topological order over the sections npm installs plus peer declarations, ties broken by package name; a cycle among installed dependencies is reported rather than resolved arbitrarily, and a peer edge no order can honour is dropped and named | | `pack` | packs a whole family into one directory and records the upload order | -| `verify` | the family's version baseline, and — when publishing — that the run comes from that family's tag and its members are publishable | +| `verify` | the family's version baseline, the publish order it prints in full, and — when publishing — that the run comes from that family's tag and its members are publishable | | `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable | | `publish` | the three registry states above | | `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable | diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index e152163976..edbb2a8884 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -88,9 +88,9 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 |---|---| | `ReleaseFamily` | 一族的身份:成员发现、版本基线、tag 前缀、打包 payload 规则、已安装入口 | | `ReleaseMember` | 一个可发布包:目录、包名、版本、manifest | -| `publishOrder` | 按运行时依赖的拓扑序,同层按包名排;遇到环是报错而不是随意定序 | +| `publishOrder` | 按 npm 会安装的依赖段加 peer 声明做拓扑序,同层按包名排;安装依赖成环是报错而不是随意定序,任何排不进去的 peer 边被丢弃并点名 | | `pack` | 把整族打进一个目录并记录上传顺序 | -| `verify` | 族的版本基线;发布时还要求本次运行来自该族的 tag、且成员可发布 | +| `verify` | 族的版本基线、完整打印出来的发布顺序;发布时还要求本次运行来自该族的 tag、且成员可发布 | | `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口 | | `publish` | 上面那三态 | | `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import | diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 2c872d4487..66c3daf83f 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -57,7 +57,7 @@ describe('release families', () => { member('packages/a/zebra', '@deepseek-ai/dsh-zebra'), ] - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-library', '@deepseek-ai/dsh-consumer', '@deepseek-ai/dsh-zebra', @@ -82,13 +82,13 @@ describe('release families', () => { ] // Name order alone would place the consumer first; the peer edge moves it. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-zebra', '@deepseek-ai/dsh-consumer', ]) }) - it('orders around a peer cycle rather than refusing to publish', () => { + it('orders around a peer cycle rather than refusing to publish, and reports the edge it dropped', () => { const dsh = releaseFamily('dsh') const members = [ member('packages/a/left', '@deepseek-ai/dsh-left', { peerDependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }), @@ -97,10 +97,15 @@ describe('release families', () => { // Sibling packages declare each other as peers, and npm treats an unmet peer // as a warning, so this pair has to publish rather than fail the release. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + const plan = dsh.publishOrder(members) + expect(plan.order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-right', '@deepseek-ai/dsh-left', ]) + // One of the two edges has to give, and which one it is belongs in the log. + expect(plan.droppedPeerEdges).toEqual([ + { consumer: '@deepseek-ai/dsh-right', peer: '@deepseek-ai/dsh-left' }, + ]) }) it('honours an install edge even when a peer cycle surrounds it', () => { @@ -115,10 +120,29 @@ describe('release families', () => { // The install edge is absolute: base publishes first, and the peer edge that // would reverse it is the one dropped. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + const plan = dsh.publishOrder(members) + expect(plan.order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-consumer', ]) + expect(plan.droppedPeerEdges).toEqual([ + { consumer: '@deepseek-ai/dsh-base', peer: '@deepseek-ai/dsh-consumer' }, + ]) + }) + + it('refuses an order that would publish a consumer before a dependency it installs', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/alpha', '@deepseek-ai/dsh-alpha', { peerDependencies: { '@deepseek-ai/dsh-bravo': 'workspace:^' } }), + member('packages/a/bravo', '@deepseek-ai/dsh-bravo', { peerDependencies: { '@deepseek-ai/dsh-charlie': 'workspace:^' } }), + member('packages/a/charlie', '@deepseek-ai/dsh-charlie', { dependencies: { '@deepseek-ai/dsh-alpha': 'workspace:^' } }), + ] + + // A cycle of two peer edges closed by one install edge: dropping a peer edge + // would order this, and the traversal drops the install edge instead. That + // order would publish charlie before the alpha it installs, so it is refused + // here rather than published. + expect(() => { dsh.publishOrder(members) }).toThrow(/no publish order honours @deepseek-ai\/dsh-charlie -> @deepseek-ai\/dsh-alpha/) }) it('ignores devDependencies when ordering', () => { @@ -130,7 +154,7 @@ describe('release families', () => { // A dev dependency is absent from the published package, so it must not move // the consumer behind it. - expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + expect(dsh.publishOrder(members).order.map(entry => entry.name)).toEqual([ '@deepseek-ai/dsh-alpha', '@deepseek-ai/dsh-zebra', ]) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 2135920047..09acbe2da9 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -32,6 +32,28 @@ const PEER_SECTIONS = ['peerDependencies'] as const /** The workspace root manifest, which is never a release member. */ const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root' +/** One peer declaration the publish order leaves unordered. */ +interface DroppedPeerEdge { + /** Package declaring the peer. */ + readonly consumer: string + /** The declared peer, which publishes after `consumer` or alongside it in a cycle. */ + readonly peer: string +} + +/** + * A family's publish order together with the ordering it could not honour. + * + * The dropped edges are part of the result rather than a detail of forming it: + * a release drops real ordering constraints, and the operator reading the pack + * log is the only one who can judge whether a newly dropped edge is expected. + */ +export interface PublishPlan { + /** Members in publish order. */ + readonly order: readonly ReleaseMember[] + /** Peer declarations left unordered, in the order the traversal reached them. */ + readonly droppedPeerEdges: readonly DroppedPeerEdge[] +} + /** One publishable package of a release family. */ export interface ReleaseMember { /** Repository-relative package directory, for example `packages/core/session`. */ @@ -130,10 +152,12 @@ export abstract class ReleaseFamily { * dropped where honouring one would deadlock: sibling packages declare each * other as peers, and npm treats an unmet peer as a warning rather than a * resolution failure ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + * Every dropped edge is reported, because dropping one is a decision about a + * real release rather than an implementation detail. * @param members - this family's members. - * @returns The same members in publish order; ties break by name for determinism. + * @returns The order, ties broken by name for determinism, and the peer edges it left unordered. */ - publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] { + publishOrder(members: readonly ReleaseMember[]): PublishPlan { const byName = new Map(members.map(member => [member.name, member])) const byNameSorted = [...members].sort((left, right) => left.name.localeCompare(right.name)) const edges = (member: ReleaseMember, sections: readonly string[]): ReleaseMember[] => @@ -159,6 +183,7 @@ export abstract class ReleaseFamily { // Emit the order over both kinds of edge. A node already on the stack is a // cycle only peer edges can form, and skipping it drops just that edge. const ordered: ReleaseMember[] = [] + const droppedPeerEdges: DroppedPeerEdge[] = [] const placed = new Set() const onStack = new Set() // Members reachable from one member through install edges. A peer edge is @@ -181,7 +206,13 @@ export abstract class ReleaseFamily { onStack.add(member.name) for (const dependency of edges(member, INSTALL_SECTIONS)) visit(dependency) for (const peer of edges(member, PEER_SECTIONS)) { - if (installClosure(peer).has(member.name)) continue + if (installClosure(peer).has(member.name)) { + droppedPeerEdges.push({ consumer: member.name, peer: peer.name }) + continue + } + // A peer already on the stack is an ancestor, so it publishes after this + // member rather than before it: the edge is dropped, not honoured. + if (onStack.has(peer.name)) droppedPeerEdges.push({ consumer: member.name, peer: peer.name }) visit(peer) } onStack.delete(member.name) @@ -190,7 +221,25 @@ export abstract class ReleaseFamily { ordered.push(member) } for (const member of byNameSorted) visit(member) - return ordered + + // A cycle mixing both kinds of edge can put an install edge's target on the + // stack, where the traversal skips it like a peer edge and emits a consumer + // before something it installs. Nothing downstream can detect that, and it + // would only surface as an unresolvable install for whoever consumes the + // published packages, so the emitted order is checked against the edges it + // exists to honour. + const position = new Map(ordered.map((entry, index) => [entry.name, index])) + for (const [index, member] of ordered.entries()) { + for (const dependency of edges(member, INSTALL_SECTIONS)) { + const dependencyIndex = position.get(dependency.name) + if (dependencyIndex !== undefined && dependencyIndex < index) continue + throw new Error( + `release family ${this.id}: no publish order honours ${member.name} -> ${dependency.name};` + + ' a cycle mixing peer and dependency declarations reaches this dependency through a peer edge', + ) + } + } + return { order: ordered, droppedPeerEdges } } /** diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 47a33a26ac..5d2b9b4e64 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -45,7 +45,7 @@ function main(): void { const family = releaseFamily(values.family) const root = process.cwd() const destination = resolve(root, values.out ?? DEFAULT_OUTPUT) - const members = family.publishOrder(family.members(root)) + const members = family.publishOrder(family.members(root)).order family.verifyVersions(members) rmSync(destination, { recursive: true, force: true }) diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts index bd906bcc9e..5829087f97 100644 --- a/scripts/release/verify.ts +++ b/scripts/release/verify.ts @@ -9,7 +9,33 @@ import { parseArgs } from 'node:util' import { isEntry } from './process.ts' -import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' +import { releaseFamily, type PublishPlan, type ReleaseFamily, type ReleaseMember } from './families.ts' + +/** + * Print the publish order the release will follow, and the peer declarations it + * leaves unordered. + * + * The order is the release's own plan: an interrupted publication leaves exactly + * a prefix of it, so reading it is how anyone judges what a partial run left on + * the registry, and printing it on every pull request is what makes a change to + * the order reviewable rather than only observable during a publication. + * @param family - the release family. + * @param plan - the resolved order and its dropped edges. + */ +function reportPublishOrder(family: ReleaseFamily, plan: PublishPlan): void { + console.log(`release verify: publish order for family ${family.id}, ${String(plan.order.length)} member(s):`) + const width = String(plan.order.length).length + for (const [index, member] of plan.order.entries()) { + console.log(` ${String(index + 1).padStart(width, ' ')} ${member.name}@${member.version}`) + } + if (plan.droppedPeerEdges.length === 0) return + console.log( + `release verify: ${String(plan.droppedPeerEdges.length)} peer declaration(s) publish unordered,` + + ' because the peer cannot precede the package declaring it without contradicting a dependency edge' + + ' or its own cycle. npm treats an unmet peer as a warning, so this orders nothing and blocks nothing:', + ) + for (const edge of plan.droppedPeerEdges) console.log(` ${edge.consumer} -> ${edge.peer}`) +} /** * Assert every member may be published: npm refuses a `private` package. @@ -58,12 +84,13 @@ function main(): void { // Resolve the publish order here, before the build: an install-edge cycle // makes the order unrepresentable, and that has to surface at the first gate // rather than when pack is already writing tarballs. - const ordered = family.publishOrder(members) - if (ordered.length !== members.length) { + const plan = family.publishOrder(members) + if (plan.order.length !== members.length) { throw new Error( - `release family ${family.id}: publish order covers ${String(ordered.length)} of ${String(members.length)} members`, + `release family ${family.id}: publish order covers ${String(plan.order.length)} of ${String(members.length)} members`, ) } + reportPublishOrder(family, plan) const publishing = process.env.RELEASE_PUBLISH === 'true' if (publishing) { @@ -73,7 +100,11 @@ function main(): void { const versions = [...new Set(members.map(member => member.version))] const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions` - console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}, publish order resolved${publishing ? ', publish gates passed' : ''}`) + console.log( + `release verify: family ${family.id}, ${String(members.length)} member(s), ${summary},` + + ` publish order resolved, ${String(plan.droppedPeerEdges.length)} peer declaration(s) unordered` + + (publishing ? ', publish gates passed' : ''), + ) } if (isEntry(import.meta.url)) main() From 7b973e27c807b4e4ece13329e74a5390d091d45e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:08:01 +0800 Subject: [PATCH 066/146] feat(release): reject a module-scope load of an optional dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta..optional, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. Nothing checked it, and nothing here could: the failure needs an installed tree missing that package, and a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. verify-optional-dependency-imports reads each package's own manifest for what it allows to be absent, then scans the files that ship across both compiler faces. Value-versus-type is decided against a bound Program rather than the import syntax, because verbatimModuleSyntax is off: the compiler already erases an import whose bindings resolve to types, so a syntactic rule would report four forms that emit nothing. Only the type phase erases an import — `import defer` still resolves and links its module, deferring evaluation alone — which is what phaseModifier expresses and the deprecated isTypeOnly cannot. A violation names the package, the declaration that made it optional, and the way out in order: import it as a type, or restructure so module scope does not need it. A dynamic import() only moves the failure to first use, so the gate does not offer it as the remedy. The gate runs in ci-static and ci-primary through ciSharedStaticGates and locally in hygiene; it needs no build. TypeScriptProject gained a face parameter so a repository-wide gate can seed the client aggregate, which was previously unreachable; the constraint it was built with is unchanged, a face config and never the root solution. The tree has no violation today, so this guards the rule rather than fixing a defect. The spec pins all seven import forms against what tsc emits, including the four a syntactic rule would misreport. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 8 + .../2026-08-10-npm-release-sequences.zh.md | 8 + package.json | 3 +- scripts/run-gates.ts | 6 + scripts/ts-project.ts | 20 +- ...verify-optional-dependency-imports.spec.ts | 130 +++++++++++ scripts/verify-optional-dependency-imports.ts | 214 ++++++++++++++++++ 8 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 scripts/verify-optional-dependency-imports.spec.ts create mode 100644 scripts/verify-optional-dependency-imports.ts diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 4b14d3b8aa..851f0e9d2d 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: 2c46fb9b3e3fb8ddd90131e3c3113166580608d4 -2026-08-10-npm-release-sequences.zh.md: edbb2a8884f658b6c87ceb5762c01551a81a249d +2026-08-10-npm-release-sequences.md: d8495f158482d5d6e06a1752a096d1e9200b6070 +2026-08-10-npm-release-sequences.zh.md: 24b466f6b7b10d31ac2e025da6e12ec3c91c7548 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index 2c46fb9b3e..d8495f1584 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -80,6 +80,14 @@ Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substit `scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason. +### An optional dependency is never loaded at module scope + +A dependency in `optionalDependencies`, or a peer carrying `peerDependenciesMeta..optional`, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. The failure appears only in an installed tree missing that package, and no test here constructs one: a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. + +[`verify-optional-dependency-imports`](../../../../scripts/verify-optional-dependency-imports.ts) closes that hole. It reads each package's own manifest for what that package allows to be absent, then scans the files that ship — `packages/*/*/src/` and `apps/*/src/` — across both compiler faces. `vendor/` is out of scope, as pinned upstream source under the [vendoring policy](../../../../vendor/README.md). Value-versus-type is decided against a bound Program rather than the import syntax, because `verbatimModuleSyntax` is off: the compiler already erases an import whose bindings resolve to types, so `import type {}`, `import {}`, an inline `type` specifier, and a named binding that resolves to a type all emit nothing and are allowed, while a bare import, a value binding, and a star re-export are kept and rejected. Only the type phase erases an import: `import defer` still resolves and links its module, deferring evaluation alone, so the gate counts it as a load. + +A violation names the package, the declaration that made it optional, and the way out in order — import it as a type, which is all that declaration merging needs, or restructure so module scope does not need the package. A dynamic `import()` only moves the failure to first use, so it belongs to a caller that genuinely requires the package and handles its absence; reaching for it is a sign the dependency is not optional, and the gate does not offer it as the remedy. + ### Release family objects The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a subclass and a workflow lane, not changing the core. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index edbb2a8884..24b466f6b7 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -80,6 +80,14 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 `scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。 +### optional 依赖绝不在模块作用域被加载 + +`optionalDependencies` 里的依赖,或带 `peerDependenciesMeta..optional` 的 peer,在安装出来的树里可以不存在——这份「可以不存在」正是 optional 的全部承诺。而静态 import 在引入方模块加载时就求值,于是一个缺失的包不再表现为「这个能力不可用」,而是变成所有能走到该模块的代码的加载失败。这种失败只在「缺了该包的安装树」里出现,而本仓没有任何测试构造这种树:workspace 安装总是把每个包都装上,所以单测、快照、打包安装探针全都会过,而那个拒绝了这个 optional peer 的消费者拿到的却是坏的包。 + +[`verify-optional-dependency-imports`](../../../../scripts/verify-optional-dependency-imports.ts) 堵掉这个洞。它从每个包自己的 manifest 读取「这个包允许谁缺失」,再扫描会发布出去的文件——`packages/*/*/src/` 与 `apps/*/src/`——且两个编译门面各扫一遍。`vendor/` 不在范围内,那是[受 vendoring 政策管辖](../../../../vendor/README.md)的固定上游源码。值与类型的判定对着绑定好的 Program 做,而不是看 import 写法,因为 `verbatimModuleSyntax` 是关的:编译器本来就会消除绑定解析为类型的 import,所以 `import type {}`、`import {}`、内联 `type` 说明符、以及解析为类型的具名绑定都不产生产物、一律放行,而裸 import、值绑定、星号 re-export 会被保留、一律报错。只有 type 相位会消除 import:`import defer` 仍然解析并链接它的模块,只推迟求值,所以门禁把它算作一次加载。 + +报错会点名这个包、点名是哪条声明把它标成 optional 的,并按顺序给出出路——把它作为类型引入(声明合并需要的仅此而已),或者调整写法让模块作用域不再需要这个包。动态 `import()` 只是把失败推迟到首次使用,它属于那种确实需要这个包、并且自己处理缺失的调用方;会想到它,往往说明这个依赖并不 optional,所以门禁不把它作为解法给出。 + ### 发布族对象 这个领域里的实体是**发布族**:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。 diff --git a/package.json b/package.json index 1fd63bae9a..517d0c56d1 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "website:build": "pnpm run docs:build", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", @@ -125,7 +126,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 27664fab5e..6b775c9bf1 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -249,6 +249,9 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { + label: 'optional dependency imports', + }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -565,6 +568,9 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { label: 'node-next types', ...artifactOptions, }), + pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { + label: 'optional dependency imports', + }), ] } diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 9a0400a39d..53b100ceb0 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -11,6 +11,12 @@ interface ProjectGraph { options: ts.CompilerOptions } +/** + * A compiler face: the two aggregates a repository-wide program may seed from. + * The root solution is never one of them. + */ +export type CompilerFace = 'host' | 'client' + /** TypeScript config host shared by repository scripts. */ export const repositoryConfigHost: ts.ParseConfigFileHost = { useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, @@ -24,12 +30,12 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = { } /** - * Parse the host aggregate tsconfig and flatten all referenced projects into one + * Parse one face aggregate tsconfig and flatten all referenced projects into one * semantic graph. Never seed the root solution: flattening host+client into one * program collides the cordis Context merges. */ -function loadProjectGraph(projectRoot: string): ProjectGraph { - const rootConfigPath = resolve(projectRoot, 'tsconfig.host.json') +function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph { + const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`) const rootConfig = parseConfig(rootConfigPath) const rootNames = new Set() const visited = new Set() @@ -81,8 +87,12 @@ export class TypeScriptProject { /** The checker shared by every semantic query in this project. */ readonly checker: ts.TypeChecker - constructor(private readonly projectRoot: string) { - const graph = loadProjectGraph(projectRoot) + /** + * @param projectRoot - repository root the program is seeded and reported from. + * @param face - which compiler face aggregate to flatten. + */ + constructor(readonly projectRoot: string, face: CompilerFace = 'host') { + const graph = loadProjectGraph(projectRoot, face) this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options)) this.checker = this.program.getTypeChecker() } diff --git a/scripts/verify-optional-dependency-imports.spec.ts b/scripts/verify-optional-dependency-imports.spec.ts new file mode 100644 index 0000000000..3bb857050f --- /dev/null +++ b/scripts/verify-optional-dependency-imports.spec.ts @@ -0,0 +1,130 @@ +/** + * Tests for the optional-dependency load gate: which import and re-export forms + * survive emit, and therefore load a package the installed tree may not carry. + * + * The expectations here match what `tsc` emits with `verbatimModuleSyntax` off: + * `import type`, `import {}`, an inline `type` specifier, and a named binding + * that resolves to a type all disappear; a bare import, a value binding, and a + * star re-export remain. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { TypeScriptProject } from './ts-project.ts' +import { collectOptionalImportViolations } from './verify-optional-dependency-imports.ts' + +const FIXTURE: Record = { + 'tsconfig.host.json': JSON.stringify({ + compilerOptions: { + target: 'es2022', + module: 'esnext', + moduleResolution: 'bundler', + noEmit: true, + skipLibCheck: true, + types: [], + paths: { + '@f/opt': ['./packages/f/opt/src/index.ts'], + '@f/hard': ['./packages/f/hard/src/index.ts'], + }, + }, + include: ['packages/**/*.ts'], + }), + + 'packages/f/opt/package.json': JSON.stringify({ name: '@f/opt', version: '0.0.1' }), + 'packages/f/opt/src/index.ts': [ + 'export interface Shape { a: number }', + 'export const runtimeValue = 1', + '', + ].join('\n'), + + 'packages/f/hard/package.json': JSON.stringify({ name: '@f/hard', version: '0.0.1' }), + 'packages/f/hard/src/index.ts': 'export const hardValue = 2\n', + + // The consumer allows @f/opt to be absent and requires @f/hard. + 'packages/f/consumer/package.json': JSON.stringify({ + name: '@f/consumer', + version: '0.0.1', + dependencies: { '@f/hard': '*' }, + peerDependencies: { '@f/opt': '*' }, + peerDependenciesMeta: { '@f/opt': { optional: true } }, + }), + + // Elided by the compiler, so each of these is allowed. + 'packages/f/consumer/src/allowed-type-only.ts': [ + "import type {} from '@f/opt'", + 'export const a = 1', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-empty.ts': [ + "import {} from '@f/opt'", + 'export const b = 1', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-inline-type.ts': [ + "import { type Shape } from '@f/opt'", + 'export const c: Shape = { a: 1 }', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-type-binding.ts': [ + "import { Shape } from '@f/opt'", + 'export const d: Shape = { a: 1 }', + '', + ].join('\n'), + 'packages/f/consumer/src/allowed-type-reexport.ts': [ + "export type { Shape } from '@f/opt'", + '', + ].join('\n'), + // A hard dependency may be loaded at module scope; only optional ones may not. + 'packages/f/consumer/src/allowed-hard-dependency.ts': [ + "import { hardValue } from '@f/hard'", + 'export const e = hardValue', + '', + ].join('\n'), + + // Kept by the compiler, so each of these loads a package that may be absent. + 'packages/f/consumer/src/rejected-bare.ts': [ + "import '@f/opt'", + 'export const f = 1', + '', + ].join('\n'), + 'packages/f/consumer/src/rejected-value.ts': [ + "import { runtimeValue } from '@f/opt'", + 'export const g = runtimeValue', + '', + ].join('\n'), + 'packages/f/consumer/src/rejected-star-reexport.ts': [ + "export * from '@f/opt'", + '', + ].join('\n'), +} + +const root = mkdtempSync(join(tmpdir(), 'optional-imports-')) +for (const [rel, content] of Object.entries(FIXTURE)) { + mkdirSync(dirname(join(root, rel)), { recursive: true }) + writeFileSync(join(root, rel), content) +} +const violations = collectOptionalImportViolations(new TypeScriptProject(root)) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('optional dependency loads', () => { + it('reports every form the compiler keeps, and nothing else', () => { + expect(violations.map(violation => violation.split(' loads ')[0])).toEqual([ + 'packages/f/consumer/src/rejected-bare.ts:1', + 'packages/f/consumer/src/rejected-star-reexport.ts:1', + 'packages/f/consumer/src/rejected-value.ts:1', + ]) + }) + + it('names the package, the declaration that made it optional, and the way out', () => { + expect(violations[0]).toBe( + 'packages/f/consumer/src/rejected-bare.ts:1 loads @f/opt at module scope,' + + ' declared optional in peerDependenciesMeta; import it as a type,' + + ' or restructure so module scope does not need it', + ) + }) +}) diff --git a/scripts/verify-optional-dependency-imports.ts b/scripts/verify-optional-dependency-imports.ts new file mode 100644 index 0000000000..e7e16d7aab --- /dev/null +++ b/scripts/verify-optional-dependency-imports.ts @@ -0,0 +1,214 @@ +/** + * Reject a static value import of an optional dependency. + * + * A dependency declared in `optionalDependencies`, or as a peer carrying + * `peerDependenciesMeta..optional`, may be absent from an installed tree — + * that absence is what "optional" promises a consumer. A static import is + * evaluated when the importing module loads, so one absent package turns + * "this capability is unavailable" into a load failure for everything that + * reaches the importing module. + * + * The way out, in order: import it as a type, which emits nothing and is all + * that declaration merging needs; or restructure so nothing at module scope + * needs the package. A dynamic `import()` only moves the failure to first use, + * so it belongs to a caller that genuinely requires the package and handles its + * absence — it is a last resort, not the default answer, and reaching for it is + * a sign the dependency is not optional. + * + * Value-vs-type is decided against a bound Program rather than the import + * syntax, because `verbatimModuleSyntax` is off: a named import used only in + * type positions is elided and does not load anything. The decision is + * deliberately conservative in one direction — a value binding the compiler + * would elide because nothing references it in a value position is still + * reported, and the fix it asks for (`import type`, or dropping the binding) is + * what the published package wants regardless. Both compiler faces are scanned, + * and only files that ship — a published package's `src` — are subject. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import { TypeScriptProject, type CompilerFace } from './ts-project.ts' + +const root = resolve(import.meta.dirname, '..') + +/** Directories whose `src` ships as a published package. */ +const PUBLISHED_SOURCE = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+)\/src\// + +/** How a manifest marked a dependency optional, for the violation message. */ +type OptionalKind = 'optionalDependencies' | 'peerDependenciesMeta' + +/** + * The package name a module specifier resolves to. + * @param specifier - an import specifier, possibly a subpath. + * @returns The bare package name, keeping a leading scope. + */ +function packageOf(specifier: string): string { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier +} + +/** + * Read a manifest field as a record. + * @param manifest - parsed manifest. + * @param field - field name. + * @returns The field value, or an empty record. + */ +function record(manifest: Record, field: string): Record { + const value = manifest[field] + if (value === null || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record +} + +/** + * The dependencies one manifest allows to be absent. + * @param manifest - parsed manifest. + * @returns Each optional package name and how it was marked. + */ +function optionalDependencies(manifest: Record): Map { + const optional = new Map() + for (const name of Object.keys(record(manifest, 'optionalDependencies'))) { + optional.set(name, 'optionalDependencies') + } + const peers = record(manifest, 'peerDependencies') + for (const [name, meta] of Object.entries(record(manifest, 'peerDependenciesMeta'))) { + if (meta === null || typeof meta !== 'object') continue + if ((meta as Record).optional !== true) continue + // A meta entry for an undeclared peer is check-workspace-constraints' business. + if (!(name in peers)) continue + optional.set(name, 'peerDependenciesMeta') + } + return optional +} + +/** One package directory's optional dependencies, resolved once per directory. */ +const optionalByDirectory = new Map>() + +/** + * The optional dependencies of the package owning a source file. + * @param projectRoot - root the relative path is resolved against. + * @param relativePath - repository-relative path of a source file. + * @returns That package's optional dependencies, empty when it declares none. + */ +function optionalFor(projectRoot: string, relativePath: string): Map { + const directory = resolve(projectRoot, relativePath.slice(0, relativePath.indexOf('/src/'))) + const cached = optionalByDirectory.get(directory) + if (cached !== undefined) return cached + const manifestPath = resolve(directory, 'package.json') + const parsed: unknown = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {} + const manifest = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + const optional = optionalDependencies(manifest) + optionalByDirectory.set(directory, optional) + return optional +} + +/** + * Whether one binding of an import or re-export names a value. + * @param name - the local binding name node. + * @param checker - the program's checker. + * @returns True when the binding carries value meaning, and on an unresolved + * symbol, so an unresolvable binding fails closed. + */ +function bindsValue(name: ts.Identifier | ts.StringLiteral, checker: ts.TypeChecker): boolean { + const symbol = checker.getSymbolAtLocation(name) + if (symbol === undefined) return true + const target = (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol) + return (target.flags & ts.SymbolFlags.Value) !== 0 +} + +/** + * Whether an import declaration loads its module at run time. + * @param declaration - the import declaration. + * @param checker - the program's checker. + * @returns True when the emitted module keeps the import. + */ +function importLoadsModule(declaration: ts.ImportDeclaration, checker: ts.TypeChecker): boolean { + const clause = declaration.importClause + // A bare `import 'x'` is kept for its side effects. + if (clause === undefined) return true + // Only the type phase erases the import. `import defer` still resolves and + // links the module, deferring evaluation alone, so an absent package fails + // exactly as it would without the modifier. + if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false + if (clause.name !== undefined) return true + const bindings = clause.namedBindings + if (bindings === undefined || ts.isNamespaceImport(bindings)) return true + return bindings.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker)) +} + +/** + * Whether a re-export loads its module at run time. + * @param declaration - the export declaration, which carries a module specifier. + * @param checker - the program's checker. + * @returns True when the emitted module keeps the re-export. + */ +function exportLoadsModule(declaration: ts.ExportDeclaration, checker: ts.TypeChecker): boolean { + if (declaration.isTypeOnly) return false + const clause = declaration.exportClause + // `export * from 'x'` re-exports whatever values the module has. + if (clause === undefined || ts.isNamespaceExport(clause)) return true + return clause.elements.some(element => !element.isTypeOnly && bindsValue(element.name, checker)) +} + +/** + * Collect every static value import of an optional dependency in one face. + * @param project - a bound repository project. + * @returns One message per violation, sorted by location. + */ +export function collectOptionalImportViolations(project: TypeScriptProject): string[] { + const checker = project.checker + const violations: string[] = [] + for (const sourceFile of project.sourceFiles()) { + if (sourceFile.isDeclarationFile) continue + const relativePath = project.relativePath(sourceFile) + if (!PUBLISHED_SOURCE.test(relativePath)) continue + const optional = optionalFor(project.projectRoot, relativePath) + if (optional.size === 0) continue + + for (const statement of sourceFile.statements) { + const isImport = ts.isImportDeclaration(statement) + if (!isImport && !ts.isExportDeclaration(statement)) continue + const specifierNode = statement.moduleSpecifier + if (specifierNode === undefined || !ts.isStringLiteral(specifierNode)) continue + const kind = optional.get(packageOf(specifierNode.text)) + if (kind === undefined) continue + const loads = isImport + ? importLoadsModule(statement, checker) + : exportLoadsModule(statement, checker) + if (!loads) continue + const { line } = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)) + violations.push( + `${relativePath}:${String(line + 1)} loads ${specifierNode.text} at module scope,` + + ` declared optional in ${kind}; import it as a type, or restructure so module scope does not need it`, + ) + } + } + return violations.sort((left, right) => left.localeCompare(right)) +} + +/** CLI entry: list every violation and exit 1, or confirm the invariant holds. */ +function main(): void { + const faces: readonly CompilerFace[] = ['host', 'client'] + const violations = new Set() + for (const face of faces) { + for (const violation of collectOptionalImportViolations(new TypeScriptProject(root, face))) { + violations.add(violation) + } + } + if (violations.size === 0) { + console.log('verify-optional-dependency-imports: no optional dependency is loaded at module scope.') + return + } + console.error(`verify-optional-dependency-imports: ${String(violations.size)} optional dependency load(s) at module scope:`) + for (const violation of [...violations].sort((left, right) => left.localeCompare(right))) { + console.error(` ${violation}`) + } + process.exit(1) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { + main() +} From 0e50fa290c6a21fb0e4f3b5fd3e4a7807ffcaae4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:14:16 +0800 Subject: [PATCH 067/146] fix(release): state what the echo helper does, and drop two dead claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections from review, none of which change behaviour. attemptStreaming promised output "as the command produces it", which spawnSync cannot do: it returns only after the child exits, and the two streams are echoed one after the other, so their interleaving is lost. For an npm publish that is visible — notices go to stderr while the `+ name@version` confirmation goes to stdout, so the confirmation prints first. The helper is now attemptEchoed and its contract says buffered, echoed after exit, stdout before stderr; live progress would need an asynchronous spawn with data listeners. The traversal comment claimed a node on the stack is a cycle only peer edges can form, and that skipping it drops just that edge. The cycle does carry a peer edge, because the install edges were proved acyclic a moment earlier, but the back edge that reaches the stacked node need not be the peer one — which is what the post-condition exists to catch, so the comment now points at it instead of asserting an invariant the traversal does not have. The `if (placed.has(member.name)) return` after leaving the stack was unreachable: a re-entrant visit returns at the top guard while the member is on the stack, so it can never be placed by the time the recursion unwinds. --- scripts/release/families.ts | 8 +++++--- scripts/release/process.ts | 18 +++++++++++++----- scripts/release/publish.ts | 4 ++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 09acbe2da9..f43939f17d 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -180,8 +180,11 @@ export abstract class ReleaseFamily { } for (const member of byNameSorted) checkInstall(member, []) - // Emit the order over both kinds of edge. A node already on the stack is a - // cycle only peer edges can form, and skipping it drops just that edge. + // Emit the order over both kinds of edge. A node already on the stack closes + // a cycle, and that cycle carries at least one peer edge because the install + // edges were just proved acyclic — but the back edge that reaches the stacked + // node is not necessarily the peer one, so the post-condition below decides + // whether the emitted order survived. const ordered: ReleaseMember[] = [] const droppedPeerEdges: DroppedPeerEdge[] = [] const placed = new Set() @@ -216,7 +219,6 @@ export abstract class ReleaseFamily { visit(peer) } onStack.delete(member.name) - if (placed.has(member.name)) return placed.add(member.name) ordered.push(member) } diff --git a/scripts/release/process.ts b/scripts/release/process.ts index acec98feae..392a6cab66 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -39,17 +39,25 @@ export function attempt(command: string, args: readonly string[], options: RunOp } /** - * Run a command, letting its output reach the log while also returning it. + * Run a command, capture its output, and echo it once the command exits. * - * A step that both shows progress and classifies its own failure needs both: the - * output has to appear in the workflow log as the command produces it, and the - * caller has to read it to decide whether a failure is worth retrying. + * A step that both shows what a command said and classifies its own failure + * needs both halves: the output has to reach the workflow log, and the caller has + * to read it to decide whether a failure is worth retrying. + * + * This is not live progress. `spawnSync` returns only after the child exits, so + * nothing appears while the command runs, and the two streams are echoed one + * after the other — all of stdout, then all of stderr — which loses their + * interleaving. For an npm publish that matters in one visible way: `npm notice` + * lines go to stderr while the `+ name@version` confirmation goes to stdout, so + * the log shows the confirmation first. Live progress would need an + * asynchronous spawn with data listeners. * @param command - executable name. * @param args - command arguments. * @param options - working directory and environment. * @returns The exit status and captured streams. */ -export function attemptStreaming(command: string, args: readonly string[], options: RunOptions = {}): CommandResult { +export function attemptEchoed(command: string, args: readonly string[], options: RunOptions = {}): CommandResult { const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index f861da18c2..6301241ed7 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -18,7 +18,7 @@ import { join, resolve } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' -import { attempt, attemptStreaming, isEntry } from './process.ts' +import { attempt, attemptEchoed, isEntry } from './process.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' /** @@ -102,7 +102,7 @@ async function publishTarball(tarball: string, name: string, version: string): P // command-line flag could not serve both and would override the manifest // that does. Each packed manifest decides, and // check-workspace-constraints holds every manifest to its sequence's level. - const result = attemptStreaming('npm', ['publish', tarball, ...tagArgs]) + const result = attemptEchoed('npm', ['publish', tarball, ...tagArgs]) const output = `${result.stdout}${result.stderr}` if (result.status === 0) return From d5be1d62c9e565399b44e07ac0caf4500a817439 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:19:02 +0800 Subject: [PATCH 068/146] feat(release): count publish progress against the whole release set A dsh publication uploads 221 packages one at a time, spaced apart, and the log gave no way to tell how far along a run was: every line named a package, none said where that package sat in the set. Each per-package line now carries [n/total]. Every entry in the order settles as either published or already present, so the counter is both "packages settled" and "position in the publish order", and the closing summary names the member count alongside the published and skipped totals. --- scripts/release/publish.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 6301241ed7..2590e96e52 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -136,9 +136,15 @@ async function main(): Promise { const family = releaseFamily(values.family) const directory = resolve(process.cwd(), values.from) + // Every entry in the order settles as either published or already present, so + // one counter answers "how far along is this run" for whoever is watching a + // release that takes minutes per family. + const order = readPublishOrder(directory) + const total = String(order.length) let published = 0 let skipped = 0 - for (const filename of readPublishOrder(directory)) { + for (const [index, filename] of order.entries()) { + const progress = `[${String(index + 1)}/${total}]` const tarball = join(directory, filename) const { name, version } = packedIdentity(tarball) const state = registryState(name, version) @@ -151,7 +157,7 @@ async function main(): Promise { + '\nBump the version, or investigate why the build is not reproducible.', ) } - console.log(`release publish: ${name}@${version} already published, skipping`) + console.log(`release publish: ${progress} ${name}@${version} already published, skipping`) skipped += 1 continue } @@ -159,11 +165,14 @@ async function main(): Promise { // only skips does not wait at all. if (published > 0) await sleep(PUBLISH_SPACING_MS) await publishTarball(tarball, name, version) - console.log(`release publish: ${name}@${version} published`) + console.log(`release publish: ${progress} ${name}@${version} published`) published += 1 } - console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`) + console.log( + `release publish: family ${family.id}, ${total} member(s),` + + ` ${String(published)} published, ${String(skipped)} already present`, + ) } if (isEntry(import.meta.url)) await main() From 5201b84863e7a89d3de177be17e398c9038eca07 Mon Sep 17 00:00:00 2001 From: kingwl Date: Tue, 4 Aug 2026 11:03:45 +0800 Subject: [PATCH 069/146] fix(web): avoid history pagination stack overflow --- ...ge-history-pagination-call-stack.i18n.yaml | 6 +++ ...-04-large-history-pagination-call-stack.md | 27 ++++++++++++ ...-large-history-pagination-call-stack.zh.md | 27 ++++++++++++ packages/host/apiproxy/src/api-proxy.ts | 7 +++- .../apiproxy/tests/api-proxy-view.spec.ts | 41 ++++++++++++++++++- 5 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml new file mode 100644 index 0000000000..96fb51ced0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-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 .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md +2026-08-04-large-history-pagination-call-stack.md: 28c22121123a227c507c506683ae727d238d98bd +2026-08-04-large-history-pagination-call-stack.zh.md: 57dde9bdc0a4aa52e1af024eb606bf9258430fa7 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md new file mode 100644 index 0000000000..28c2212112 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md @@ -0,0 +1,27 @@ +# Agent Note: Large history provenance is scanned without argument expansion + +Status: implemented + +English | [中文](2026-08-04-large-history-pagination-call-stack.zh.md) + +## Problem + +A finalized assistant message can reference hundreds of thousands of streamed chunks through `sourceEventSeqs`. History pagination found the message group's first event with `Math.min(event.seq, ...sourceEventSeqs)`, so a valid session could exceed the JavaScript engine's function-argument limit and make `session.history` fail with HTTP 500. + +## Decision + +Pagination scans `sourceEventSeqs` and updates the earliest sequence number one element at a time. The algorithm remains linear in the provenance size and preserves the existing page boundary: a page starts before all recorded sources of its oldest included message. + +A regression test rejects multi-argument minimum calls and verifies that every provenance event remains on the page with its finalized message. This exercises the failure mechanism without making the default test suite allocate a production-sized chunk stream. + +## Alternatives considered + +- **Raise the JavaScript stack or argument limit** — rejected: the limit is engine- and deployment-dependent, and array expansion still makes valid history depend on an unrelated runtime ceiling. +- **Truncate `sourceEventSeqs` during pagination** — rejected: this could cut a page inside a message and violate replay grouping. +- **Cap streamed chunk count at the provider boundary** — rejected: providers may legitimately emit long streams, and pagination must handle every valid session representation. + +## Consequences + +- Large provenance arrays no longer make history pagination throw solely because of their length. +- Pagination semantics and wire responses are unchanged. +- This does not bound the byte size of a history page or the browser cost of replaying it; those performance concerns remain separate from the server-side call-stack failure. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md new file mode 100644 index 0000000000..57dde9bdc0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 大规模历史记录的溯源信息通过扫描处理,不做参数展开 + +Status: implemented + +[English](2026-08-04-large-history-pagination-call-stack.md) | 中文 + +## 问题 + +一条已定稿的 assistant 消息可以通过 `sourceEventSeqs` 引用数十万个流式分片。历史记录分页使用 `Math.min(event.seq, ...sourceEventSeqs)` 查找消息组的首个事件,因此,有效会话可能超出 JavaScript 引擎的函数参数数量上限,导致 `session.history` 以 HTTP 500 失败。 + +## 决策 + +分页逻辑逐项扫描 `sourceEventSeqs`,每次使用一个元素更新最早的序号。该算法的复杂度相对溯源信息规模仍为线性,并保留现有的页面边界:页面起点位于其所含最早消息的所有已记录来源之前。 + +回归测试会拒绝以多个参数调用取最小值的做法,并验证每个溯源事件都会与其已定稿消息保留在同一页中。这既覆盖了故障机制,也避免默认测试套件分配生产规模的分片流。 + +## 考虑过的替代方案 + +- **提高 JavaScript 栈或参数上限**:不予采纳,因为该上限取决于引擎和部署环境,而且数组展开仍会让有效历史记录受制于无关的运行时上限。 +- **在分页时截断 `sourceEventSeqs`**:不予采纳,因为这可能会从消息中间切分页面,破坏回放分组。 +- **在提供方边界限制流式分片数量**:不予采纳,因为提供方可能会合理地产生长流,而分页必须处理每一种有效的会话表示。 + +## 后果 + +- 大型溯源数组不再仅因长度而使历史记录分页抛出异常。 +- 分页语义与协议响应保持不变。 +- 本决策不限制历史记录页面的字节大小,也不限制浏览器回放该页面的开销;这两项性能问题仍与服务端调用栈故障分开处理。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index bda99362b6..7c6abbf271 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -302,7 +302,12 @@ function paginate( if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue count++ const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs - const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq + let groupStart = event.seq + if (sources !== undefined) { + for (const source of sources) { + if (source < groupStart) groupStart = source + } + } if (count >= maxMessages) { cut = groupStart break diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 3d756b7da8..6955b9416c 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -7,7 +7,7 @@ * turn/end cleared it. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -285,6 +285,45 @@ describe('mux live view computation', () => { expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index)) }) + it('paginates a message with many provenance sources without variadic argument expansion', async () => { + const { ctx } = await harness() + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + session.append('turn/start', { turn: 1 }) + const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index, text: 'x' }, + }).seq) + const message = session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'x'.repeat(sources.length) }], + source: { kind: 'model', provider: 'p', model: 'm' }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: sources }) + + const scalarMin = Math.min + const min = vi.spyOn(Math, 'min').mockImplementation((...values) => { + if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness') + return scalarMin(...values) + }) + try { + const response = await api.sessions.history({ + rpcId: RpcId('t-hist-large-provenance'), + payload: { sessionId: session.id, maxMessages: 1 }, + }) + if (!response.result.ok) throw new Error('unreachable') + expect(response.result.value.events.map(entry => entry.event.seq)).toEqual([...sources, message.seq]) + expect(response.result.value.hasMore).toBe(true) + } finally { + min.mockRestore() + } + }) + it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) From 4edbdd443ecb22543ead13205df585d20537bed1 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:54:13 +0800 Subject: [PATCH 070/146] fix(web): repair Safari textarea soft-wrap shrink --- ...text-layers-share-one-scrollport.i18n.yaml | 4 +- ...mposer-text-layers-share-one-scrollport.md | 2 + ...ser-text-layers-share-one-scrollport.zh.md | 2 + ...safari-textarea-soft-wrap-reflow.i18n.yaml | 6 + ...-08-13-safari-textarea-soft-wrap-reflow.md | 47 +++++++ ...-13-safari-textarea-soft-wrap-reflow.zh.md | 47 +++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/skeleton/InputBar.tsx | 18 ++- .../src/client/skeleton/safari.ts | 42 ++++++ .../tests/input-bar.client.spec.tsx | 110 +++++++++++++++ .../tests/safari.client.spec.ts | 131 ++++++++++++++++++ 13 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md create mode 100644 packages/client/ui-conversation/src/client/skeleton/safari.ts create mode 100644 packages/client/ui-conversation/tests/safari.client.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index 92dcd28a7e..4c5e88d55e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: 6097779529f86e6d994296ae396f108c63f01abc -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 753d67d538d0c17512444639d60b7b5c8ff80e9a +2026-07-31-composer-text-layers-share-one-scrollport.md: d01231f706a7d3850ce1b3770ef351cb7e211384 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 3ce8cc05dc57b05bb8c0bd903a0b64b12d4a6963 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index 6097779529..d01231f706 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -24,6 +24,8 @@ One scrolling box, holding both layers. The browser then applies one offset to both layers, in the same frame, on the same compositor. The caret is bound to its glyphs by construction rather than by upkeep: there is no code to run, no event to wait for, and no state that can be one frame stale. The wheel-chaining handler stays, retargeted from the textarea to the scrollport, and remains the only listener on the box. +Safari's native text control has one engine exception: deleting across a soft-wrap threshold can retain the former line layout after the mirror shrinks. The [Safari soft-wrap recovery](2026-08-13-safari-textarea-soft-wrap-reflow.md) restores the zero-overflow invariant before paint without changing the one-scrollport design. + Two things the previous mechanism needed are gone with it: **The backdrop's trailing-line sentinel.** It existed to keep the two boxes' scroll extents equal — a textarea reserves a line box for the caret after a final newline while `white-space: pre-wrap` collapses a text node's trailing newline, so a draft ending in a newline made the backdrop one line shorter and clamped the mirrored offset a line above the caret. With one scrollport the backdrop's own extent decides nothing: the mirror div sizes the stack for both layers, both start at the same top, and a layer whose content ends earlier simply paints nothing on the last line. The shape is worth keeping in mind rather than the mechanism: it is the one that measured 628 against 652 when the two boxes had to agree on a height. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 753d67d538..3ce8cc05dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -24,6 +24,8 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl 于是浏览器在同一帧、同一个合成器上,把同一个偏移施加给两层。光标与字形的绑定来自结构本身,而不是来自持续维护:没有代码要跑,没有事件要等,也没有任何状态可能落后一帧。滚轮接力处理器保留,只是从 textarea 改挂到滚动容器上,并且仍是这个盒子上唯一的监听。 +Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的删除可能在镜像层收缩后仍保留原先的行布局。[Safari 软换行恢复](2026-08-13-safari-textarea-soft-wrap-reflow.md)会在绘制前恢复零溢出不变量,而不改变单滚动容器设计。 + 上一版机制所需要的两样东西随它一起消失: **backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行,因此以换行结尾的草稿会让 backdrop 少一行,把镜像偏移钳制在光标上方一行。改为单一滚动容器后,backdrop 自身的范围不再决定任何事:镜像层为两层统一定高,两层顶端对齐,内容更早结束的那一层只是在最后一行什么都不画。值得记住的是这类草稿形状而不是那套机制:正是它在「两个盒子必须就高度达成一致」的时代量出了 628 对 652。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml new file mode 100644 index 0000000000..f7fde5254a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.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 .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md +2026-08-13-safari-textarea-soft-wrap-reflow.md: fb264a8e6fbe24369584f2427bbb0c462b450ecf +2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: 7f55a5260e825059e1f9a08db03f13e19484d14e diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md new file mode 100644 index 0000000000..fb264a8e6f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md @@ -0,0 +1,47 @@ +# Agent Note: Safari textarea soft-wrap shrink recovery + +Status: implemented + +English | [中文](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md) + +## Problem + +The composer keeps the caret and selection in a transparent native textarea while the backdrop paints visible glyphs and the hidden mirror determines the full draft height. The [single-scrollport decision](2026-07-31-composer-text-layers-share-one-scrollport.md) therefore depends on the textarea owning no scrollable overflow: after every draft commit, its `scrollHeight` and `clientHeight` are equal and its `scrollTop` is zero. + +Safari 26.5.2 can retain the textarea's former native line layout when Backspace moves a draft across a soft-wrap threshold at the same time that React updates the mirror. In the reproduced two-line-to-one-line transition, the mirror, backdrop, grow stack, and textarea box all become 28px high, but the textarea still reports `scrollHeight=52` and `scrollTop=20`. The caret remains in the stale native line while the backdrop correctly paints one line. + +The `color` declaration is not a layout input. Changing its inline style changes the computed color but leaves the stale `52/28/20` state intact. Editing the stylesheet rule happens to trigger broader rule invalidation and clears the state to `28/28/0`, which explains why Web Inspector makes the declaration appear causal. + +## Decision + +`InputBar` detects Safari once from the Apple vendor and the `Version/... Safari/...` user-agent form, while rejecting known alternate iOS browser tokens such as `CriOS`, `FxiOS`, `EdgiOS`, and `OPiOS`. A browser shell indistinguishable through these identity fields still has to violate the textarea overflow invariant before the recovery mutates layout. + +The native textarea change handler records whether an edit shortens the controlled draft. After that draft commits, a layout effect returns without reading geometry unless both the cached Safari identity and the native-shrink signal are present. It then checks the single-scrollport invariant: equal `scrollHeight` and `clientHeight` are settled and trigger no forced layout. A mismatch first changes the textarea's real height by one pixel, forces layout, restores the owned height, and forces layout again. This rebuilds Safari's native text-control layout without changing the value, selection, IME state, or undo transaction. + +The temporary native overflow can leave the draft scrollport's auto height at the former line count even after the textarea is correct. The recovery therefore repeats the one-pixel invalidation on `[data-input-scroll]` after repairing the textarea. Both elements return to their owned styles before paint; the settled one-line state is `scrollHeight=clientHeight=28`, `scrollTop=0`, and a 28px scrollport. + +## Verification + +Component tests synthesize Safari's stale metrics, assert the textarea-then-scrollport invalidation order, preserve selection, and prove that a growing native draft reads no geometry. Browser-identity tests cover desktop and mobile Safari, desktop Chromium, Chrome, Edge, and Opera on iOS, and an Apple web view. + +The assembled package is also exercised in Safari 26.5.2 through the native 51-character-to-50-character Backspace path. Playwright WebKit 26.5 settles correctly without the workaround in both the assembled app and a reduced page, so the repository's Chromium browser lane cannot reproduce this Safari application defect; the focused component test pins the engine state until an automatable Safari lane exists. + +## Alternatives considered + +**Change `color` or use `-webkit-text-fill-color`.** Rejected because inline color changes and transparent text fill leave the stale native geometry unchanged. Stylesheet-rule editing works only because its invalidation scope is broader than the declaration's paint semantics. + +**Set `scrollTop=0`.** Rejected because it moves the stale native content without rebuilding its two-line `scrollHeight`; the caret can become clipped instead of aligned. + +**Rewrite the textarea value.** Clearing and restoring the value rebuilds Safari's text control, but it mutates the editing state that owns IME composition and selection. The height invalidation leaves the value untouched. + +**Use `field-sizing: content`.** Rejected because Safari reproduces the stale two-line intrinsic height after the same deletion, and the composer still needs the mirror as the caret ruler and backdrop metric peer. + +**Invalidate only the textarea or only the scrollport.** Rejected because the textarea-only recovery clears `52/28/20` but can leave the scrollport at 52px, while the scrollport-only recovery leaves the textarea's native overflow untouched. The ordered pair is the smallest complete recovery. + +**Check geometry after every Safari draft commit.** Rejected because reading `scrollHeight` or `clientHeight` after React changes the mirror can synchronously lay out even a healthy growing draft. A native shortening signal limits the invariant read to edits that can produce the observed shrink defect. + +**Run the recovery in every browser.** Rejected because Chromium, Playwright WebKit, and Firefox maintain the invariant without forced layouts. The Safari identity and observed mismatch jointly bound the synchronous work. + +## Consequences + +Non-Safari browsers, programmatic draft updates, and native edits that do not shorten the draft perform no geometry read. A native Safari shortening reads the overflow invariant and pays the four forced layouts only when the textarea violates it. The exceptional path accepts rare local work before paint to preserve caret alignment, native editing semantics, and the single scrolling box. An equivalent stale state caused only by resize or sidebar width changes has not been observed and is outside this recovery trigger. The browser test gap remains explicit: real Safari evidence owns the engine defect, while deterministic component coverage owns the recovery and its browser gate. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md new file mode 100644 index 0000000000..7f55a5260e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Safari textarea 软换行收缩恢复 + +Status: implemented + +[English](2026-08-13-safari-textarea-soft-wrap-reflow.md) | 中文 + +## 问题 + +composer 把光标与选区留在透明的原生 textarea 中,由 backdrop 绘制可见字形,并由隐藏的镜像层决定完整草稿高度。因此,[单滚动容器决策](2026-07-31-composer-text-layers-share-one-scrollport.md)依赖 textarea 不持有可滚动溢出:每次草稿提交后,它的 `scrollHeight` 与 `clientHeight` 相等,`scrollTop` 为零。 + +当 Backspace 让草稿跨过软换行阈值,同时 React 更新镜像层时,Safari 26.5.2 可能保留 textarea 原先的原生行布局。在复现出的两行变一行转换中,镜像层、backdrop、自增高栈和 textarea 盒都变为 28px 高,但 textarea 仍报告 `scrollHeight=52` 与 `scrollTop=20`。光标留在陈旧的原生行中,而 backdrop 已正确绘制为一行。 + +`color` 声明不是布局输入。修改 inline style 会改变计算后的颜色,却让陈旧的 `52/28/20` 状态保持不变。编辑样式表规则会碰巧触发范围更广的规则失效,并把状态清为 `28/28/0`,这正是 Web Inspector 让该声明显得像成因的原因。 + +## 决策 + +`InputBar` 通过 Apple vendor 与 `Version/... Safari/...` 形式的 user agent 一次性识别 Safari,同时排除 `CriOS`、`FxiOS`、`EdgiOS`、`OPiOS` 等已知的 iOS 其他浏览器 token。仅凭这些 identity 字段无法区分的浏览器壳仍必须先违反 textarea 溢出不变量,恢复逻辑才会修改布局。 + +原生 textarea change handler 会记录本次编辑是否缩短受控草稿。草稿提交后,除非同时存在已缓存的 Safari identity 与原生缩短信号,否则 layout effect 会在读取几何前直接返回。随后它才检查单滚动容器不变量:`scrollHeight` 与 `clientHeight` 相等即为稳定态,不会触发强制布局。出现差异时,逻辑先把 textarea 的实际高度改变一个像素,强制布局,再恢复其自有高度并再次强制布局。这样无需改变值、选区、输入法组合状态或撤销事务,即可重建 Safari 的原生文本控件布局。 + +即使 textarea 已正确恢复,临时的原生溢出仍可能让草稿滚动容器的 auto 高度停在原行数。因此,恢复逻辑会在修复 textarea 后,对 `[data-input-scroll]` 重复一次单像素失效。两个元素都会在绘制前恢复各自拥有的样式;稳定的一行状态为 `scrollHeight=clientHeight=28`、`scrollTop=0`,滚动容器高度为 28px。 + +## 验证 + +组件测试会合成 Safari 的陈旧度量,断言先 textarea 后滚动容器的失效顺序,保留选区,并证明原生草稿增长不会读取几何。浏览器 identity 测试覆盖桌面与移动 Safari、桌面 Chromium、iOS Chrome/Edge/Opera 和 Apple web view。 + +组装后的包还会在 Safari 26.5.2 中通过原生的 51 字符到 50 字符 Backspace 路径验证。Playwright WebKit 26.5 在组装应用与最小化页面中都无需本绕法即可正确稳定,因此仓库的 Chromium 浏览器泳道无法复现这个 Safari 应用缺陷;在可自动化的 Safari 泳道出现之前,由聚焦组件测试固定该引擎状态。 + +## 备选方案 + +**修改 `color` 或使用 `-webkit-text-fill-color`。** 被否决,因为 inline color 修改与透明 text fill 都不会改变陈旧的原生几何。编辑样式表规则之所以有效,只是因为其失效范围比该声明的绘制语义更广。 + +**设置 `scrollTop=0`。** 被否决,因为这只会移动陈旧的原生内容,不会重建其两行 `scrollHeight`;光标可能从错位变为被裁剪。 + +**重写 textarea 的值。** 清空再恢复值能够重建 Safari 文本控件,但会改动拥有输入法组合与选区的编辑状态。高度失效不会触碰值。 + +**使用 `field-sizing: content`。** 被否决,因为相同删除后 Safari 的两行固有高度仍会陈旧,并且 composer 仍需要镜像层充当光标标尺与 backdrop 的度量对端。 + +**只让 textarea 或滚动容器失效。** 被否决,因为只恢复 textarea 虽能清除 `52/28/20`,却可能把滚动容器留在 52px;只恢复滚动容器则不会改变 textarea 的原生溢出。这个有序二元操作是最小的完整恢复。 + +**每次 Safari 草稿提交后都检查几何。** 被否决,因为 React 改变镜像层后读取 `scrollHeight` 或 `clientHeight`,即使草稿健康增长也可能同步执行布局。原生缩短信号把不变量读取限制在可能产生已观测收缩缺陷的编辑中。 + +**在所有浏览器中运行恢复逻辑。** 被否决,因为 Chromium、Playwright WebKit 与 Firefox 无需强制布局即可维持该不变量。Safari identity 与已观测到的差异共同限定同步工作范围。 + +## 影响 + +非 Safari 浏览器、程序化草稿更新,以及不会缩短草稿的原生编辑都不会读取几何。Safari 的原生缩短会读取溢出不变量,并且仅在 textarea 违反不变量时承担四次强制布局。例外路径以绘制前的罕见局部工作换取光标对齐、原生编辑语义与单一滚动盒。尚未观测到仅由 resize 或侧栏宽度变化引发的同类陈旧状态,本恢复触发器也不覆盖它。浏览器测试缺口保持显式:真实 Safari 证据负责引擎缺陷,确定性的组件覆盖负责恢复逻辑与浏览器门控。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 954ba5d7af..6d866e05e7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-conversation/README.md -README.md: ecf463da619662fe158511079e7773ee3c627ac8 -README.zh.md: f8922b160cdc6feca94dea998163c25d803a5535 +README.md: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf +README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ecf463da61..d1a265b578 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compaction/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compaction/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. -The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Safari alone receives a pre-paint recovery when a native edit shortens the draft and leaves stale soft-wrap overflow; draft growth, programmatic updates, and other browsers never read layout for that recovery ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)). Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model-selection, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f8922b160c..3f303391d3 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compaction/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩(context compaction)图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compaction/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。只有 Safari 会在原生编辑缩短草稿并留下陈旧软换行溢出时执行绘制前恢复;草稿增长、程序化更新与其他浏览器都不会为这项恢复读取布局([决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md))。 别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model-selection,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它无法路由的提示词。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9585677ae3..000174f513 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -6,7 +6,7 @@ * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { @@ -31,6 +31,7 @@ import { } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' +import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ @@ -106,6 +107,8 @@ export function InputBar({ const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) + const safari = useMemo(() => isSafariBrowser(navigator), []) + const safariNativeShrinkRef = useRef(false) // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; // clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend. const composingRef = useRef(false) @@ -154,6 +157,18 @@ export function InputBar({ } }, [attachments, input?.imageIds, inputActions]) + // A native Safari edit that shortens the draft may leave the previous + // soft-wrap layout behind after the mirror shrinks. The native-change signal + // keeps ordinary typing and programmatic draft updates from reading layout; + // the helper then repairs only measured overflow before paint while + // preserving native editing state. See + // .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md. + useLayoutEffect(() => { + const nativeShrink = safariNativeShrinkRef.current + safariNativeShrinkRef.current = false + if (safari && nativeShrink) repairSafariTextareaLayout(inputRef.current) + }, [draft, safari]) + useEffect(() => { if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) }, [attachments, preview]) @@ -343,6 +358,7 @@ export function InputBar({ if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value + safariNativeShrinkRef.current = safari && next.length < draft.length keyboard.setDraft(next) // selectionStart is number|null in lib.dom; the type-aware lint program narrows it. // oxlint-disable-next-line typescript/no-unnecessary-condition diff --git a/packages/client/ui-conversation/src/client/skeleton/safari.ts b/packages/client/ui-conversation/src/client/skeleton/safari.ts new file mode 100644 index 0000000000..d563b25e6c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/safari.ts @@ -0,0 +1,42 @@ +/** Safari-specific textarea layout recovery for the conversation composer. */ + +/** Browser identity fields needed to distinguish Safari from other WebKit-based browsers. */ +export interface BrowserIdentity { + readonly userAgent: string + readonly vendor: string +} + +const ALTERNATE_IOS_BROWSER = /\b(?:CriOS|FxiOS|EdgiOS|OPiOS|OPT|DuckDuckGo|Brave)(?:\/|\b)/ + +/** + * Detect Safari's `Version/... Safari/...` form while excluding known alternate iOS browser tokens. + * @param identity - Browser user-agent and vendor values. + * @returns Whether the identity should use the Safari-specific recovery. + */ +export function isSafariBrowser(identity: BrowserIdentity): boolean { + return identity.vendor === 'Apple Computer, Inc.' + && /\bVersion\/[\d.]+.*\bSafari\/[\d.]+/.test(identity.userAgent) + && !ALTERNATE_IOS_BROWSER.test(identity.userAgent) +} + +/** + * Repair Safari's stale native textarea layout and the scrollport auto height it can contaminate. + * @param input - Composer textarea whose own scrollable overflow must stay zero. + */ +export function repairSafariTextareaLayout(input: HTMLTextAreaElement | null): void { + if (input === null || input.scrollHeight <= input.clientHeight) return + const scrollport = input.closest('[data-input-scroll]') + if (scrollport === null) return + + const inputHeight = input.style.height + input.style.height = `${String(input.clientHeight + 1)}px` + void input.offsetHeight + input.style.height = inputHeight + void input.offsetHeight + + const scrollportHeight = scrollport.style.height + scrollport.style.height = `${String(scrollport.clientHeight + 1)}px` + void scrollport.offsetHeight + scrollport.style.height = scrollportHeight + void scrollport.offsetHeight +} diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 94354bd316..f7d5e02a7f 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -824,6 +824,116 @@ describe('running and lock semantics', () => { expect(backdrop.textContent).toBe('line\n'.repeat(40)) }) + it('repairs Safari native overflow after the mirror shrinks the draft', () => { + const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.') + const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + ) + onTestFinished(() => { + vendor.mockRestore() + userAgent.mockRestore() + }) + const { textarea } = bench({ draft: 'two wrapped lines' }) + const scrollport = textarea.closest('[data-input-scroll]')! + let inputRepaired = false + let scrollportRepaired = false + const inputLayouts: string[] = [] + const scrollportLayouts: string[] = [] + Object.defineProperty(textarea, 'clientHeight', { + configurable: true, + get: () => textarea.style.height === '29px' ? 29 : 28, + }) + Object.defineProperty(textarea, 'scrollHeight', { + configurable: true, + get: () => inputRepaired ? 28 : 52, + }) + Object.defineProperty(textarea, 'offsetHeight', { + configurable: true, + get: () => { + inputLayouts.push(textarea.style.height) + if (textarea.style.height === '') inputRepaired = true + return textarea.clientHeight + }, + }) + Object.defineProperty(scrollport, 'clientHeight', { + configurable: true, + get: () => { + if (scrollport.style.height === '53px') return 53 + if (inputRepaired && !scrollportRepaired) return 52 + return 28 + }, + }) + Object.defineProperty(scrollport, 'offsetHeight', { + configurable: true, + get: () => { + scrollportLayouts.push(scrollport.style.height) + if (scrollport.style.height === '') scrollportRepaired = true + return scrollport.clientHeight + }, + }) + textarea.setSelectionRange(5, 5) + + fireEvent.change(textarea, { target: { value: 'one line' } }) + + expect(inputLayouts).toEqual(['29px', '']) + expect(scrollportLayouts).toEqual(['53px', '']) + expect(textarea.style.height).toBe('') + expect(scrollport.style.height).toBe('') + expect(textarea.scrollHeight).toBe(textarea.clientHeight) + expect(scrollport.clientHeight).toBe(28) + }) + + it('does not force the Safari recovery for another iOS browser', () => { + const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.') + const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1', + ) + onTestFinished(() => { + vendor.mockRestore() + userAgent.mockRestore() + }) + const { textarea } = bench({ draft: 'two wrapped lines' }) + const scrollport = textarea.closest('[data-input-scroll]')! + Object.defineProperty(textarea, 'clientHeight', { configurable: true, value: 28 }) + Object.defineProperty(textarea, 'scrollHeight', { configurable: true, value: 52 }) + Object.defineProperty(textarea, 'offsetHeight', { + configurable: true, + get: () => { throw new Error('non-Safari browser must not force textarea layout') }, + }) + Object.defineProperty(scrollport, 'offsetHeight', { + configurable: true, + get: () => { throw new Error('non-Safari browser must not force scrollport layout') }, + }) + + fireEvent.change(textarea, { target: { value: 'one line' } }) + + expect(scrollport.style.height).toBe('') + }) + + it('does not read Safari layout while a native edit grows the draft', () => { + const vendor = vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.') + const userAgent = vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue( + 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + ) + onTestFinished(() => { + vendor.mockRestore() + userAgent.mockRestore() + }) + const { textarea, shell } = bench({ draft: 'one line' }) + Object.defineProperty(textarea, 'clientHeight', { + configurable: true, + get: () => { throw new Error('growing Safari input must not read layout') }, + }) + Object.defineProperty(textarea, 'scrollHeight', { + configurable: true, + get: () => { throw new Error('growing Safari input must not read layout') }, + }) + + fireEvent.change(textarea, { target: { value: 'one line grows' } }) + + expect(shell.snapshot.draft).toBe('one line grows') + }) + it('an edit the composer performs itself scrolls the caret back into view', async () => { // Paste and cut suppress the native edit, so no engine reveals the caret // for them. jsdom has no layout: the rects are stubbed, diff --git a/packages/client/ui-conversation/tests/safari.client.spec.ts b/packages/client/ui-conversation/tests/safari.client.spec.ts new file mode 100644 index 0000000000..895680d693 --- /dev/null +++ b/packages/client/ui-conversation/tests/safari.client.spec.ts @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { isSafariBrowser, repairSafariTextareaLayout } from '../src/client/skeleton/safari.ts' + +describe('Safari browser detection', () => { + it.each([ + { + name: 'desktop Safari', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Safari/605.1.15', + expected: true, + }, + { + name: 'mobile Safari', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1', + expected: true, + }, + { + name: 'desktop Chromium', + vendor: 'Google Inc.', + userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', + expected: false, + }, + { + name: 'Chrome on iOS', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0.0.0 Mobile/15E148 Safari/604.1', + expected: false, + }, + { + name: 'Edge on iOS with Safari tokens', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 EdgiOS/140.0 Mobile/15E148 Safari/604.1', + expected: false, + }, + { + name: 'Opera on iOS with Safari tokens', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 OPiOS/6.0 Mobile/15E148 Safari/604.1', + expected: false, + }, + { + name: 'Apple web view', + vendor: 'Apple Computer, Inc.', + userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148', + expected: false, + }, + ])('identifies $name', ({ vendor, userAgent, expected }) => { + expect(isSafariBrowser({ vendor, userAgent })).toBe(expected) + }) +}) + +describe('Safari textarea layout recovery', () => { + it('does nothing while the textarea owns no scrollable overflow', () => { + const input = document.createElement('textarea') + Object.defineProperty(input, 'clientHeight', { value: 28 }) + Object.defineProperty(input, 'scrollHeight', { value: 28 }) + + repairSafariTextareaLayout(input) + + expect(input.style.height).toBe('') + }) + + it('invalidates a stale native layout and restores the owned height', () => { + const input = document.createElement('textarea') + const scrollport = document.createElement('div') + scrollport.setAttribute('data-input-scroll', '') + scrollport.appendChild(input) + input.value = 'abcdef' + input.setSelectionRange(3, 3) + input.style.height = '100%' + scrollport.style.height = '100%' + let inputRepaired = false + let scrollportRepaired = false + const inputLayouts: string[] = [] + const scrollportLayouts: string[] = [] + Object.defineProperty(input, 'clientHeight', { + get: () => input.style.height === '29px' ? 29 : 28, + }) + Object.defineProperty(input, 'scrollHeight', { + get: () => inputRepaired ? 28 : 52, + }) + Object.defineProperty(input, 'offsetHeight', { + get: () => { + inputLayouts.push(input.style.height) + if (input.style.height === '100%') inputRepaired = true + return input.clientHeight + }, + }) + Object.defineProperty(scrollport, 'clientHeight', { + get: () => { + if (scrollport.style.height === '53px') return 53 + if (inputRepaired && !scrollportRepaired) return 52 + return 28 + }, + }) + Object.defineProperty(scrollport, 'offsetHeight', { + get: () => { + scrollportLayouts.push(scrollport.style.height) + if (scrollport.style.height === '100%') scrollportRepaired = true + return scrollport.clientHeight + }, + }) + + repairSafariTextareaLayout(input) + + expect(inputLayouts).toEqual(['29px', '100%']) + expect(scrollportLayouts).toEqual(['53px', '100%']) + expect(input.style.height).toBe('100%') + expect(scrollport.style.height).toBe('100%') + expect(input.scrollHeight).toBe(input.clientHeight) + expect(scrollport.clientHeight).toBe(28) + expect([input.selectionStart, input.selectionEnd]).toEqual([3, 3]) + }) + + it('does nothing outside the composer scrollport', () => { + const input = document.createElement('textarea') + Object.defineProperty(input, 'clientHeight', { value: 28 }) + Object.defineProperty(input, 'scrollHeight', { value: 52 }) + + repairSafariTextareaLayout(input) + + expect(input.style.height).toBe('') + }) + + it('accepts an absent textarea during teardown', () => { + expect(() => { repairSafariTextareaLayout(null) }).not.toThrow() + }) +}) From 48d14b4a7cd8eaec7f8f0795cd8c069f98de98c4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:17:50 +0800 Subject: [PATCH 071/146] docs(client): state where a browser-only dependency is declared Browser artifacts resolve nothing on a user's machine: tsdown inlines every non-platform specifier, the shell dist answers the rest from its frozen module table, and Vite inlines the shell's own imports into the published dist. Record the resulting declaration rule, and the Agent Note behind it. --- ...026-08-14-client-build-time-deps.i18n.yaml | 6 ++ .../2026-08-14-client-build-time-deps.md | 100 ++++++++++++++++++ .../2026-08-14-client-build-time-deps.zh.md | 100 ++++++++++++++++++ packages/client/AGENTS.md | 11 +- 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.md create mode 100644 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml new file mode 100644 index 0000000000..4c98962ba9 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.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 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.md +2026-08-14-client-build-time-deps.md: 0605225a70a16fed9004acfe26adba9b6166f201 +2026-08-14-client-build-time-deps.zh.md: 5caed3422288711532c40cd767f72c97693e68b4 diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md new file mode 100644 index 0000000000..0605225a70 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md @@ -0,0 +1,100 @@ +# Agent Note: Client build-time dependencies stay out of the install face + +Status: proposed + +English | [中文](2026-08-14-client-build-time-deps.zh.md) + +## Problem + +A browser artifact resolves nothing on the user's machine: + +- A `ui-*` plugin package's browser artifact is `lib/client.js`, where tsdown inlines every non-platform specifier (`noExternal` in `packages/client/tsdown.client.ts`). The specifiers that survive are answered by the loader's frozen module table, because `require` inside that bundle is a parameter the loader injects, not Node's. +- Platform modules (`PLATFORM_MODULES`) come from the shell `dist`, never from Node resolution. +- The shell's own imports are inlined by Vite into `@deepseek-ai/dsh-web-frontend`'s published `dist`; that package ships `dist` alone and has no `.` export. + +Every browser code path is therefore a build product, served as an asset or baked into `dist`. Yet the packages those artifacts are built from — react, react-dom, shiki, katex, clsx, the micromark and mdast families — sit in `dependencies` and non-optional `peerDependencies`, which npm installs for every consumer of the published package. Across the repository that is 79 such external declarations in 38 packages, downloaded by users who never load them. + +## Proposal + +### The rule + +**An external package only a browser artifact reaches belongs in `devDependencies`.** Two deliberate omissions are as much part of the rule: + +- **External packages only.** A `@deepseek-ai/*` name stays where its manifest puts it. Such a declaration also states which package supplies an injected service, which Remote contribution an assembly mounts, or which Loader row must resolve; [verify-runtime-closure](../../../../scripts/verify-runtime-closure.ts) and the Loader read it, and the app installs the package regardless — so moving one removes meaning without removing a download. +- **Anything the node half reaches stays**, an erased type import included. + +Faces are walked from the entries a manifest publishes, not by a directory rule, so a module under `src/` that only the browser entry reaches counts as browser source: + +| kind | test | host face entries | +| --- | --- | --- | +| `bundle-half` | has a `./client` export | every export target except `./client` | +| `browser-library` | under `packages/client/` with no `./client` export | `src/invariant.ts` alone — the companion the host mounts; `.` is browser code | +| `prebuilt-dist` | no `.` export, ships a `dist` | none: the package offers Node no entry | + +### The gate: `scripts/verify-client-runtime-deps.ts` + +Wired into `pnpm run hygiene`, about 35 seconds — the cost of two bound Programs, the same order as `verify-optional-dependency-imports` in that lane. It reuses the repository's tooling rather than growing its own: `TypeScriptProject` (`scripts/ts-project.ts`) binds the host and client compiler faces separately (that file states why the two cannot share one program — the cordis Context merges collide), `ts.resolveModuleName` resolves relative specifiers, and the walk stops at the package boundary. + +Three findings decided the mechanism, after a first pass that scanned string literals: + +1. A package name must match as a name: the `react` substring inside `'@deepseek-ai/dsh-client-web-react'` silently swallowed react. +2. Whether `./client` is the tsdown browser bundle is keyed on the **artifact path** (`./lib/client.js`), not the subpath name — `dsh-goal` publishes `./client` as `./lib/types/client.js`, a plain tsc-emitted browser-shared module. +3. `require`, `require.resolve`, and dynamic `import()` on a literal each reach a package; `require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` is a real host resolution path. + +Two classes, both reported per entry: + +| class | count | test | +| --- | --- | --- | +| `browser` | 74 | only a browser artifact reaches it | +| `nothing` | 5 | no reference names it: `client-runtime`'s `react` (which contradicts its own React-free layering red line), the peer `react` of `ui-settings` and `ui-theme`, `ui-trajectory`'s peer `react-dom`, and `ui-primitives`' `@types/mdast` | + +Each conservative rule below answers a false report or a semantic loss observed while building it: + +- **A type reference from the node half keeps its declaration.** `import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'` in `src/invariant.ts` is erased at run time, yet it states which package supplies the service that companion registers — `verify-runtime-closure`'s relation. +- **A package publishing a Node entry with no source counterpart is skipped whole, and named in the output.** `dsh-goal`'s `./typert -> ./lib/typert.host.js` is emitted by the typert generator and carries its own `import { z } from 'zod'`, which no source states. Getting this test right removed four false reports, among them `api-gateway`'s `typert-registry`. +- A `cordis*.yml` the package owns counts as host face: a Loader row names its plugin instead of importing it. +- `@deepseek-ai/cordis` is exempt — check-workspace-constraints requires it as both peer and dev everywhere. + +`--json` output feeds the bulk edit and the install measurement. + +### What leaves an install + +Measured against a real `npm install` of the published CLI, with tarball bytes read from an isolated cache: 103 external tarballs stop being downloaded, 6.05 MB in total. + +| group | packages | saved | +| --- | --- | --- | +| syntax highlighting and math (shiki family, oniguruma family, katex) | 16 | 3.93 MB | +| react and view libraries (react, react-dom, scheduler, immer, zustand, `@tanstack/*`, clsx, use-sync-external-store) | 11 | 1.47 MB | +| markdown and ansi pipeline plus odds and ends (micromark, mdast, hast families, anser, a few `@types/*`) | 76 | 0.65 MB | + +Our own six browser-library packages (ui-primitives, ui-slots, web-react, ui-attachment, schema-form, client-web — 0.20 MB together) stay installed: code names them, and the rule above leaves those declarations alone. + +### How it lands, split by nature + +1. **Documentation first**: a declaration section in `packages/client/AGENTS.md`, and one clause in the new-plugin-package checklist. +2. **The gate**: `scripts/verify-client-runtime-deps.ts`, its `package.json` script, its place in `hygiene`, and a counterexample spec. +3. **The manifests**: 79 entries in 38 packages. 50 need a new `devDependencies` entry; the rest already carry one, so the change is a deleted line. +4. **Re-measure after the next release** with the same method, confirming the 103 tarballs stay gone. + +## Alternatives considered + +- **Scanning string literals**: the first implementation, rejected by the three findings above — the react-inside-web-react substring had already produced a silent miss. +- **Reading built artifacts (`lib/**/*.js`) instead of source**: that is Node's own view, but the gate would then depend on `pnpm run build`, and it still cannot judge a browser-library's `lib/index.js` (node platform, browser content), so the face test stays either way. +- **Asking the checker whether a binding is used in a value position** (what `verify-optional-dependency-imports` does): tried, and it also judged 83 node-face type-only declarations movable — no download saved for a real loss of meaning, 53 of them `dsh-invariants`. This gate needs to know whether a reference exists, not whether it is a value. +- **Also clearing our own six browser-library packages from the install face**, on the test that no install loads one: another 0.20 MB, at the price of deleting 74 workspace declarations that code genuinely names. Ruled out (2026-08-14): keep what the code names. The cleaner end state is to stop publishing those six packages, which is its own proposal. +- **`peerDependenciesMeta.optional` instead of `devDependencies`**: npm does skip an optional peer, but the meaning is "a consumer may supply this", and there is no run-time consumer at all. The repository must install it to build, which is what `devDependencies` says. +- **Leaving it to knip**: out of scope for knip, which reports a declared package nothing imports. These specifiers are imported; a bundler inlines them. The evidence is that they persisted on master with knip green. Only the five `nothing` entries overlap. +- **`optionalDependencies`**: wrong meaning — it says "skip this if it cannot be installed". + +## Acceptance criteria + +- `pnpm run hygiene` includes `verify-client-runtime-deps` and passes; a counterexample spec proves one `dependencies.react` is rejected. +- `pnpm run build`, `pnpm run test:gui`, and `DSH_SNAPSHOT=replay pnpm run test:web` pass — the move changes no build input, so artifacts stay byte-identical. +- A real install after the next release no longer downloads the 103 tarballs above. + +## Risks + +- **The six browser-library packages that stay installed carry bare imports nothing resolves**: `ui-primitives/lib/index.js` is a rolldown artifact and still reads `from "anser"`, while anser is now dev-only. It is inert — only our Vite build reads that file, and no loader exists for it on a user's machine (verified: only browser code imports those packages, never the host). Retiring their publication is the way to erase it; see Alternatives. +- **`@types/*` go unreported**: source never names them, so the rule cannot see them. `@types/mdast` was caught only because nothing referenced it either. They belong in dev regardless, and closing that gap is follow-up work. +- **A skipped package is unprotected**: `dsh-goal` is skipped whole for its generated entry, so its browser-side declarations are now nobody's business. Reading a generated artifact's own run-time imports is what would let the exemption be withdrawn. +- **A false report would delete a declaration something needs at run time**: three defenses hold that line — literal arguments to `require`, `require.resolve`, and dynamic `import()` count as references; a package's own `cordis*.yml` counts as host face; and no `@deepseek-ai/*` name is subject at all. diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md new file mode 100644 index 0000000000..5caed34222 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md @@ -0,0 +1,100 @@ +# Agent Note: 客户端构建期依赖不进安装面 + +Status: proposed + +[English](2026-08-14-client-build-time-deps.md) | 中文 + +## Problem + +浏览器产物不在用户机上解析任何 specifier: + +- `ui-*` 插件包的浏览器产物是 `lib/client.js`,tsdown 把每个非平台 specifier 直接内联(`packages/client/tsdown.client.ts` 的 `noExternal`)。留下来的 specifier 由 loader 的冻结模块表应答——那个 bundle 里的 `require` 是 loader 注入的形参,不是 Node 的。 +- 平台模块(`PLATFORM_MODULES`)由 shell `dist` 提供,不走 Node 解析。 +- shell 自身的 import 由 Vite 内联进 `@deepseek-ai/dsh-web-frontend` 已发布的 `dist`;该包只发 `dist`,连 `.` 导出都没有。 + +所以浏览器的每条代码路径都是构建产物,或作为静态资源下发,或烤进 `dist`。但这些产物的构建输入——react、react-dom、shiki、katex、clsx、micromark 与 mdast 全族——现在写在 `dependencies` 和非 optional `peerDependencies` 里,而 npm 对每个消费者都会安装这两个区段。全仓 38 个包共 79 处这样的外部依赖声明,装给了永远不会加载它们的用户。 + +## Proposal + +### 规则 + +**只被浏览器产物触及的外部包落 `devDependencies`。** 两条留白同样是规则的一部分: + +- **只管外部依赖**。`@deepseek-ai/*` 一律留在原处:那些声明还表达「谁提供我注入的服务」「这个 assembly 挂载了谁的 Remote」「哪个 Loader 行必须能解析」,[verify-runtime-closure](../../../../scripts/verify-runtime-closure.ts) 与 Loader 都读它,而 app 无论如何都会装那个包——移走只是删掉语义,并没有减少下载。 +- **node 面触及的一律不动**,包括被擦除的类型引用。 + +face 从 manifest 真正发布的入口走图,不用目录规则,所以 `src/` 下只被浏览器入口触及的模块就算浏览器代码: + +| kind | 判据 | host face 入口 | +| --- | --- | --- | +| `bundle-half` | 有 `./client` 导出 | 除 `./client` 外的每个导出目标 | +| `browser-library` | `packages/client/` 下且无 `./client` 导出 | 只有 `src/invariant.ts`——宿主唯一能挂载的伴生模块;`.` 面是浏览器代码 | +| `prebuilt-dist` | 无 `.` 导出、发布 `dist` | 没有:这个包不给 Node 提供任何入口 | + +### 门禁:`scripts/verify-client-runtime-deps.ts` + +接入 `pnpm run hygiene`,约 35 秒——两个绑定 Program 的开销,与同 lane 的 `verify-optional-dependency-imports` 同量级。复用仓内既有工具而不自造一套:`TypeScriptProject`(`scripts/ts-project.ts`)分别绑定 host 与 client 两个编译面(该文件写明两者不能合进一个 program——cordis Context merge 会撞),相对 specifier 交给 `ts.resolveModuleName` 解析,遍历到包边界即停。 + +判据要害有三条,都是起手那版扫字符串字面量踩出来的: + +1. 包名必须按名匹配:`'@deepseek-ai/dsh-client-web-react'` 里的 `react` 子串会静默吞掉 react。 +2. `./client` 是不是 tsdown 浏览器 bundle,看的是**产物路径**(`./lib/client.js`)而不是子路径名——`dsh-goal` 的 `./client` 是 `./lib/types/client.js`,一个 tsc 直出的浏览器共享模块。 +3. `require`、`require.resolve`、动态 `import()` 的字面量实参都能触及一个包;`require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` 就是真实存在的宿主解析路径。 + +两类判定,逐条报告: + +| 类 | 数量 | 判据 | +| --- | --- | --- | +| `browser` | 74 | 只有浏览器产物触及 | +| `nothing` | 5 | 没有任何引用具名它:`client-runtime` 的 `react`(与它自己「零 React 引用」的分层红线矛盾)、`ui-settings` 与 `ui-theme` 的 peer `react`、`ui-trajectory` 的 peer `react-dom`、`ui-primitives` 的 `@types/mdast` | + +下面每条保守规则都对应一次实测到的误报或语义损失: + +- **node 面的类型引用保留声明。** `src/invariant.ts` 里的 `import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'` 运行期被擦除,但它声明了谁提供这个伴生插件要注册的服务——归 `verify-runtime-closure` 管的关系。 +- **发布了没有源码对应文件的 Node 入口的包整包跳过,并在输出里点名。** `dsh-goal` 的 `./typert -> ./lib/typert.host.js` 由 typert 生成器直出,自带 `import { z } from 'zod'`,没有任何源码陈述这件事。把这条判据修对,消掉了四处误报,其中包括 `api-gateway` 的 `typert-registry`。 +- 包自带的 `cordis*.yml` 算 host face:Loader 行是具名它的插件而不是 import 它。 +- `@deepseek-ai/cordis` 豁免——check-workspace-constraints 要求它在每个包里同时是 peer 和 dev。 + +`--json` 输出供批量改写与安装体积实测复用。 + +### 安装面少掉什么 + +对已发布 CLI 真装一遍实测,tarball 字节从独立 cache 读出:103 个外部 tarball 不再下载,合计 6.05 MB。 + +| 组 | 包数 | 省 | +| --- | --- | --- | +| 语法高亮与数学(shiki 族、oniguruma 族、katex) | 16 | 3.93 MB | +| react 与视图库(react、react-dom、scheduler、immer、zustand、`@tanstack/*`、clsx、use-sync-external-store) | 11 | 1.47 MB | +| markdown 与 ansi 管线及零碎(micromark、mdast、hast 全族、anser、若干 `@types/*`) | 76 | 0.65 MB | + +我们自己的 6 个浏览器库包(ui-primitives、ui-slots、web-react、ui-attachment、schema-form、client-web,合计 0.20 MB)仍留在安装面:代码确实具名它们,上面的规则不动那些声明。 + +### 分刀落地 + +1. **文档住顶刀**:`packages/client/AGENTS.md` 的依赖声明节,加新插件包 checklist 里的一句。 +2. **门禁**:`scripts/verify-client-runtime-deps.ts`、它的 `package.json` 脚本、它在 `hygiene` 里的位置,以及一条反例 spec。 +3. **manifest**:38 个包 79 处。其中 50 处需要新增 `devDependencies` 条目,其余包已有同名条目,改动就是删掉一行。 +4. **发版后按同一方法复测**,确认这 103 个 tarball 没有回来。 + +## Alternatives considered + +- **扫字符串字面量**:起手就是这么实现的,被上面三条要害否掉——react-in-web-react 的子串已经造成过一次静默漏报。 +- **读构建产物(`lib/**/*.js`)而不是源码**:那是 Node 自己的视角,但门禁从此依赖 `pnpm run build`,而且它照样判不了浏览器库包的 `lib/index.js`(platform 是 node、内容是浏览器代码),face 判据两种走法都得有。 +- **用 checker 判绑定是否用在值位置**(`verify-optional-dependency-imports` 就是这么做的):试过,它把 83 处 node 面纯类型声明也判成可移出——没省下任何下载,却实打实损失语义,其中 53 处是 `dsh-invariants`。本门禁要知道的是引用是否存在,而不是它是值还是类型。 +- **顺带把我们自己的 6 个浏览器库包也清出安装面**,判据是任何安装都不加载它们:再省 0.20 MB,代价是删掉 74 处代码确实具名的 workspace 声明。已否(2026-08-14):代码用到的就保留。更干净的终态是这 6 个包不再发布,那是另一个提案。 +- **用 `peerDependenciesMeta.optional` 而不是 `devDependencies`**:npm 确实会跳过 optional peer,但那个语义是「消费者可以自行提供」,而这里根本没有运行期消费者。仓内必须装一份才能构建,这正是 `devDependencies` 的意思。 +- **交给 knip**:不属于 knip 的范畴,它报的是「声明了但没人 import」。这些 specifier 确实被 import,只是被打包器内联了。实证就是它们在 master 上长期存在而 knip 全绿。只有 `nothing` 那 5 条与它重叠。 +- **用 `optionalDependencies`**:语义错,它说的是「装不上就跳过」。 + +## Acceptance criteria + +- `pnpm run hygiene` 包含 `verify-client-runtime-deps` 并通过;一条反例 spec 证明一处 `dependencies.react` 会被拒。 +- `pnpm run build`、`pnpm run test:gui`、`DSH_SNAPSHOT=replay pnpm run test:web` 通过——这次迁移不改任何构建输入,产物应逐字节等价。 +- 发版后真装一遍,上面那 103 个 tarball 不再被下载。 + +## Risks + +- **留在安装面的 6 个浏览器库包会带着解析不了的 bare import**:`ui-primitives/lib/index.js` 是 rolldown 产物,仍写着 `from "anser"`,而 anser 已经只在 dev。它是惰性的——只有我们的 Vite 构建会读这个文件,用户机上没有任何加载者(已实证:只有浏览器代码 import 它们,宿主从不)。要彻底消掉就让这些包不再发布,见 Alternatives。 +- **`@types/*` 报不出来**:源码从不具名它们,规则看不见。`@types/mdast` 被抓到只是因为恰好也没有任何引用。它们本来就该在 dev,补这个缺口是后续的事。 +- **被跳过的包没人管**:`dsh-goal` 因生成入口整包跳过,它浏览器侧的声明现在没有门禁看着。能读到生成产物自身的运行期 import,这条豁免才能收回。 +- **误报会删掉运行期真需要的声明**:三层兜底守住这条线——`require`、`require.resolve`、动态 `import()` 的字面量实参都算引用;包自带的 `cordis*.yml` 算 host face;`@deepseek-ai/*` 整体不在判据范围内。 diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 1928f30a3c..14965fd495 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -35,6 +35,15 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile. 3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. +## Dependency declaration + +A browser artifact resolves nothing on the user's machine: tsdown inlines every non-platform specifier into `lib/client.js`, the shell `dist` answers `PLATFORM_MODULES` from its frozen module table, and Vite inlines the shell's own imports into the published `dist`. + +- **An external package only browser code reaches belongs in `devDependencies`.** npm installs `dependencies` and non-optional `peerDependencies` for every consumer, so react, shiki, katex, or clsx declared there ships to users who never load it. +- **A workspace name stays where it is.** Such a declaration also states which package supplies an injected service or a mounted Remote contribution — [verify-runtime-closure](../../scripts/verify-runtime-closure.ts) and the Loader read it, and the app installs the package regardless. +- **The node half decides.** Anything its published entries reach — an erased type import, a `require.resolve`, and a Loader row in the package's own `cordis*.yml` included — stays declared as it is. +- `pnpm run verify-client-runtime-deps` (inside `hygiene`) names each offending entry; knip owns whether the surviving declaration is used at all. + ## ctx discipline (components never see ctx) `ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook. @@ -91,7 +100,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list, and every browser-side external package under `devDependencies` per the [declaration rules](#dependency-declaration)), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. From 6e77499326e225d53d856a793ce0cb8e27879fde Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:18:35 +0800 Subject: [PATCH 072/146] feat(scripts): gate browser-only dependencies out of the install face verify-client-runtime-deps walks each package's host and browser faces from the entries its manifest publishes, over the bound host and client Programs, and reports an external package no host-face reference reaches. Wired into hygiene. A workspace name is out of scope: it also states which package supplies an injected service or a mounted Remote contribution, and the app installs it either way. A package whose published Node entry has no source counterpart is skipped and named, because a generated artifact carries imports no source states. --- package.json | 3 +- scripts/verify-client-runtime-deps.ts | 368 ++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-client-runtime-deps.ts diff --git a/package.json b/package.json index 517d0c56d1..6f2346e2e6 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", + "verify-client-runtime-deps": "tsx scripts/verify-client-runtime-deps.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", @@ -126,7 +127,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-runtime-deps && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", diff --git a/scripts/verify-client-runtime-deps.ts b/scripts/verify-client-runtime-deps.ts new file mode 100644 index 0000000000..0d42f8c135 --- /dev/null +++ b/scripts/verify-client-runtime-deps.ts @@ -0,0 +1,368 @@ +/** + * Keep browser-only external packages out of installed dependency sections. + * + * A browser artifact resolves nothing on the user's machine: tsdown inlines + * every non-platform specifier into `lib/client.js`, the shell `dist` answers + * `PLATFORM_MODULES` from its frozen module table, and Vite inlines the shell's + * own imports into that published `dist`. A specifier only browser source + * reaches is therefore a build-time input and belongs in `devDependencies`, + * because npm installs `dependencies` and non-optional `peerDependencies` for + * every consumer of the published package. + * + * Each face is walked from the entries the manifest publishes, not by a + * directory rule, so a module under `src/` that only the browser entry reaches + * counts as browser source: + * + * `./client` is `lib/client.js` host: the other export targets; browser: the bundle + * `packages/client/*` with no browser-only library: host is `src/invariant.ts`, + * `./client` export the companion the host mounts; `.` is browser code + * no `.` export, ships a `dist` prebuilt browser bundle: no host face at all + * + * Only external packages are subject: they are what an install downloads. A + * workspace name stays where its manifest puts it, because that declaration also + * states which package supplies an injected service or a mounted Remote + * contribution, and the app installs it either way. A reference from the host + * face, an erased type import included, likewise keeps a declaration in place. + * + * Run: pnpm exec tsx scripts/verify-client-runtime-deps.ts [--json] + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' +import { TypeScriptProject, type CompilerFace } from './ts-project.ts' + +const root = resolve(import.meta.dirname, '..') + +/** + * `@deepseek-ai/cordis` placement belongs to check-workspace-constraints, which + * requires it as a peerDependency plus devDependency of every harness package + * regardless of face. + */ +const PLACEMENT_OWNED_ELSEWHERE = new Set(['@deepseek-ai/cordis']) + +/** Dependency sections npm installs for a consumer of the published package. */ +const INSTALLED_SECTIONS = ['dependencies', 'peerDependencies'] as const + +type Section = (typeof INSTALLED_SECTIONS)[number] + +interface Manifest { + name?: string + files?: string[] + exports?: Record + dependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +/** How a package reaches the browser, which fixes the entries Node can load. */ +type Kind = 'bundle-half' | 'browser-library' | 'prebuilt-dist' + +/** What settles an external specifier as build-time only. */ +type Reached = 'browser' | 'nothing' + +interface Violation { + readonly section: Section + readonly dep: string + readonly reached: Reached + /** Whether the browser face names it, which decides dev-move versus deletion. */ + readonly browserReferenced: boolean +} + +interface Offender { + readonly name: string + readonly dir: string + readonly kind: Kind + readonly violations: Violation[] +} + +/** Why each class needs no install, for the failure report. */ +const REASON: Record = { + browser: 'only a browser artifact reaches it, and that resolves nothing on the user machine', + nothing: 'no reference names it at all', +} + +/** + * Classify a package by the browser artifact it produces. + * @param dir - repository-relative package directory. + * @param manifest - the package manifest. + * @returns the package kind, or undefined when the package has no browser face. + */ +function kindOf(dir: string, manifest: Manifest): Kind | undefined { + if (manifest.exports?.['./client'] !== undefined) return 'bundle-half' + if (dir.startsWith('packages/client/')) return 'browser-library' + const shipsDist = (manifest.files ?? []).some(entry => entry === 'dist' || entry.startsWith('dist/')) + if (shipsDist && manifest.exports?.['.'] === undefined) return 'prebuilt-dist' + return undefined +} + +/** The bare package name a specifier names, keeping a leading scope. */ +function packageOf(specifier: string): string { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier +} + +/** One compiler face's bound program plus its module resolution state. */ +interface Face { + readonly project: TypeScriptProject + readonly host: ts.CompilerHost + readonly cache: ts.ModuleResolutionCache +} + +const faces = new Map() +for (const face of ['host', 'client'] as const) { + const project = new TypeScriptProject(root, face) + const options = project.program.getCompilerOptions() + faces.set(face, { + project, + host: ts.createCompilerHost(options, false), + cache: ts.createModuleResolutionCache(root, fileName => fileName, options), + }) +} + +/** Which face's program bound each workspace module, keyed by absolute path. */ +const boundIn = new Map() +for (const [face, { project }] of faces) { + for (const sourceFile of project.sourceFiles()) { + if (sourceFile.isDeclarationFile) continue + if (!boundIn.has(sourceFile.fileName)) boundIn.set(sourceFile.fileName, face) + } +} + +/** + * Read every module specifier one source file names. + * + * An import clause is not the only way to reach a package: `require`, + * `require.resolve`, and a dynamic `import()` on a literal each name one, and a + * type-only import still names a package the build must resolve. + * @param sourceFile - a bound source file. + * @returns every specifier, relative ones included. + */ +function specifiersOf(sourceFile: ts.SourceFile): string[] { + const specifiers: string[] = [] + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isImportEqualsDeclaration(node)) { + const specifier = ts.isImportEqualsDeclaration(node) + ? (ts.isExternalModuleReference(node.moduleReference) ? node.moduleReference.expression : undefined) + : node.moduleSpecifier + if (specifier !== undefined && ts.isStringLiteralLike(specifier)) specifiers.push(specifier.text) + } else if (ts.isCallExpression(node)) { + const target = node.expression + const isRequire = ts.isIdentifier(target) && target.text === 'require' + const isRequireResolve = ts.isPropertyAccessExpression(target) + && ts.isIdentifier(target.expression) && target.expression.text === 'require' + && target.name.text === 'resolve' + const argument = node.arguments[0] + if ((isRequire || isRequireResolve || target.kind === ts.SyntaxKind.ImportKeyword) + && argument !== undefined && ts.isStringLiteralLike(argument)) { + specifiers.push(argument.text) + } + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return specifiers +} + +/** + * Walk one face from its entries and collect the packages it names. + * @param entries - absolute entry module paths. + * @param packageDir - absolute package directory; the walk stops at its edge. + * @returns package names the walk reaches. + */ +function walk(entries: readonly string[], packageDir: string): Set { + const found = new Set() + const seen = new Set() + const queue = entries.filter(entry => boundIn.has(entry)) + while (queue.length > 0) { + const file = queue.pop() + if (file === undefined || seen.has(file)) continue + seen.add(file) + const faceName = boundIn.get(file) + const face = faceName === undefined ? undefined : faces.get(faceName) + const sourceFile = face?.project.program.getSourceFile(file) + if (face === undefined || sourceFile === undefined) continue + + for (const specifier of specifiersOf(sourceFile)) { + if (!specifier.startsWith('.')) { + if (!specifier.startsWith('node:')) found.add(packageOf(specifier)) + continue + } + const resolved = ts.resolveModuleName( + specifier, file, face.project.program.getCompilerOptions(), face.host, face.cache, + ).resolvedModule?.resolvedFileName + // A relative specifier resolving outside the package is a packaging error + // verify-package-paths owns; either way it is not this package's own module. + if (resolved !== undefined && resolved.startsWith(`${packageDir}/`)) queue.push(resolved) + } + } + return found +} + +/** + * The source module behind one published JavaScript export target. + * + * `lib/` holds the tsdown bundles and `lib/types/` the tsc emit, so both + * prefixes lead back to one `src` module. + * @param dir - absolute package directory. + * @param emitted - the export target, as written in the manifest. + * @returns the absolute source path, or undefined when nothing in `src` emits it. + */ +function sourceBehind(dir: string, emitted: string): string | undefined { + const stem = emitted.replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '') + return [`src/${stem}.ts`, `src/${stem}.tsx`, `src/${stem}/index.ts`, `src/${stem}/index.tsx`] + .map(candidate => join(dir, candidate)) + .find(candidate => existsSync(candidate)) +} + +interface Entries { + readonly host: string[] + readonly browser: string[] + /** + * Published JavaScript entries no `src` module emits — a generated artifact + * such as `lib/typert.host.js`, whose own runtime imports are invisible here. + */ + readonly generated: string[] +} + +/** + * The entry modules of each face, derived from what the manifest publishes. + * @param dir - absolute package directory. + * @param manifest - the package manifest. + * @param kind - the package kind. + * @returns absolute entry module paths per face, plus unmapped published entries. + */ +function faceEntries(dir: string, manifest: Manifest, kind: Kind): Entries { + if (kind === 'prebuilt-dist') return { host: [], browser: [], generated: [] } + if (kind === 'browser-library') { + return { host: [join(dir, 'src/invariant.ts')], browser: [join(dir, 'src/index.ts')], generated: [] } + } + const host = [join(dir, 'src/index.ts'), join(dir, 'src/invariant.ts')] + const browser: string[] = [] + const generated: string[] = [] + for (const [key, target] of Object.entries(manifest.exports ?? {})) { + if (key === '.' || key === './package.json' || key.includes('*')) continue + const emitted = typeof target === 'string' ? target : (target as { default?: unknown }).default + if (typeof emitted !== 'string' || !emitted.endsWith('.js')) continue + // Keyed on the artifact path, not the subpath name: `./client` is the tsdown + // browser bundle only when it resolves to lib/client.js, while other packages + // publish a plain browser-shared module under the same subpath. + const source = emitted === './lib/client.js' + ? sourceBehind(dir, './lib/client/index.js') + : sourceBehind(dir, emitted) + if (source === undefined) generated.push(`${key} -> ${emitted}`) + else if (key === './client') browser.push(source) + else host.push(source) + } + return { host, browser, generated } +} + +/** Every installed dependency of a manifest, paired with its section. */ +function installedDeps(manifest: Manifest): { section: Section; dep: string }[] { + const deps: { section: Section; dep: string }[] = [] + for (const section of INSTALLED_SECTIONS) { + for (const dep of Object.keys(manifest[section] ?? {})) { + if (section === 'peerDependencies' && manifest.peerDependenciesMeta?.[dep]?.optional === true) continue + if (PLACEMENT_OWNED_ELSEWHERE.has(dep)) continue + deps.push({ section, dep }) + } + } + return deps +} + +/** + * Test whether a Loader config names a package as a whole word. + * @param text - raw config text. + * @param dep - package name to look for. + * @returns true when the name appears outside a longer specifier. + */ +function namesPackage(text: string, dep: string): boolean { + const escaped = dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`(^|[^\\w@/.-])${escaped}(?![\\w.-])`).test(text) +} + +interface Candidate { + readonly name: string + readonly relativeDir: string + readonly manifest: Manifest + readonly kind: Kind +} + +const candidates: Candidate[] = [] +for (const path of [ + ...globSync('packages/*/*/package.json', { cwd: root }), + ...globSync('apps/*/package.json', { cwd: root }), +].sort()) { + const relativeDir = dirname(path) + const manifest = JSON.parse(readFileSync(join(root, path), 'utf8')) as Manifest + if (manifest.name === undefined) continue + const kind = kindOf(relativeDir, manifest) + if (kind !== undefined) candidates.push({ name: manifest.name, relativeDir, manifest, kind }) +} + +const offenders: Offender[] = [] +const unchecked: string[] = [] +for (const { name, relativeDir, manifest, kind } of candidates) { + const dir = join(root, relativeDir) + const entries = faceEntries(dir, manifest, kind) + // A generated Node entry carries runtime imports of its own that no source + // states, so this package's declarations cannot be judged from `src` alone. + if (entries.generated.length > 0) { + unchecked.push(`${name}: generated entry ${entries.generated.join(', ')}`) + continue + } + const host = walk(entries.host, dir) + const browser = walk(entries.browser, dir) + // A Loader row names its plugin package instead of importing it, so a config + // the package owns is part of its host face. YAML keys carry no quotes, so + // these are matched as whole names against the raw text. + const configs = globSync('cordis*.yml', { cwd: dir }).map(config => readFileSync(join(dir, config), 'utf8')) + + const violations = installedDeps(manifest) + // A workspace name stays where the manifest puts it. Such a declaration also + // states which package supplies an injected service, which Remote contribution + // an assembly mounts, or which Loader row must resolve; the app installs the + // package regardless, so moving one saves no download while deleting what + // verify-runtime-closure and the Loader read. External packages are the + // download, and this gate is about the download. + .filter(({ dep }) => !dep.startsWith('@deepseek-ai/')) + .filter(({ dep }) => !host.has(dep) && !configs.some(text => namesPackage(text, dep))) + .map(({ section, dep }) => ({ + section, + dep, + browserReferenced: browser.has(dep), + // A prebuilt bundle publishes no Node entry, so everything it declares is + // build-time by construction, named in its Vite graph rather than in src. + reached: kind === 'prebuilt-dist' || browser.has(dep) ? 'browser' as const : 'nothing' as const, + })) + if (violations.length > 0) offenders.push({ name, dir: relativeDir, kind, violations }) +} + +if (process.argv.includes('--json')) { + console.log(JSON.stringify(offenders, null, 2)) + process.exit(0) +} + +if (unchecked.length > 0) { + console.log(`verify-client-runtime-deps: ${String(unchecked.length)} package(s) not checked, no source states their entry's imports:`) + for (const entry of unchecked) console.log(` ${entry}`) +} + +if (offenders.length > 0) { + const all = offenders.flatMap(offender => offender.violations) + console.error(`verify-client-runtime-deps: ${String(all.length)} build-time specifier(s) in installed sections:`) + for (const { name, dir, kind, violations } of offenders) { + console.error(` ${name} (${dir}, ${kind})`) + for (const { section, dep, reached } of violations) { + console.error(` ${section}.${dep} -> devDependencies [${reached}]`) + } + } + console.error('') + for (const reached of ['browser', 'nothing'] as const) { + const count = all.filter(violation => violation.reached === reached).length + if (count > 0) console.error(` ${String(count).padStart(4)} ${reached}: ${REASON[reached]}`) + } + console.error('\nDeclaration rules: packages/client/AGENTS.md.') + process.exit(1) +} +console.log(`verify-client-runtime-deps: browser-face specifiers are dev-only across ${String(candidates.length)} browser-facing packages.`) From 93a95e838da235c1c519ee8c6bf8f5ac1778394e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:18:39 +0800 Subject: [PATCH 073/146] fix(client): declare browser-only externals as devDependencies react, react-dom, shiki, katex, clsx, the micromark and mdast families and nine more reach only browser artifacts, which resolve nothing on a user's machine. Moving 79 declarations out of dependencies and non-optional peerDependencies drops 103 tarballs and 6.05 MB from an install of the published CLI. --- apps/web/package.json | 8 +- packages/client/locale/package.json | 3 +- packages/client/runtime/package.json | 10 +- packages/client/ui-agent-preset/package.json | 3 +- packages/client/ui-attachment/package.json | 10 +- packages/client/ui-commands/package.json | 9 +- packages/client/ui-conversation/package.json | 7 +- packages/client/ui-deliverables/package.json | 6 +- .../ui-directory-picker-browse/package.json | 9 +- .../ui-directory-picker-native/package.json | 3 +- packages/client/ui-goal/package.json | 3 +- packages/client/ui-input-trigger/package.json | 9 +- packages/client/ui-jobs/package.json | 6 +- packages/client/ui-layout/package.json | 3 +- .../client/ui-message-feedback/package.json | 3 +- .../client/ui-model-selection/package.json | 4 +- .../client/ui-permission-presets/package.json | 3 +- packages/client/ui-plan/package.json | 3 +- packages/client/ui-primitives/package.json | 12 +- .../client/ui-settings-general/package.json | 9 +- .../client/ui-settings-models/package.json | 3 +- .../ui-settings-plugin-inventory/package.json | 3 +- .../client/ui-settings-plugins/package.json | 9 +- packages/client/ui-settings/package.json | 3 +- packages/client/ui-sidebar/package.json | 9 +- packages/client/ui-skill/package.json | 3 +- packages/client/ui-subagent/package.json | 6 +- packages/client/ui-theme/package.json | 5 +- packages/client/ui-tool/package.json | 7 +- packages/client/ui-trajectory/package.json | 12 +- .../client/ui-user-questions/package.json | 8 +- packages/client/ui-workflow-run/package.json | 6 +- packages/client/ui-workspace/package.json | 9 +- packages/client/web-react/package.json | 8 +- packages/client/web/package.json | 8 +- .../cordis-client-runner/package.json | 3 +- packages/extensions/ui-cordis/package.json | 3 +- .../session-log-export/package.json | 34 ++- pnpm-lock.yaml | 215 ++++++++---------- 39 files changed, 214 insertions(+), 263 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index fc990f684c..5e6dc8152b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,9 +26,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-client-web": "workspace:^", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@deepseek-ai/dsh-client-web": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", @@ -46,6 +44,8 @@ "typescript": "^6.0.3", "vite": "^6.0.0", "vitest": "^4.1.8", - "fflate": "^0.8.2" + "fflate": "^0.8.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" } } diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 0184f72d18..3f4f408854 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -50,8 +50,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 1da3ac6428..cc610a925d 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -53,10 +53,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "immer": "^10.1.1", - "react": "^18.2.0", - "zustand": "~4.4.7" + "@deepseek-ai/dsh-tools": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -72,7 +69,10 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@types/react": "~18.3.1" + "@types/react": "~18.3.1", + "immer": "^10.1.1", + "react": "^18.2.0", + "zustand": "~4.4.7" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index f705158d1e..b2c447b17e 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -58,8 +58,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index c257166e2a..4d40c870a7 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -28,16 +28,16 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0" + "@types/react-dom": "~18.3.0", + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index cf80e20f3f..a0445a32e2 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -46,9 +46,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -59,8 +56,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -76,7 +72,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 2fc12605c1..096c5280af 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,7 +49,6 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { @@ -72,8 +71,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-tools": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -104,7 +102,8 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ac7f264f62..da627c207b 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -45,9 +45,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "react": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -68,7 +65,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 0cc14700fc..028a46e290 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -44,9 +44,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -54,8 +51,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -69,7 +65,8 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 74b8e7845b..5fe8d80cc7 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -48,8 +48,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 6076aab741..56b9a23472 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -55,8 +55,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index a4ca2afe84..f933031f82 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -43,17 +43,13 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -64,7 +60,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index a59d064dda..0855cc486d 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -45,9 +45,6 @@ "publishConfig": { "access": "public" }, - "dependencies": { - "react": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -66,7 +63,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index d3ab329297..0753bd9367 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -48,8 +48,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 481d02aa03..3d2480b05e 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -56,8 +56,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 298fd8b133..dd74f2c764 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -56,9 +56,7 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "clsx": "^2.1.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 62f03cbd07..159700431b 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -60,8 +60,7 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-permission-presets": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 233a8a72fe..58b8d4430a 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -53,8 +53,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index b6e01b4f5a..5bb934f474 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -26,7 +26,11 @@ "./package.json": "./package.json" }, "license": "MIT", - "dependencies": { + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "@deepseek-ai/cordis": "workspace:^", "@shikijs/langs": "^4.3.1", "@types/mdast": "^4.0.4", "anser": "^2.3.5", @@ -48,12 +52,6 @@ "react-dom": "^18.2.0", "shiki": "^4.3.1" }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^" - }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index c5207dfc9d..3727a03ee9 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -49,8 +49,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", - "clsx": "^2.0.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -63,8 +62,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -80,7 +78,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 423755475c..b6dc52fc4d 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -54,8 +54,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 95af8a15fa..8d04e40581 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -53,8 +53,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index a9fd7b8e9d..936d2df19e 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -56,8 +56,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -72,6 +71,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", + "clsx": "^2.0.0", "react": "^18.2.0" }, "files": [ @@ -79,8 +79,5 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" - ], - "dependencies": { - "clsx": "^2.0.0" - } + ] } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 485092d2b9..46e269a3a3 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -52,8 +52,7 @@ "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-settings": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index cfda6d55fc..bad27010aa 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -44,17 +44,13 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -66,7 +62,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index b2026095f2..b979880e83 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -56,8 +56,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 0e0b7aadae..327fc66442 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -46,9 +46,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "react": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -73,7 +70,8 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 6f320cd92f..efea7f3e47 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -54,8 +54,7 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -69,6 +68,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", + "clsx": "^2.0.0", "react": "^18.2.0" }, "files": [ @@ -84,7 +84,6 @@ }, "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 80bc4f2586..05e5f7fd28 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -44,9 +44,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -55,8 +52,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -72,6 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 375053ba7c..bd90aba857 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -44,10 +44,6 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "@tanstack/react-virtual": "^3.14.9", - "diff": "^9.0.0" - }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -56,9 +52,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@deepseek-ai/dsh-tools": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -75,7 +69,9 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@tanstack/react-virtual": "^3.14.9", + "diff": "^9.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 95fb02b0f3..ddd64ed2cb 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -48,9 +48,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "clsx": "^2.0.0", - "react": "^18.2.0" + "@deepseek-ai/dsh-client-ui-slots": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -67,7 +65,9 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", - "@types/react": "~18.3.1" + "@types/react": "~18.3.1", + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index e5b71cf942..62e8dcc803 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -50,9 +50,6 @@ "lib/types/**/*.d.ts" ], "license": "MIT", - "dependencies": { - "react": "^18.2.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -77,6 +74,7 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" } } diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index b6a711b873..cd85838b3b 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -45,17 +45,13 @@ "watch": "tsdown --watch" }, "license": "MIT", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -68,7 +64,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "clsx": "^2.0.0" }, "files": [ "lib/index.js", diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 75bf6877ad..e884bc9fa1 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -27,9 +27,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "react": "^18.2.0", - "use-sync-external-store": "1.2.0" + "@deepseek-ai/dsh-client-ui-slots": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -38,7 +36,9 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "use-sync-external-store": "1.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 9e4e9481e0..d128b3219d 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -33,9 +33,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-client-web-react": "workspace:^", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@deepseek-ai/dsh-client-web-react": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -45,7 +43,9 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 74f7970bbc..ecc0bca76d 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -54,8 +54,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index cacd075a58..7bcb487bf1 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -61,8 +61,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index b84dede798..1ff514d0ae 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -2,7 +2,9 @@ "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", "version": "0.1.0-rc.6", - "publishConfig": { "access": "public" }, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", @@ -12,14 +14,31 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { - ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, - "files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"], - "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -30,8 +49,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1490a0f9f7..f7974b33ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,12 +358,6 @@ importers: '@deepseek-ai/dsh-client-web': specifier: workspace:^ version: link:../../packages/client/web - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ @@ -404,6 +398,12 @@ importers: playwright: specifier: ^1.49.0 version: 1.61.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1607,15 +1607,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - immer: - specifier: ^10.1.1 - version: 10.2.0 - react: - specifier: ^18.2.0 - version: 18.3.1 - zustand: - specifier: ~4.4.7 - version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1638,6 +1629,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + immer: + specifier: ^10.1.1 + version: 10.2.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + zustand: + specifier: ~4.4.7 + version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) packages/client/schema-form: dependencies: @@ -1705,15 +1705,6 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives - clsx: - specifier: ^2.0.0 - version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1727,12 +1718,17 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - - packages/client/ui-commands: - dependencies: clsx: specifier: ^2.0.0 version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-commands: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1773,6 +1769,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -1785,9 +1784,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1873,15 +1869,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-deliverables: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1913,12 +1908,11 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-directory-picker-browse: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1950,6 +1944,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2036,10 +2033,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-input-trigger: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2065,15 +2058,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-jobs: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2102,6 +2094,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-layout: devDependencies: @@ -2326,13 +2321,25 @@ importers: version: 18.3.1 packages/client/ui-primitives: - dependencies: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) anser: specifier: ^2.3.5 version: 2.3.5 @@ -2387,19 +2394,6 @@ importers: shiki: specifier: ^4.3.1 version: 4.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@18.3.31) packages/client/ui-settings: dependencies: @@ -2446,9 +2440,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2489,6 +2480,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2581,10 +2575,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-settings-plugins: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2622,15 +2612,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-sidebar: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2659,6 +2648,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2724,10 +2716,6 @@ importers: version: 18.3.31 packages/client/ui-subagent: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2765,6 +2753,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-theme: dependencies: @@ -2777,9 +2768,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2814,15 +2802,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-tool: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2863,6 +2850,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2871,13 +2861,6 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-trajectory: - dependencies: - '@tanstack/react-virtual': - specifier: ^3.14.9 - version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - diff: - specifier: ^9.0.0 - version: 9.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2912,12 +2895,18 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + diff: + specifier: ^9.0.0 + version: 9.0.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -2942,12 +2931,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - clsx: - specifier: ^2.0.0 - version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2976,12 +2959,14 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - - packages/client/ui-workflow-run: - dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 + + packages/client/ui-workflow-run: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3019,12 +3004,11 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-workspace: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3056,6 +3040,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + clsx: + specifier: ^2.0.0 + version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -3083,12 +3070,6 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../web-react - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3111,6 +3092,12 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3120,12 +3107,6 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots - react: - specifier: ^18.2.0 - version: 18.3.1 - use-sync-external-store: - specifier: 1.2.0 - version: 1.2.0(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3136,6 +3117,12 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + use-sync-external-store: + specifier: 1.2.0 + version: 1.2.0(react@18.3.1) packages/code-runtime/code-runtime: devDependencies: From 7d59006fd18c738007dd89b0e2e8b1caf7143151 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:24:23 +0800 Subject: [PATCH 074/146] docs(notes): record how the notices tier learns what ships A package a published browser artifact carries is a runtime disclosure whatever section declares it, and the build answers which ones those are. Record that clause, the dry run behind it, why a bundler's virtual module is not a shipped package, why a types-only package is not either, and the generator run cost the dry run adds. --- .../2026-07-30-generated-third-party-notices.i18n.yaml | 4 ++-- .../process/2026-07-30-generated-third-party-notices.md | 4 +++- .../process/2026-07-30-generated-third-party-notices.zh.md | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 821e829c52..dfbdf76bec 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 9ca24c9634b0410f9b7cc255902344cd6014bb90 -2026-07-30-generated-third-party-notices.zh.md: b49e60565bbae62cf17026db17babc1ba7a408dc +2026-07-30-generated-third-party-notices.md: 2128537b09f9d5d68cb1175b1ecb195fa50e1865 +2026-07-30-generated-third-party-notices.zh.md: 906f5ed5b9a11f3ddd68491686b460c5d9f4a682 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 9ca24c9634..2128537b09 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -26,6 +26,8 @@ The runtime tier deliberately covers **every mountable plugin**, not just what t The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. +**A package a published artifact carries is runtime whatever section declares it.** The browser side is built, not resolved: tsdown inlines every non-platform specifier into a plugin's `lib/client.js`, and Vite inlines the shell's imports into `@deepseek-ai/dsh-web-frontend`'s `dist`. Those packages are therefore declared as `devDependencies` — [the client build-time dependency note](../../proposed/process/2026-08-14-client-build-time-deps.md) owns that placement rule — while a copy of each is redistributed, so react, shiki, katex, and the markdown pipeline must stay in the runtime tier. [`scripts/browser-bundled-externals.ts`](../../../../scripts/browser-bundled-externals.ts) answers which ones those are from the build itself: it drives each client bundle through that package's own `tsdown.config.ts` and the shell through `apps/web`'s Vite config, with a recording plugin that resolves every bare specifier as external and notes it. The pass walks this repository's own source and stops at the package boundary, which costs about three seconds and yields exactly the direct-dependency granularity this file discloses. A specifier counts only once the host resolves it to a file inside a package, so a bundler's own virtual module is not mistaken for a shipped one: `vite/modulepreload-polyfill` is generated by a Vite plugin rather than published as a file, which makes the polyfill in the `dist` build glue of the same kind as an emitted TypeScript helper. Using the real configurations rather than a hand-kept list buys two properties no list has: an erased type import never appears, because the transform drops it before resolution, and a package stops being disclosed as shipped the moment its last browser import is gone. Workspace names are followed only on the Vite side, where the shell's aliases map them to source — that is how a browser-only library's own imports, `ui-primitives`' katex and shiki among them, become visible. A package that ships only type declarations is development-only however a shipped package names it: it contributes no redistributed code, and the dry run cannot see an erased type import anyway, because the transform drops it before resolution. `@types/mdast` and `micromark-util-types` moved to that tier for exactly this reason. + The project owner separately authorizes distribution of every official `@anthropic-ai/claude-agent-sdk` version and the official Claude Code CLI/platform payloads that version declares through `optionalDependencies`. The generator represents this as one exact direct-package identity exception, not as a permissive-license override: `SEE LICENSE IN README.md` and `SEE LICENSE IN LICENSE.md` remain non-permissive classifications, and every unrelated non-permissive runtime still fails closed. When the SDK is present, the generator reads its installed manifest, rejects optional identities outside the official SDK payload prefix, derives the current SDK, CLI, and payload versions, verifies the installed host payload's identity, version, and declared-license field, and renders the complete SDK-declared payload set in a separate notices section. Version, declared-license, and payload-set changes do not require new identity authorization, but they still require ordinary dependency, lockfile, compatibility, terms, and notices review. ## Testing @@ -52,7 +54,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa ## Consequences -A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. +A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run on commits that touch a manifest, and nothing on any other commit. It costs a few seconds rather than one, because learning what the browser artifacts carry drives the real client and shell bundlers; a broken client source therefore fails the hook, as the staged lint job already would. Committing with hooks disabled defers the cost to a test-lane failure that names the command. The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index b49e60565b..906f5ed5b9 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -26,6 +26,8 @@ Status: implemented manifest 集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布 manifest 答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列入运行时表格,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 +**被已发布产物带上的包,无论由哪个区段声明都算 runtime。** 浏览器侧是构建出来的,不是解析出来的:tsdown 把每个非平台 specifier 内联进插件的 `lib/client.js`,Vite 把 shell 的 import 内联进 `@deepseek-ai/dsh-web-frontend` 的 `dist`。因此这些包声明在 `devDependencies`——落位规则归 [客户端构建期依赖那篇](../../proposed/process/2026-08-14-client-build-time-deps.md)——但它们各自都有一份副本被分发出去,所以 react、shiki、katex 与整条 markdown 管线必须留在 runtime 档。到底是哪些包,由 [`scripts/browser-bundled-externals.ts`](../../../../scripts/browser-bundled-externals.ts) 从构建本身取答案:它用各包自己的 `tsdown.config.ts` 驱动每个 client bundle,用 `apps/web` 的 Vite 配置驱动 shell,挂一个记录用插件把每个 bare specifier 解析成 external 并记下来。这一趟只走本仓自己的源码、到包边界即停,约三秒,且给出的正是本文件披露的「直接依赖」粒度。一个 specifier 只有在宿主把它解析到某个包内的文件之后才被计入,所以打包器自己的虚拟模块不会被误当成随产物分发的包:`vite/modulepreload-polyfill` 由 Vite 插件生成而非作为文件发布,因此 `dist` 里那段 polyfill 与 TypeScript 生成的辅助代码同类,属于构建胶水。用真配置而不是一张人工名单,白拿两个手写名单没有的性质:被擦除的类型 import 永远不会出现,因为 transform 在解析前就删了它;某个包最后一处浏览器 import 消失时,它也立刻不再被披露成随产物分发。workspace 名字只在 Vite 那侧继续走,因为 shell 的 alias 会把它们映射到源码——浏览器库包自己的 import,比如 `ui-primitives` 的 katex 与 shiki,正是这样才可见的。 只发布类型声明的包一律算 development-only,无论哪个已发布包具名了它:它不贡献任何被分发的代码,而 dry-run 本来也看不见被擦除的类型 import——transform 在解析前就删了它。`@types/mdast` 与 `micromark-util-types` 正是因此落到该档。 + 项目所有者另行授权分发每个官方 `@anthropic-ai/claude-agent-sdk` 版本,以及该版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。生成器将其表示为一项精确匹配直接包身份的例外,而非宽松许可证覆盖项:`SEE LICENSE IN README.md` 与 `SEE LICENSE IN LICENSE.md` 仍归类为非宽松,所有无关的非宽松运行时依赖仍以默认拒绝方式失败。存在该 SDK 时,生成器会读取其已安装 manifest,拒绝不符合官方 SDK 载荷前缀的可选包身份,推导当前 SDK、CLI 与载荷版本,核验已安装宿主载荷的身份、版本和声明许可证字段,并在单独的声明章节中渲染 SDK 声明的完整载荷集合。版本、声明许可证和载荷集合发生变化时无需新的身份授权,但仍须经过常规的依赖、锁文件、兼容性、条款和声明评审。 ## 测试 @@ -52,7 +54,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 ## 后果 -此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 +此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行;其余提交不受影响。这次运行从约一秒变成几秒,因为要弄清浏览器产物带了什么就得驱动真实的 client 与 shell 打包器;因此客户端源码坏掉会让这个 hook 失败,而暂存区的 lint job 本来也会失败。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 From 1fae5dc40ec3cf7e559550a80ba88d5f029f87b0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:24:23 +0800 Subject: [PATCH 075/146] fix(scripts): disclose browser-bundled packages as shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving react, shiki, katex and the markdown pipeline to devDependencies took them out of the notices runtime tier, which tiers by declaring section — yet their code is inside lib/client.js and the shell dist. The generator now learns what the browser artifacts carry from the real build configs: each client bundle through its own tsdown config, the shell through apps/web's Vite config, with a recorder that resolves each bare specifier, notes the package behind it, and stops there. About three seconds, and only packages a resolved file backs, so a bundler's virtual module is not mistaken for a shipped one. Net effect on the file: the type-only packages @types/mdast and micromark-util-types move to the development tier, because neither ships code. --- THIRD_PARTY_NOTICES.md | 8 +- lefthook.yml | 2 +- scripts/browser-bundled-externals.ts | 177 ++++++++++++++++++++++++ scripts/gen-third-party-notices.spec.ts | 24 +++- scripts/gen-third-party-notices.ts | 47 ++++--- 5 files changed, 234 insertions(+), 24 deletions(-) create mode 100644 scripts/browser-bundled-externals.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 92b218ff33..482d0ea1ce 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages that reach a user: a workspace package resolves them at runtime, or a published browser artifact carries a copy of their code. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default — and the packages the client build inlines into a plugin bundle or the shell `dist`, which are declared as `devDependencies` because nothing on a user's machine resolves their specifiers. | Package | License | | --- | --- | @@ -48,7 +48,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | -| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | @@ -73,7 +72,6 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | -| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | @@ -115,7 +113,7 @@ The installed SDK 0.3.220 declares the following optional platform packages. Eac ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace, and carried by no published artifact. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. | Package | License | | --- | --- | @@ -129,6 +127,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -154,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | | [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | diff --git a/lefthook.yml b/lefthook.yml index 1c2a693c88..01c4a5b519 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,packages/client/tsdown.client.ts,scripts/build-exe-for-python-sdk.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/scripts/browser-bundled-externals.ts b/scripts/browser-bundled-externals.ts new file mode 100644 index 0000000000..4ab572f544 --- /dev/null +++ b/scripts/browser-bundled-externals.ts @@ -0,0 +1,177 @@ +/** + * The external packages a published browser artifact carries a copy of. + * + * Read from the real build configurations rather than declared by hand: each + * `lib/client.js` plugin bundle is driven through its own `tsdown.config.ts`, and + * the shell `dist` through `apps/web`'s Vite config. A recording plugin resolves + * every bare specifier as external and notes it, so the pass walks our own source + * and stops at the package boundary — which is both fast (about two seconds for + * the whole repository) and exactly the direct-dependency granularity + * THIRD_PARTY_NOTICES.md discloses. Erased type imports never appear, because the + * transform drops them before resolution. + * + * Workspace names are followed only on the Vite side, where the shell's aliases + * map them to source: that is how a browser-only library's own third-party + * imports — katex and shiki through `ui-primitives`, for one — become visible. A + * plugin bundle keeps them external, matching the frozen module table it is built + * against; the wire layers it inlines are host packages that declare their own + * dependencies, so nothing goes undisclosed. + * + * A specifier is recorded only once the host resolves it to a file inside a + * package. A bundler's own virtual module has no package behind it — + * `vite/modulepreload-polyfill` is generated by a Vite plugin rather than shipped + * as a file, so the polyfill in the published `dist` is build glue in the same + * category as an emitted TypeScript helper, not a redistributed copy of Vite. + * + * rolldown is resolved through tsdown deliberately: the dry run must use the + * exact bundler the real build uses, which a separate root pin could drift from. + */ + +import { globSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +/** The plugin-context member the recorder needs to resolve before recording. */ +interface ResolveContext { + resolve: ( + source: string, + importer: string, + options: { skipSelf: boolean }, + ) => Promise<{ id: string } | null> +} + +/** A rolldown/Vite plugin shape, narrowed to what the recorder needs. */ +interface RecorderPlugin { + name: string + enforce?: 'pre' + resolveId: ( + this: ResolveContext, + source: string, + importer: string | undefined, + ) => Promise<{ id: string; external: true } | null> +} + +/** + * The package a resolved module file belongs to. + * @param file - absolute path of a resolved module. + * @returns the package name, or undefined when the file is not inside a package. + */ +function packageOfFile(file: string): string | undefined { + const marker = file.lastIndexOf('node_modules/') + if (marker < 0) return undefined + const rest = file.slice(marker + 'node_modules/'.length) + const parts = rest.split('/') + return rest.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} + +/** + * Build the plugin that records bare specifiers and stops the walk at them. + * @param seen - set the recorder adds package names to. + * @param followWorkspace - leave `@deepseek-ai/*` to the host resolver instead of + * externalizing it, so the walk continues into our own source. + * @returns the recording plugin. + */ +function recorder(seen: Set, followWorkspace: boolean): RecorderPlugin { + return { + name: 'dsh-record-direct-externals', + enforce: 'pre', + async resolveId(source, importer) { + if (importer === undefined) return null // the entry itself + if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null + if (source.startsWith('virtual:') || source.includes('?')) return null + if (followWorkspace && source.startsWith('@deepseek-ai/')) return null + if (source.startsWith('node:')) return { id: source, external: true } + if (!source.startsWith('@deepseek-ai/')) { + const resolved = await this.resolve(source, importer, { skipSelf: true }) + const name = resolved === null ? undefined : packageOfFile(resolved.id) + if (name !== undefined) seen.add(name) + } + return { id: source, external: true } + }, + } +} + +interface Manifest { + exports?: Record + files?: string[] +} + +/** Read one workspace manifest. */ +function manifestOf(dir: string): Manifest { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Manifest +} + +/** Whether a manifest publishes a tsdown browser bundle at `lib/client.js`. */ +function publishesClientBundle(manifest: Manifest): boolean { + const target = manifest.exports?.['./client'] + return typeof target === 'object' && target !== null && target.default === './lib/client.js' +} + +/** + * Record every external package the plugin client bundles carry. + * @param root - repository root. + * @param seen - set the recorder adds package names to. + */ +async function collectFromClientBundles(root: string, seen: Set): Promise { + const requireFromTsdown = createRequire(createRequire(import.meta.url).resolve('tsdown')) + const { rolldown } = await import(requireFromTsdown.resolve('rolldown')) as { + rolldown: (options: Record) => Promise<{ + generate: (output: Record) => Promise + close: () => Promise + }> + } + + for (const relative of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) { + const dir = join(root, dirname(relative)) + if (!publishesClientBundle(manifestOf(dir))) continue + const loaded = await import(join(root, relative)) as { default: unknown } + const factory = loaded.default + const configs = (typeof factory === 'function' + ? (factory as (inline: { env: Record }) => unknown[])({ env: {} }) + : [factory]) as { name?: string; entry?: unknown; plugins?: unknown[] }[] + // The `/client` config is the browser bundle; its siblings emit the node half. + const client = configs.find(config => config.name?.endsWith('/client') === true) + if (client === undefined) continue + const bundle = await rolldown({ + cwd: dir, + input: client.entry, + plugins: [recorder(seen, false), ...(client.plugins ?? [])], + platform: 'browser', + }) + await bundle.generate({ format: 'cjs', minify: false, sourcemap: false }) + await bundle.close() + } +} + +/** + * Record every external package the prebuilt shell bundle carries. + * @param root - repository root. + * @param seen - set the recorder adds package names to. + */ +async function collectFromShellBundle(root: string, seen: Set): Promise { + for (const relative of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) { + const dir = join(root, dirname(relative)) + // Vite belongs to the app that builds with it, so it resolves from there. + const { build } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as { + build: (options: Record) => Promise + } + await build({ + root: dir, + logLevel: 'error', + plugins: [recorder(seen, true)], + build: { write: false, minify: false, sourcemap: false, reportCompressedSize: false }, + }) + } +} + +/** + * The external packages a published browser artifact carries a copy of. + * @param root - repository root. + * @returns package names, workspace names excluded. + */ +export async function browserBundledExternals(root: string): Promise> { + const seen = new Set() + await collectFromClientBundles(root, seen) + await collectFromShellBundle(root, seen) + return seen +} diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 479a2f13b1..5cd4f6b7fa 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -24,11 +24,12 @@ describe('THIRD_PARTY_NOTICES.md', () => { // already runs in the test lane, so the check costs no extra CI process. // Pre-commit regenerates the file whenever a manifest is staged, so reaching // this assertion means the notices were committed without that hook. - it('matches what the generator produces from the current manifests', () => { - const generated = render() + it('matches what the generator produces from the current manifests', async () => { + const generated = await render() expect(generated).toContain('It depends on the third-party software listed below.') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) - }) + // Driving the two real bundlers to learn what ships costs a few seconds. + }, 60_000) }) /** Build the (manifests, names) pair `tierExternalDeps` consumes. */ @@ -67,6 +68,23 @@ describe('tierExternalDeps', () => { ])) }) + it('keeps a devDependency runtime when a published browser artifact carries it', () => { + const { manifests, names } = workspace({ + // The client build inlines these, so a copy ships even though no manifest + // resolves the specifier at run time. + 'packages/client/ui-primitives/package.json': { + name: '@deepseek-ai/dsh-client-ui-primitives', + devDependencies: { katex: '^0.16', 'test-only-helper': '^1' }, + }, + }) + + expect(tierExternalDeps(manifests, names, new Set(['katex']))).toEqual(new Map([ + ['tsx', true], + ['katex', true], + ['test-only-helper', false], + ])) + }) + it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => { const { manifests, names } = workspace({ 'package.json': { devDependencies: { shared: '^1' } }, diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index f2411f5eff..1762e2d125 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -13,6 +13,7 @@ import { resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' +import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'THIRD_PARTY_NOTICES.md' @@ -347,15 +348,17 @@ function normalizeRepo(raw: string | undefined): string | undefined { } /** - * External npm dependencies, tiered by which workspace area declares them at - * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS` - * names it in `dependencies`/`optionalDependencies`. A package declared only - * by tooling, test infrastructure, the website, or the demo leaves — whatever - * the declaring section is called — is development-only. + * External npm dependencies, tiered by what reaches a user: a package is runtime + * when any manifest outside `DEV_ONLY_AREAS` names it in + * `dependencies`/`optionalDependencies`, or when a published browser artifact + * carries a copy of it. A package declared only by tooling, test infrastructure, + * the website, or the demo leaves — whatever the declaring section is called, and + * with no shipped artifact carrying it — is development-only. + * @returns every external dependency with its tier and metadata. */ -function collectNpmDeps(): ExternalDep[] { +async function collectNpmDeps(): Promise { const { manifests, names } = loadWorkspaceManifests() - return [...tierExternalDeps(manifests, names)] + return [...tierExternalDeps(manifests, names, await browserBundledExternals(root))] .filter(([name]) => !FIRST_PARTY.has(name)) .sort(([a], [b]) => a.localeCompare(b)) .map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime })) @@ -363,11 +366,23 @@ function collectNpmDeps(): ExternalDep[] { /** * Tier every external dependency the workspace declares. + * + * A package a published browser artifact carries is runtime whatever section + * declares it: the client build inlines its code, or the shell `dist` answers it + * from the frozen module table, so a copy is redistributed even though nothing on + * a user's machine resolves the specifier. Those packages are declared as + * `devDependencies` — `verify-client-runtime-deps` owns that rule — and tiering + * them by section alone would understate the notice. * @param manifests - workspace manifests keyed by repository-relative path. * @param names - every workspace package name, which never counts as external. + * @param bundled - external packages a published browser artifact carries. * @returns each external package mapped to whether it is a runtime dependency. */ -export function tierExternalDeps(manifests: Map, names: Set): Map { +export function tierExternalDeps( + manifests: Map, + names: Set, + bundled: ReadonlySet = new Set(), +): Map { const tiers = new Map() // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook. tiers.set('tsx', true) @@ -376,7 +391,7 @@ export function tierExternalDeps(manifests: Map, names: Set { verifyBuildTimePins() - const npm = collectNpmDeps() + const npm = await collectNpmDeps() const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) const vendored = collectVendored() @@ -707,7 +722,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.u ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. +External packages that reach a user: a workspace package resolves them at runtime, or a published browser artifact carries a copy of their code. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default — and the packages the client build inlines into a plugin bundle or the shell \`dist\`, which are declared as \`devDependencies\` because nothing on a user's machine resolves their specifiers. ${renderNpmTable(runtimeDeps)} @@ -718,7 +733,7 @@ ${renderClaudeDistribution(claudeDistribution)} ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace, and carried by no published artifact. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. ${renderNpmTable(devDeps)} ${renderNonPermissiveNote(nonPermissiveDev)} @@ -746,8 +761,8 @@ ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.lice /** CLI entry: default writes the notices, `--check` fails if the committed copy * is stale. Guarded behind an entry-point check so importing this module for * tests neither regenerates the committed file nor calls process.exit. */ -function main(): void { - const content = render() +async function main(): Promise { + const content = await render() if (process.argv.includes('--check')) { let committed: string | null = null try { @@ -771,5 +786,5 @@ function main(): void { // Run only when invoked as a script, not when imported by a test. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - main() + await main() } From b6d0195d5f04be9c3867cc776ab922c5dbd2f448 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:35:11 +0800 Subject: [PATCH 076/146] fix(scripts): resolve workspace names to source in the notices dry run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell's Vite config aliases a few workspace packages to source; every other workspace name resolved through node_modules to a lib/ entry the real build has emitted but a clean checkout has not, so the generator only worked on a built tree — and a static gate has to pass on a clean one. The dry run now supplies source aliases for the names the shell leaves out. Aliases rather than the recorder's resolveId hook, because Vite resolves a stylesheet @import through aliases alone, and the theme package publishes its stylesheets from lib/styles/. lib/ is compiled from src/, and the recorded set is identical either way: 24 packages on a built tree and on a clean one. --- scripts/browser-bundled-externals.ts | 71 ++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/scripts/browser-bundled-externals.ts b/scripts/browser-bundled-externals.ts index 4ab572f544..1ed61b1704 100644 --- a/scripts/browser-bundled-externals.ts +++ b/scripts/browser-bundled-externals.ts @@ -23,11 +23,18 @@ * as a file, so the polyfill in the published `dist` is build glue in the same * category as an emitted TypeScript helper, not a redistributed copy of Vite. * + * The pass runs on a clean tree, as a static gate must. The shell's Vite config + * aliases a few workspace packages to source; every other workspace name would + * resolve through `node_modules` to a `lib/` entry the real build has emitted but + * a clean checkout has not, so this module resolves those names to their own + * source instead. `lib/` is compiled from `src/`, so the third-party edges the + * pass records are the same either way. + * * rolldown is resolved through tsdown deliberately: the dry run must use the * exact bundler the real build uses, which a separate root pin could drift from. */ -import { globSync, readFileSync } from 'node:fs' +import { existsSync, globSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' @@ -64,14 +71,50 @@ function packageOfFile(file: string): string | undefined { return rest.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] } +/** + * Source aliases for the workspace packages the shell does not already alias. + * + * A clean checkout has no `lib/`, so a workspace name would otherwise resolve + * through `node_modules` to an entry that does not exist yet. Aliases are the + * right seam rather than a plugin hook, because Vite resolves a stylesheet + * `@import` through them too — the theme package publishes its stylesheets from + * `lib/styles/`. `lib/` is compiled from `src/`, so the third-party edges the + * pass records are the same either way. + * @param root - repository root. + * @param existing - the shell's own alias patterns, whose entry choices win. + * @returns alias entries mapping each remaining workspace name to its source. + */ +function workspaceSourceAliases(root: string, existing: readonly string[]): { find: RegExp | string; replacement: string }[] { + const aliases: { find: RegExp | string; replacement: string }[] = [] + for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) { + for (const relative of globSync(pattern, { cwd: root })) { + const dir = join(root, dirname(relative)) + const manifest = JSON.parse(readFileSync(join(root, relative), 'utf8')) as Manifest & { name?: string } + const name = manifest.name + if (name === undefined || !existsSync(join(dir, 'src'))) continue + if (existing.some(find => find.includes(name))) continue + const root_ = manifest.exports?.['.'] + const target = typeof root_ === 'string' ? root_ : root_?.default + const stem = (target ?? './lib/index.js') + .replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '') + const entry = [`${stem}.ts`, `${stem}.tsx`, `${stem}/index.ts`, `${stem}/index.tsx`] + .map(candidate => join(dir, 'src', candidate)) + .find(candidate => existsSync(candidate)) + // The subpath prefix carries `./client`, `./types`, and `./styles/*` alike: + // each published subpath mirrors a path under `src/`. + aliases.push({ find: `${name}/`, replacement: `${join(dir, 'src')}/` }) + if (entry !== undefined) aliases.push({ find: new RegExp(`^${name.replaceAll('/', '\\/')}$`), replacement: entry }) + } + } + return aliases +} + /** * Build the plugin that records bare specifiers and stops the walk at them. * @param seen - set the recorder adds package names to. - * @param followWorkspace - leave `@deepseek-ai/*` to the host resolver instead of - * externalizing it, so the walk continues into our own source. * @returns the recording plugin. */ -function recorder(seen: Set, followWorkspace: boolean): RecorderPlugin { +function recorder(seen: Set): RecorderPlugin { return { name: 'dsh-record-direct-externals', enforce: 'pre', @@ -79,7 +122,9 @@ function recorder(seen: Set, followWorkspace: boolean): RecorderPlugin { if (importer === undefined) return null // the entry itself if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null if (source.startsWith('virtual:') || source.includes('?')) return null - if (followWorkspace && source.startsWith('@deepseek-ai/')) return null + // A workspace name that reaches here is one no alias mapped to source, so + // nothing of ours is left to walk; it is never a third-party disclosure. + if (source.startsWith('@deepseek-ai/')) return { id: source, external: true } if (source.startsWith('node:')) return { id: source, external: true } if (!source.startsWith('@deepseek-ai/')) { const resolved = await this.resolve(source, importer, { skipSelf: true }) @@ -92,7 +137,7 @@ function recorder(seen: Set, followWorkspace: boolean): RecorderPlugin { } interface Manifest { - exports?: Record + exports?: Record files?: string[] } @@ -135,7 +180,7 @@ async function collectFromClientBundles(root: string, seen: Set): Promis const bundle = await rolldown({ cwd: dir, input: client.entry, - plugins: [recorder(seen, false), ...(client.plugins ?? [])], + plugins: [recorder(seen), ...(client.plugins ?? [])], platform: 'browser', }) await bundle.generate({ format: 'cjs', minify: false, sourcemap: false }) @@ -152,13 +197,21 @@ async function collectFromShellBundle(root: string, seen: Set): Promise< for (const relative of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) { const dir = join(root, dirname(relative)) // Vite belongs to the app that builds with it, so it resolves from there. - const { build } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as { + const { build, resolveConfig } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as { build: (options: Record) => Promise + resolveConfig: (options: Record, command: string) => Promise<{ + resolve: { alias: { find: string | RegExp }[] } + }> } + // The shell already aliases some workspace names to source, and its entry + // choices win: a stylesheet `@import` resolves through aliases rather than a + // plugin hook, so only the names it leaves out get one from here. + const resolved = await resolveConfig({ root: dir, logLevel: 'error' }, 'build') await build({ root: dir, logLevel: 'error', - plugins: [recorder(seen, true)], + plugins: [recorder(seen)], + resolve: { alias: workspaceSourceAliases(root, resolved.resolve.alias.map(entry => String(entry.find))) }, build: { write: false, minify: false, sourcemap: false, reportCompressedSize: false }, }) } From ef249da2e989f22749b32fabf3b71e8b5e962368 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:31:37 +0800 Subject: [PATCH 077/146] perf(web): remove the module preload polyfill The preload links stay; browsers that ignore them fetch vendor when index imports it. --- apps/web/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7b9ef7a15b..2289b6c13a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -93,6 +93,7 @@ export default defineConfig({ plugins: [rejectStandaloneServe(), react()], build: { sourcemap: true, + modulePreload: { polyfill: false }, rollupOptions: { output: { // Output layout: the two main chunks stay at assets/ root; lazy From f2830fec6ddaf898cf09036043b840e3b9b129ea Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:19:09 +0800 Subject: [PATCH 078/146] Revert "fix(client): declare browser-only externals as devDependencies" --- ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 4 +- ...-07-30-generated-third-party-notices.zh.md | 4 +- ...026-08-14-client-build-time-deps.i18n.yaml | 6 - .../2026-08-14-client-build-time-deps.md | 100 ----- .../2026-08-14-client-build-time-deps.zh.md | 100 ----- THIRD_PARTY_NOTICES.md | 8 +- apps/web/package.json | 8 +- apps/web/vite.config.ts | 1 - lefthook.yml | 2 +- package.json | 3 +- packages/client/AGENTS.md | 11 +- packages/client/locale/package.json | 3 +- packages/client/runtime/package.json | 10 +- packages/client/ui-agent-preset/package.json | 3 +- packages/client/ui-attachment/package.json | 10 +- packages/client/ui-commands/package.json | 9 +- packages/client/ui-conversation/package.json | 7 +- packages/client/ui-deliverables/package.json | 6 +- .../ui-directory-picker-browse/package.json | 9 +- .../ui-directory-picker-native/package.json | 3 +- packages/client/ui-goal/package.json | 3 +- packages/client/ui-input-trigger/package.json | 9 +- packages/client/ui-jobs/package.json | 6 +- packages/client/ui-layout/package.json | 3 +- .../client/ui-message-feedback/package.json | 3 +- .../client/ui-model-selection/package.json | 4 +- .../client/ui-permission-presets/package.json | 3 +- packages/client/ui-plan/package.json | 3 +- packages/client/ui-primitives/package.json | 12 +- .../client/ui-settings-general/package.json | 9 +- .../client/ui-settings-models/package.json | 3 +- .../ui-settings-plugin-inventory/package.json | 3 +- .../client/ui-settings-plugins/package.json | 9 +- packages/client/ui-settings/package.json | 3 +- packages/client/ui-sidebar/package.json | 9 +- packages/client/ui-skill/package.json | 3 +- packages/client/ui-subagent/package.json | 6 +- packages/client/ui-theme/package.json | 5 +- packages/client/ui-tool/package.json | 7 +- packages/client/ui-trajectory/package.json | 12 +- .../client/ui-user-questions/package.json | 8 +- packages/client/ui-workflow-run/package.json | 6 +- packages/client/ui-workspace/package.json | 9 +- packages/client/web-react/package.json | 8 +- packages/client/web/package.json | 8 +- .../cordis-client-runner/package.json | 3 +- packages/extensions/ui-cordis/package.json | 3 +- .../session-log-export/package.json | 34 +- pnpm-lock.yaml | 215 +++++----- scripts/browser-bundled-externals.ts | 230 ----------- scripts/gen-third-party-notices.spec.ts | 24 +- scripts/gen-third-party-notices.ts | 47 +-- scripts/verify-client-runtime-deps.ts | 368 ------------------ 54 files changed, 293 insertions(+), 1096 deletions(-) delete mode 100644 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml delete mode 100644 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.md delete mode 100644 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md delete mode 100644 scripts/browser-bundled-externals.ts delete mode 100644 scripts/verify-client-runtime-deps.ts diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index dfbdf76bec..821e829c52 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 2128537b09f9d5d68cb1175b1ecb195fa50e1865 -2026-07-30-generated-third-party-notices.zh.md: 906f5ed5b9a11f3ddd68491686b460c5d9f4a682 +2026-07-30-generated-third-party-notices.md: 9ca24c9634b0410f9b7cc255902344cd6014bb90 +2026-07-30-generated-third-party-notices.zh.md: b49e60565bbae62cf17026db17babc1ba7a408dc diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 2128537b09..9ca24c9634 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -26,8 +26,6 @@ The runtime tier deliberately covers **every mountable plugin**, not just what t The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. -**A package a published artifact carries is runtime whatever section declares it.** The browser side is built, not resolved: tsdown inlines every non-platform specifier into a plugin's `lib/client.js`, and Vite inlines the shell's imports into `@deepseek-ai/dsh-web-frontend`'s `dist`. Those packages are therefore declared as `devDependencies` — [the client build-time dependency note](../../proposed/process/2026-08-14-client-build-time-deps.md) owns that placement rule — while a copy of each is redistributed, so react, shiki, katex, and the markdown pipeline must stay in the runtime tier. [`scripts/browser-bundled-externals.ts`](../../../../scripts/browser-bundled-externals.ts) answers which ones those are from the build itself: it drives each client bundle through that package's own `tsdown.config.ts` and the shell through `apps/web`'s Vite config, with a recording plugin that resolves every bare specifier as external and notes it. The pass walks this repository's own source and stops at the package boundary, which costs about three seconds and yields exactly the direct-dependency granularity this file discloses. A specifier counts only once the host resolves it to a file inside a package, so a bundler's own virtual module is not mistaken for a shipped one: `vite/modulepreload-polyfill` is generated by a Vite plugin rather than published as a file, which makes the polyfill in the `dist` build glue of the same kind as an emitted TypeScript helper. Using the real configurations rather than a hand-kept list buys two properties no list has: an erased type import never appears, because the transform drops it before resolution, and a package stops being disclosed as shipped the moment its last browser import is gone. Workspace names are followed only on the Vite side, where the shell's aliases map them to source — that is how a browser-only library's own imports, `ui-primitives`' katex and shiki among them, become visible. A package that ships only type declarations is development-only however a shipped package names it: it contributes no redistributed code, and the dry run cannot see an erased type import anyway, because the transform drops it before resolution. `@types/mdast` and `micromark-util-types` moved to that tier for exactly this reason. - The project owner separately authorizes distribution of every official `@anthropic-ai/claude-agent-sdk` version and the official Claude Code CLI/platform payloads that version declares through `optionalDependencies`. The generator represents this as one exact direct-package identity exception, not as a permissive-license override: `SEE LICENSE IN README.md` and `SEE LICENSE IN LICENSE.md` remain non-permissive classifications, and every unrelated non-permissive runtime still fails closed. When the SDK is present, the generator reads its installed manifest, rejects optional identities outside the official SDK payload prefix, derives the current SDK, CLI, and payload versions, verifies the installed host payload's identity, version, and declared-license field, and renders the complete SDK-declared payload set in a separate notices section. Version, declared-license, and payload-set changes do not require new identity authorization, but they still require ordinary dependency, lockfile, compatibility, terms, and notices review. ## Testing @@ -54,7 +52,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa ## Consequences -A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run on commits that touch a manifest, and nothing on any other commit. It costs a few seconds rather than one, because learning what the browser artifacts carry drives the real client and shell bundlers; a broken client source therefore fails the hook, as the staged lint job already would. Committing with hooks disabled defers the cost to a test-lane failure that names the command. +A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 906f5ed5b9..b49e60565b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -26,8 +26,6 @@ Status: implemented manifest 集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布 manifest 答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列入运行时表格,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 -**被已发布产物带上的包,无论由哪个区段声明都算 runtime。** 浏览器侧是构建出来的,不是解析出来的:tsdown 把每个非平台 specifier 内联进插件的 `lib/client.js`,Vite 把 shell 的 import 内联进 `@deepseek-ai/dsh-web-frontend` 的 `dist`。因此这些包声明在 `devDependencies`——落位规则归 [客户端构建期依赖那篇](../../proposed/process/2026-08-14-client-build-time-deps.md)——但它们各自都有一份副本被分发出去,所以 react、shiki、katex 与整条 markdown 管线必须留在 runtime 档。到底是哪些包,由 [`scripts/browser-bundled-externals.ts`](../../../../scripts/browser-bundled-externals.ts) 从构建本身取答案:它用各包自己的 `tsdown.config.ts` 驱动每个 client bundle,用 `apps/web` 的 Vite 配置驱动 shell,挂一个记录用插件把每个 bare specifier 解析成 external 并记下来。这一趟只走本仓自己的源码、到包边界即停,约三秒,且给出的正是本文件披露的「直接依赖」粒度。一个 specifier 只有在宿主把它解析到某个包内的文件之后才被计入,所以打包器自己的虚拟模块不会被误当成随产物分发的包:`vite/modulepreload-polyfill` 由 Vite 插件生成而非作为文件发布,因此 `dist` 里那段 polyfill 与 TypeScript 生成的辅助代码同类,属于构建胶水。用真配置而不是一张人工名单,白拿两个手写名单没有的性质:被擦除的类型 import 永远不会出现,因为 transform 在解析前就删了它;某个包最后一处浏览器 import 消失时,它也立刻不再被披露成随产物分发。workspace 名字只在 Vite 那侧继续走,因为 shell 的 alias 会把它们映射到源码——浏览器库包自己的 import,比如 `ui-primitives` 的 katex 与 shiki,正是这样才可见的。 只发布类型声明的包一律算 development-only,无论哪个已发布包具名了它:它不贡献任何被分发的代码,而 dry-run 本来也看不见被擦除的类型 import——transform 在解析前就删了它。`@types/mdast` 与 `micromark-util-types` 正是因此落到该档。 - 项目所有者另行授权分发每个官方 `@anthropic-ai/claude-agent-sdk` 版本,以及该版本通过 `optionalDependencies` 声明的官方 Claude Code CLI 与平台载荷。生成器将其表示为一项精确匹配直接包身份的例外,而非宽松许可证覆盖项:`SEE LICENSE IN README.md` 与 `SEE LICENSE IN LICENSE.md` 仍归类为非宽松,所有无关的非宽松运行时依赖仍以默认拒绝方式失败。存在该 SDK 时,生成器会读取其已安装 manifest,拒绝不符合官方 SDK 载荷前缀的可选包身份,推导当前 SDK、CLI 与载荷版本,核验已安装宿主载荷的身份、版本和声明许可证字段,并在单独的声明章节中渲染 SDK 声明的完整载荷集合。版本、声明许可证和载荷集合发生变化时无需新的身份授权,但仍须经过常规的依赖、锁文件、兼容性、条款和声明评审。 ## 测试 @@ -54,7 +52,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 ## 后果 -此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行;其余提交不受影响。这次运行从约一秒变成几秒,因为要弄清浏览器产物带了什么就得驱动真实的 client 与 shell 打包器;因此客户端源码坏掉会让这个 hook 失败,而暂存区的 lint job 本来也会失败。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 +此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml deleted file mode 100644 index 4c98962ba9..0000000000 --- a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 .agents/notes/proposed/process/2026-08-14-client-build-time-deps.md -2026-08-14-client-build-time-deps.md: 0605225a70a16fed9004acfe26adba9b6166f201 -2026-08-14-client-build-time-deps.zh.md: 5caed3422288711532c40cd767f72c97693e68b4 diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md deleted file mode 100644 index 0605225a70..0000000000 --- a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.md +++ /dev/null @@ -1,100 +0,0 @@ -# Agent Note: Client build-time dependencies stay out of the install face - -Status: proposed - -English | [中文](2026-08-14-client-build-time-deps.zh.md) - -## Problem - -A browser artifact resolves nothing on the user's machine: - -- A `ui-*` plugin package's browser artifact is `lib/client.js`, where tsdown inlines every non-platform specifier (`noExternal` in `packages/client/tsdown.client.ts`). The specifiers that survive are answered by the loader's frozen module table, because `require` inside that bundle is a parameter the loader injects, not Node's. -- Platform modules (`PLATFORM_MODULES`) come from the shell `dist`, never from Node resolution. -- The shell's own imports are inlined by Vite into `@deepseek-ai/dsh-web-frontend`'s published `dist`; that package ships `dist` alone and has no `.` export. - -Every browser code path is therefore a build product, served as an asset or baked into `dist`. Yet the packages those artifacts are built from — react, react-dom, shiki, katex, clsx, the micromark and mdast families — sit in `dependencies` and non-optional `peerDependencies`, which npm installs for every consumer of the published package. Across the repository that is 79 such external declarations in 38 packages, downloaded by users who never load them. - -## Proposal - -### The rule - -**An external package only a browser artifact reaches belongs in `devDependencies`.** Two deliberate omissions are as much part of the rule: - -- **External packages only.** A `@deepseek-ai/*` name stays where its manifest puts it. Such a declaration also states which package supplies an injected service, which Remote contribution an assembly mounts, or which Loader row must resolve; [verify-runtime-closure](../../../../scripts/verify-runtime-closure.ts) and the Loader read it, and the app installs the package regardless — so moving one removes meaning without removing a download. -- **Anything the node half reaches stays**, an erased type import included. - -Faces are walked from the entries a manifest publishes, not by a directory rule, so a module under `src/` that only the browser entry reaches counts as browser source: - -| kind | test | host face entries | -| --- | --- | --- | -| `bundle-half` | has a `./client` export | every export target except `./client` | -| `browser-library` | under `packages/client/` with no `./client` export | `src/invariant.ts` alone — the companion the host mounts; `.` is browser code | -| `prebuilt-dist` | no `.` export, ships a `dist` | none: the package offers Node no entry | - -### The gate: `scripts/verify-client-runtime-deps.ts` - -Wired into `pnpm run hygiene`, about 35 seconds — the cost of two bound Programs, the same order as `verify-optional-dependency-imports` in that lane. It reuses the repository's tooling rather than growing its own: `TypeScriptProject` (`scripts/ts-project.ts`) binds the host and client compiler faces separately (that file states why the two cannot share one program — the cordis Context merges collide), `ts.resolveModuleName` resolves relative specifiers, and the walk stops at the package boundary. - -Three findings decided the mechanism, after a first pass that scanned string literals: - -1. A package name must match as a name: the `react` substring inside `'@deepseek-ai/dsh-client-web-react'` silently swallowed react. -2. Whether `./client` is the tsdown browser bundle is keyed on the **artifact path** (`./lib/client.js`), not the subpath name — `dsh-goal` publishes `./client` as `./lib/types/client.js`, a plain tsc-emitted browser-shared module. -3. `require`, `require.resolve`, and dynamic `import()` on a literal each reach a package; `require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` is a real host resolution path. - -Two classes, both reported per entry: - -| class | count | test | -| --- | --- | --- | -| `browser` | 74 | only a browser artifact reaches it | -| `nothing` | 5 | no reference names it: `client-runtime`'s `react` (which contradicts its own React-free layering red line), the peer `react` of `ui-settings` and `ui-theme`, `ui-trajectory`'s peer `react-dom`, and `ui-primitives`' `@types/mdast` | - -Each conservative rule below answers a false report or a semantic loss observed while building it: - -- **A type reference from the node half keeps its declaration.** `import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'` in `src/invariant.ts` is erased at run time, yet it states which package supplies the service that companion registers — `verify-runtime-closure`'s relation. -- **A package publishing a Node entry with no source counterpart is skipped whole, and named in the output.** `dsh-goal`'s `./typert -> ./lib/typert.host.js` is emitted by the typert generator and carries its own `import { z } from 'zod'`, which no source states. Getting this test right removed four false reports, among them `api-gateway`'s `typert-registry`. -- A `cordis*.yml` the package owns counts as host face: a Loader row names its plugin instead of importing it. -- `@deepseek-ai/cordis` is exempt — check-workspace-constraints requires it as both peer and dev everywhere. - -`--json` output feeds the bulk edit and the install measurement. - -### What leaves an install - -Measured against a real `npm install` of the published CLI, with tarball bytes read from an isolated cache: 103 external tarballs stop being downloaded, 6.05 MB in total. - -| group | packages | saved | -| --- | --- | --- | -| syntax highlighting and math (shiki family, oniguruma family, katex) | 16 | 3.93 MB | -| react and view libraries (react, react-dom, scheduler, immer, zustand, `@tanstack/*`, clsx, use-sync-external-store) | 11 | 1.47 MB | -| markdown and ansi pipeline plus odds and ends (micromark, mdast, hast families, anser, a few `@types/*`) | 76 | 0.65 MB | - -Our own six browser-library packages (ui-primitives, ui-slots, web-react, ui-attachment, schema-form, client-web — 0.20 MB together) stay installed: code names them, and the rule above leaves those declarations alone. - -### How it lands, split by nature - -1. **Documentation first**: a declaration section in `packages/client/AGENTS.md`, and one clause in the new-plugin-package checklist. -2. **The gate**: `scripts/verify-client-runtime-deps.ts`, its `package.json` script, its place in `hygiene`, and a counterexample spec. -3. **The manifests**: 79 entries in 38 packages. 50 need a new `devDependencies` entry; the rest already carry one, so the change is a deleted line. -4. **Re-measure after the next release** with the same method, confirming the 103 tarballs stay gone. - -## Alternatives considered - -- **Scanning string literals**: the first implementation, rejected by the three findings above — the react-inside-web-react substring had already produced a silent miss. -- **Reading built artifacts (`lib/**/*.js`) instead of source**: that is Node's own view, but the gate would then depend on `pnpm run build`, and it still cannot judge a browser-library's `lib/index.js` (node platform, browser content), so the face test stays either way. -- **Asking the checker whether a binding is used in a value position** (what `verify-optional-dependency-imports` does): tried, and it also judged 83 node-face type-only declarations movable — no download saved for a real loss of meaning, 53 of them `dsh-invariants`. This gate needs to know whether a reference exists, not whether it is a value. -- **Also clearing our own six browser-library packages from the install face**, on the test that no install loads one: another 0.20 MB, at the price of deleting 74 workspace declarations that code genuinely names. Ruled out (2026-08-14): keep what the code names. The cleaner end state is to stop publishing those six packages, which is its own proposal. -- **`peerDependenciesMeta.optional` instead of `devDependencies`**: npm does skip an optional peer, but the meaning is "a consumer may supply this", and there is no run-time consumer at all. The repository must install it to build, which is what `devDependencies` says. -- **Leaving it to knip**: out of scope for knip, which reports a declared package nothing imports. These specifiers are imported; a bundler inlines them. The evidence is that they persisted on master with knip green. Only the five `nothing` entries overlap. -- **`optionalDependencies`**: wrong meaning — it says "skip this if it cannot be installed". - -## Acceptance criteria - -- `pnpm run hygiene` includes `verify-client-runtime-deps` and passes; a counterexample spec proves one `dependencies.react` is rejected. -- `pnpm run build`, `pnpm run test:gui`, and `DSH_SNAPSHOT=replay pnpm run test:web` pass — the move changes no build input, so artifacts stay byte-identical. -- A real install after the next release no longer downloads the 103 tarballs above. - -## Risks - -- **The six browser-library packages that stay installed carry bare imports nothing resolves**: `ui-primitives/lib/index.js` is a rolldown artifact and still reads `from "anser"`, while anser is now dev-only. It is inert — only our Vite build reads that file, and no loader exists for it on a user's machine (verified: only browser code imports those packages, never the host). Retiring their publication is the way to erase it; see Alternatives. -- **`@types/*` go unreported**: source never names them, so the rule cannot see them. `@types/mdast` was caught only because nothing referenced it either. They belong in dev regardless, and closing that gap is follow-up work. -- **A skipped package is unprotected**: `dsh-goal` is skipped whole for its generated entry, so its browser-side declarations are now nobody's business. Reading a generated artifact's own run-time imports is what would let the exemption be withdrawn. -- **A false report would delete a declaration something needs at run time**: three defenses hold that line — literal arguments to `require`, `require.resolve`, and dynamic `import()` count as references; a package's own `cordis*.yml` counts as host face; and no `@deepseek-ai/*` name is subject at all. diff --git a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md b/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md deleted file mode 100644 index 5caed34222..0000000000 --- a/.agents/notes/proposed/process/2026-08-14-client-build-time-deps.zh.md +++ /dev/null @@ -1,100 +0,0 @@ -# Agent Note: 客户端构建期依赖不进安装面 - -Status: proposed - -[English](2026-08-14-client-build-time-deps.md) | 中文 - -## Problem - -浏览器产物不在用户机上解析任何 specifier: - -- `ui-*` 插件包的浏览器产物是 `lib/client.js`,tsdown 把每个非平台 specifier 直接内联(`packages/client/tsdown.client.ts` 的 `noExternal`)。留下来的 specifier 由 loader 的冻结模块表应答——那个 bundle 里的 `require` 是 loader 注入的形参,不是 Node 的。 -- 平台模块(`PLATFORM_MODULES`)由 shell `dist` 提供,不走 Node 解析。 -- shell 自身的 import 由 Vite 内联进 `@deepseek-ai/dsh-web-frontend` 已发布的 `dist`;该包只发 `dist`,连 `.` 导出都没有。 - -所以浏览器的每条代码路径都是构建产物,或作为静态资源下发,或烤进 `dist`。但这些产物的构建输入——react、react-dom、shiki、katex、clsx、micromark 与 mdast 全族——现在写在 `dependencies` 和非 optional `peerDependencies` 里,而 npm 对每个消费者都会安装这两个区段。全仓 38 个包共 79 处这样的外部依赖声明,装给了永远不会加载它们的用户。 - -## Proposal - -### 规则 - -**只被浏览器产物触及的外部包落 `devDependencies`。** 两条留白同样是规则的一部分: - -- **只管外部依赖**。`@deepseek-ai/*` 一律留在原处:那些声明还表达「谁提供我注入的服务」「这个 assembly 挂载了谁的 Remote」「哪个 Loader 行必须能解析」,[verify-runtime-closure](../../../../scripts/verify-runtime-closure.ts) 与 Loader 都读它,而 app 无论如何都会装那个包——移走只是删掉语义,并没有减少下载。 -- **node 面触及的一律不动**,包括被擦除的类型引用。 - -face 从 manifest 真正发布的入口走图,不用目录规则,所以 `src/` 下只被浏览器入口触及的模块就算浏览器代码: - -| kind | 判据 | host face 入口 | -| --- | --- | --- | -| `bundle-half` | 有 `./client` 导出 | 除 `./client` 外的每个导出目标 | -| `browser-library` | `packages/client/` 下且无 `./client` 导出 | 只有 `src/invariant.ts`——宿主唯一能挂载的伴生模块;`.` 面是浏览器代码 | -| `prebuilt-dist` | 无 `.` 导出、发布 `dist` | 没有:这个包不给 Node 提供任何入口 | - -### 门禁:`scripts/verify-client-runtime-deps.ts` - -接入 `pnpm run hygiene`,约 35 秒——两个绑定 Program 的开销,与同 lane 的 `verify-optional-dependency-imports` 同量级。复用仓内既有工具而不自造一套:`TypeScriptProject`(`scripts/ts-project.ts`)分别绑定 host 与 client 两个编译面(该文件写明两者不能合进一个 program——cordis Context merge 会撞),相对 specifier 交给 `ts.resolveModuleName` 解析,遍历到包边界即停。 - -判据要害有三条,都是起手那版扫字符串字面量踩出来的: - -1. 包名必须按名匹配:`'@deepseek-ai/dsh-client-web-react'` 里的 `react` 子串会静默吞掉 react。 -2. `./client` 是不是 tsdown 浏览器 bundle,看的是**产物路径**(`./lib/client.js`)而不是子路径名——`dsh-goal` 的 `./client` 是 `./lib/types/client.js`,一个 tsc 直出的浏览器共享模块。 -3. `require`、`require.resolve`、动态 `import()` 的字面量实参都能触及一个包;`require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` 就是真实存在的宿主解析路径。 - -两类判定,逐条报告: - -| 类 | 数量 | 判据 | -| --- | --- | --- | -| `browser` | 74 | 只有浏览器产物触及 | -| `nothing` | 5 | 没有任何引用具名它:`client-runtime` 的 `react`(与它自己「零 React 引用」的分层红线矛盾)、`ui-settings` 与 `ui-theme` 的 peer `react`、`ui-trajectory` 的 peer `react-dom`、`ui-primitives` 的 `@types/mdast` | - -下面每条保守规则都对应一次实测到的误报或语义损失: - -- **node 面的类型引用保留声明。** `src/invariant.ts` 里的 `import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'` 运行期被擦除,但它声明了谁提供这个伴生插件要注册的服务——归 `verify-runtime-closure` 管的关系。 -- **发布了没有源码对应文件的 Node 入口的包整包跳过,并在输出里点名。** `dsh-goal` 的 `./typert -> ./lib/typert.host.js` 由 typert 生成器直出,自带 `import { z } from 'zod'`,没有任何源码陈述这件事。把这条判据修对,消掉了四处误报,其中包括 `api-gateway` 的 `typert-registry`。 -- 包自带的 `cordis*.yml` 算 host face:Loader 行是具名它的插件而不是 import 它。 -- `@deepseek-ai/cordis` 豁免——check-workspace-constraints 要求它在每个包里同时是 peer 和 dev。 - -`--json` 输出供批量改写与安装体积实测复用。 - -### 安装面少掉什么 - -对已发布 CLI 真装一遍实测,tarball 字节从独立 cache 读出:103 个外部 tarball 不再下载,合计 6.05 MB。 - -| 组 | 包数 | 省 | -| --- | --- | --- | -| 语法高亮与数学(shiki 族、oniguruma 族、katex) | 16 | 3.93 MB | -| react 与视图库(react、react-dom、scheduler、immer、zustand、`@tanstack/*`、clsx、use-sync-external-store) | 11 | 1.47 MB | -| markdown 与 ansi 管线及零碎(micromark、mdast、hast 全族、anser、若干 `@types/*`) | 76 | 0.65 MB | - -我们自己的 6 个浏览器库包(ui-primitives、ui-slots、web-react、ui-attachment、schema-form、client-web,合计 0.20 MB)仍留在安装面:代码确实具名它们,上面的规则不动那些声明。 - -### 分刀落地 - -1. **文档住顶刀**:`packages/client/AGENTS.md` 的依赖声明节,加新插件包 checklist 里的一句。 -2. **门禁**:`scripts/verify-client-runtime-deps.ts`、它的 `package.json` 脚本、它在 `hygiene` 里的位置,以及一条反例 spec。 -3. **manifest**:38 个包 79 处。其中 50 处需要新增 `devDependencies` 条目,其余包已有同名条目,改动就是删掉一行。 -4. **发版后按同一方法复测**,确认这 103 个 tarball 没有回来。 - -## Alternatives considered - -- **扫字符串字面量**:起手就是这么实现的,被上面三条要害否掉——react-in-web-react 的子串已经造成过一次静默漏报。 -- **读构建产物(`lib/**/*.js`)而不是源码**:那是 Node 自己的视角,但门禁从此依赖 `pnpm run build`,而且它照样判不了浏览器库包的 `lib/index.js`(platform 是 node、内容是浏览器代码),face 判据两种走法都得有。 -- **用 checker 判绑定是否用在值位置**(`verify-optional-dependency-imports` 就是这么做的):试过,它把 83 处 node 面纯类型声明也判成可移出——没省下任何下载,却实打实损失语义,其中 53 处是 `dsh-invariants`。本门禁要知道的是引用是否存在,而不是它是值还是类型。 -- **顺带把我们自己的 6 个浏览器库包也清出安装面**,判据是任何安装都不加载它们:再省 0.20 MB,代价是删掉 74 处代码确实具名的 workspace 声明。已否(2026-08-14):代码用到的就保留。更干净的终态是这 6 个包不再发布,那是另一个提案。 -- **用 `peerDependenciesMeta.optional` 而不是 `devDependencies`**:npm 确实会跳过 optional peer,但那个语义是「消费者可以自行提供」,而这里根本没有运行期消费者。仓内必须装一份才能构建,这正是 `devDependencies` 的意思。 -- **交给 knip**:不属于 knip 的范畴,它报的是「声明了但没人 import」。这些 specifier 确实被 import,只是被打包器内联了。实证就是它们在 master 上长期存在而 knip 全绿。只有 `nothing` 那 5 条与它重叠。 -- **用 `optionalDependencies`**:语义错,它说的是「装不上就跳过」。 - -## Acceptance criteria - -- `pnpm run hygiene` 包含 `verify-client-runtime-deps` 并通过;一条反例 spec 证明一处 `dependencies.react` 会被拒。 -- `pnpm run build`、`pnpm run test:gui`、`DSH_SNAPSHOT=replay pnpm run test:web` 通过——这次迁移不改任何构建输入,产物应逐字节等价。 -- 发版后真装一遍,上面那 103 个 tarball 不再被下载。 - -## Risks - -- **留在安装面的 6 个浏览器库包会带着解析不了的 bare import**:`ui-primitives/lib/index.js` 是 rolldown 产物,仍写着 `from "anser"`,而 anser 已经只在 dev。它是惰性的——只有我们的 Vite 构建会读这个文件,用户机上没有任何加载者(已实证:只有浏览器代码 import 它们,宿主从不)。要彻底消掉就让这些包不再发布,见 Alternatives。 -- **`@types/*` 报不出来**:源码从不具名它们,规则看不见。`@types/mdast` 被抓到只是因为恰好也没有任何引用。它们本来就该在 dev,补这个缺口是后续的事。 -- **被跳过的包没人管**:`dsh-goal` 因生成入口整包跳过,它浏览器侧的声明现在没有门禁看着。能读到生成产物自身的运行期 import,这条豁免才能收回。 -- **误报会删掉运行期真需要的声明**:三层兜底守住这条线——`require`、`require.resolve`、动态 `import()` 的字面量实参都算引用;包自带的 `cordis*.yml` 算 host face;`@deepseek-ai/*` 整体不在判据范围内。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 482d0ea1ce..92b218ff33 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that reach a user: a workspace package resolves them at runtime, or a published browser artifact carries a copy of their code. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default — and the packages the client build inlines into a plugin bundle or the shell `dist`, which are declared as `devDependencies` because nothing on a user's machine resolves their specifiers. +External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -48,6 +48,7 @@ External packages that reach a user: a workspace package resolves them at runtim | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | @@ -72,6 +73,7 @@ External packages that reach a user: a workspace package resolves them at runtim | [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT | | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | +| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | | [`picomatch`](https://github.com/micromatch/picomatch) | MIT | @@ -113,7 +115,7 @@ The installed SDK 0.3.220 declares the following optional platform packages. Eac ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace, and carried by no published artifact. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. | Package | License | | --- | --- | @@ -127,7 +129,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | -| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | @@ -153,7 +154,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | -| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | | [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | diff --git a/apps/web/package.json b/apps/web/package.json index 5e6dc8152b..fc990f684c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,9 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-client-web": "workspace:^" + "@deepseek-ai/dsh-client-web": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", @@ -44,8 +46,6 @@ "typescript": "^6.0.3", "vite": "^6.0.0", "vitest": "^4.1.8", - "fflate": "^0.8.2", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "fflate": "^0.8.2" } } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2289b6c13a..7b9ef7a15b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -93,7 +93,6 @@ export default defineConfig({ plugins: [rejectStandaloneServe(), react()], build: { sourcemap: true, - modulePreload: { polyfill: false }, rollupOptions: { output: { // Output layout: the two main chunks stay at assets/ root; lazy diff --git a/lefthook.yml b/lefthook.yml index 01c4a5b519..1c2a693c88 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,packages/client/tsdown.client.ts,scripts/build-exe-for-python-sdk.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/package.json b/package.json index 6f2346e2e6..517d0c56d1 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,6 @@ "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", - "verify-client-runtime-deps": "tsx scripts/verify-client-runtime-deps.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-cordis-api": "tsx scripts/gen-cordis-api.ts", @@ -127,7 +126,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-runtime-deps && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 14965fd495..1928f30a3c 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -35,15 +35,6 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile. 3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. -## Dependency declaration - -A browser artifact resolves nothing on the user's machine: tsdown inlines every non-platform specifier into `lib/client.js`, the shell `dist` answers `PLATFORM_MODULES` from its frozen module table, and Vite inlines the shell's own imports into the published `dist`. - -- **An external package only browser code reaches belongs in `devDependencies`.** npm installs `dependencies` and non-optional `peerDependencies` for every consumer, so react, shiki, katex, or clsx declared there ships to users who never load it. -- **A workspace name stays where it is.** Such a declaration also states which package supplies an injected service or a mounted Remote contribution — [verify-runtime-closure](../../scripts/verify-runtime-closure.ts) and the Loader read it, and the app installs the package regardless. -- **The node half decides.** Anything its published entries reach — an erased type import, a `require.resolve`, and a Loader row in the package's own `cordis*.yml` included — stays declared as it is. -- `pnpm run verify-client-runtime-deps` (inside `hygiene`) names each offending entry; knip owns whether the surviving declaration is used at all. - ## ctx discipline (components never see ctx) `ctx` belongs to the apply world only: the plugin body and the inject factories closed over it. Components — every `.tsx` under a feature domain — receive all data and callbacks **through the four props shares**; they never call a hook that reaches ctx, never import a service class to poke it, never read a React context (business components see zero contexts — `BindingContext` and its kin are renderer-internal). If a component needs something new, the answer is a prop threaded from its share's source (owner site, store declaration, or inject face), not a hook. @@ -100,7 +91,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons): -1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list, and every browser-side external package under `devDependencies` per the [declaration rules](#dependency-declaration)), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. +1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `runtime-diagnostics/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. 2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the healed `$DSH_HOME/profiles/node_modules` fallback, which mirrors the app's and each bundle's declared dependencies — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dsh.client manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads. diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 3f4f408854..0184f72d18 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -50,7 +50,8 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index cc610a925d..1da3ac6428 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -53,7 +53,10 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "immer": "^10.1.1", + "react": "^18.2.0", + "zustand": "~4.4.7" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -69,10 +72,7 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@types/react": "~18.3.1", - "immer": "^10.1.1", - "react": "^18.2.0", - "zustand": "~4.4.7" + "@types/react": "~18.3.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index b2c447b17e..f705158d1e 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -58,7 +58,8 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 4d40c870a7..c257166e2a 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -28,16 +28,16 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^" + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", - "clsx": "^2.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@types/react-dom": "~18.3.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index a0445a32e2..cf80e20f3f 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -46,6 +46,9 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -56,7 +59,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -72,8 +76,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "clsx": "^2.0.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 096c5280af..2fc12605c1 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -49,6 +49,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { @@ -71,7 +72,8 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -102,8 +104,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "clsx": "^2.0.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index da627c207b..ac7f264f62 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -45,6 +45,9 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "react": "^18.2.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -65,8 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 028a46e290..0cc14700fc 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -44,6 +44,9 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -51,7 +54,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -65,8 +69,7 @@ "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0", - "clsx": "^2.0.0" + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 5fe8d80cc7..74b8e7845b 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -48,7 +48,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 56b9a23472..6076aab741 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -55,7 +55,8 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index f933031f82..a4ca2afe84 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -43,13 +43,17 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -60,8 +64,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "clsx": "^2.0.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 0855cc486d..a59d064dda 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -45,6 +45,9 @@ "publishConfig": { "access": "public" }, + "dependencies": { + "react": "^18.2.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -63,8 +66,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 0753bd9367..d3ab329297 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -48,7 +48,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 3d2480b05e..481d02aa03 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -56,7 +56,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index dd74f2c764..298fd8b133 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -56,7 +56,9 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "clsx": "^2.1.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 159700431b..62f03cbd07 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -60,7 +60,8 @@ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^" + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 58b8d4430a..233a8a72fe 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -53,7 +53,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 5bb934f474..b6e01b4f5a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -26,11 +26,7 @@ "./package.json": "./package.json" }, "license": "MIT", - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", + "dependencies": { "@shikijs/langs": "^4.3.1", "@types/mdast": "^4.0.4", "anser": "^2.3.5", @@ -52,6 +48,12 @@ "react-dom": "^18.2.0", "shiki": "^4.3.1" }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "@deepseek-ai/cordis": "workspace:^" + }, "files": [ "lib/index.js", "lib/invariant.js", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 3727a03ee9..c5207dfc9d 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -49,7 +49,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "clsx": "^2.0.0" }, "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -62,7 +63,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -78,8 +80,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0", - "clsx": "^2.0.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index b6dc52fc4d..423755475c 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -54,7 +54,8 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 8d04e40581..95af8a15fa 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -53,7 +53,8 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 936d2df19e..a9fd7b8e9d 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -56,7 +56,8 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -71,7 +72,6 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "clsx": "^2.0.0", "react": "^18.2.0" }, "files": [ @@ -79,5 +79,8 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "clsx": "^2.0.0" + } } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 46e269a3a3..485092d2b9 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -52,7 +52,8 @@ "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index bad27010aa..cfda6d55fc 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -44,13 +44,17 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -62,8 +66,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "clsx": "^2.0.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index b979880e83..b2026095f2 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -56,7 +56,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 327fc66442..0e0b7aadae 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -46,6 +46,9 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "react": "^18.2.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -70,8 +73,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index efea7f3e47..6f320cd92f 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -54,7 +54,8 @@ "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -68,7 +69,6 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "clsx": "^2.0.0", "react": "^18.2.0" }, "files": [ @@ -84,6 +84,7 @@ }, "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", + "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 05e5f7fd28..80bc4f2586 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -44,6 +44,9 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", @@ -52,7 +55,8 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -68,7 +72,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index bd90aba857..375053ba7c 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -44,6 +44,10 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "@tanstack/react-virtual": "^3.14.9", + "diff": "^9.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -52,7 +56,9 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -69,9 +75,7 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", - "react-dom": "^18.2.0", - "@tanstack/react-virtual": "^3.14.9", - "diff": "^9.0.0" + "react-dom": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index ddd64ed2cb..95fb02b0f3 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -48,7 +48,9 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^" + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "clsx": "^2.0.0", + "react": "^18.2.0" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -65,9 +67,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", - "@types/react": "~18.3.1", - "clsx": "^2.0.0", - "react": "^18.2.0" + "@types/react": "~18.3.1" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 62e8dcc803..e5b71cf942 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -50,6 +50,9 @@ "lib/types/**/*.d.ts" ], "license": "MIT", + "dependencies": { + "react": "^18.2.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -74,7 +77,6 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index cd85838b3b..b6a711b873 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -45,13 +45,17 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -64,8 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "clsx": "^2.0.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index e884bc9fa1..75bf6877ad 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -27,7 +27,9 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-client-ui-slots": "workspace:^" + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "react": "^18.2.0", + "use-sync-external-store": "1.2.0" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -36,9 +38,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "use-sync-external-store": "1.2.0" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/web/package.json b/packages/client/web/package.json index d128b3219d..9e4e9481e0 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -33,7 +33,9 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-client-web-react": "workspace:^" + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -43,9 +45,7 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", - "typescript": "^6.0.3", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "typescript": "^6.0.3" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index ecc0bca76d..74f7970bbc 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -54,7 +54,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 7bcb487bf1..cacd075a58 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -61,7 +61,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 1ff514d0ae..b84dede798 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -2,9 +2,7 @@ "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", "version": "0.1.0-rc.6", - "publishConfig": { - "access": "public" - }, + "publishConfig": { "access": "public" }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", @@ -14,31 +12,14 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts" - ], - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, + "files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"], + "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }, "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -49,7 +30,8 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7974b33ec..1490a0f9f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -358,6 +358,12 @@ importers: '@deepseek-ai/dsh-client-web': specifier: workspace:^ version: link:../../packages/client/web + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ @@ -398,12 +404,6 @@ importers: playwright: specifier: ^1.49.0 version: 1.61.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1607,6 +1607,15 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + immer: + specifier: ^10.1.1 + version: 10.2.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + zustand: + specifier: ~4.4.7 + version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1629,15 +1638,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - immer: - specifier: ^10.1.1 - version: 10.2.0 - react: - specifier: ^18.2.0 - version: 18.3.1 - zustand: - specifier: ~4.4.7 - version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) packages/client/schema-form: dependencies: @@ -1705,6 +1705,15 @@ importers: '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1718,17 +1727,12 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + + packages/client/ui-commands: + dependencies: clsx: specifier: ^2.0.0 version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - - packages/client/ui-commands: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1769,9 +1773,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -1784,6 +1785,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1869,14 +1873,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-deliverables: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1908,11 +1913,12 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/client/ui-directory-picker-browse: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1944,9 +1950,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2033,6 +2036,10 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-input-trigger: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2058,14 +2065,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-jobs: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2094,9 +2102,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/client/ui-layout: devDependencies: @@ -2321,25 +2326,13 @@ importers: version: 18.3.1 packages/client/ui-primitives: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants + dependencies: '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@18.3.31) anser: specifier: ^2.3.5 version: 2.3.5 @@ -2394,6 +2387,19 @@ importers: shiki: specifier: ^4.3.1 version: 4.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) packages/client/ui-settings: dependencies: @@ -2440,6 +2446,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2480,9 +2489,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2575,6 +2581,10 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-settings-plugins: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2612,14 +2622,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-sidebar: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2648,9 +2659,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2716,6 +2724,10 @@ importers: version: 18.3.31 packages/client/ui-subagent: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2753,9 +2765,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/client/ui-theme: dependencies: @@ -2768,6 +2777,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2802,14 +2814,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-tool: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2850,9 +2863,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -2861,6 +2871,13 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-trajectory: + dependencies: + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + diff: + specifier: ^9.0.0 + version: 9.0.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2895,18 +2912,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@tanstack/react-virtual': - specifier: ^3.14.9 - version: 3.14.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ~18.3.1 version: 18.3.31 '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - diff: - specifier: ^9.0.0 - version: 9.0.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -2931,6 +2942,12 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2959,14 +2976,12 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 + + packages/client/ui-workflow-run: + dependencies: react: specifier: ^18.2.0 version: 18.3.1 - - packages/client/ui-workflow-run: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3004,11 +3019,12 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 packages/client/ui-workspace: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3040,9 +3056,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -3070,6 +3083,12 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../web-react + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3092,12 +3111,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -3107,6 +3120,12 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + react: + specifier: ^18.2.0 + version: 18.3.1 + use-sync-external-store: + specifier: 1.2.0 + version: 1.2.0(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3117,12 +3136,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - use-sync-external-store: - specifier: 1.2.0 - version: 1.2.0(react@18.3.1) packages/code-runtime/code-runtime: devDependencies: diff --git a/scripts/browser-bundled-externals.ts b/scripts/browser-bundled-externals.ts deleted file mode 100644 index 1ed61b1704..0000000000 --- a/scripts/browser-bundled-externals.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * The external packages a published browser artifact carries a copy of. - * - * Read from the real build configurations rather than declared by hand: each - * `lib/client.js` plugin bundle is driven through its own `tsdown.config.ts`, and - * the shell `dist` through `apps/web`'s Vite config. A recording plugin resolves - * every bare specifier as external and notes it, so the pass walks our own source - * and stops at the package boundary — which is both fast (about two seconds for - * the whole repository) and exactly the direct-dependency granularity - * THIRD_PARTY_NOTICES.md discloses. Erased type imports never appear, because the - * transform drops them before resolution. - * - * Workspace names are followed only on the Vite side, where the shell's aliases - * map them to source: that is how a browser-only library's own third-party - * imports — katex and shiki through `ui-primitives`, for one — become visible. A - * plugin bundle keeps them external, matching the frozen module table it is built - * against; the wire layers it inlines are host packages that declare their own - * dependencies, so nothing goes undisclosed. - * - * A specifier is recorded only once the host resolves it to a file inside a - * package. A bundler's own virtual module has no package behind it — - * `vite/modulepreload-polyfill` is generated by a Vite plugin rather than shipped - * as a file, so the polyfill in the published `dist` is build glue in the same - * category as an emitted TypeScript helper, not a redistributed copy of Vite. - * - * The pass runs on a clean tree, as a static gate must. The shell's Vite config - * aliases a few workspace packages to source; every other workspace name would - * resolve through `node_modules` to a `lib/` entry the real build has emitted but - * a clean checkout has not, so this module resolves those names to their own - * source instead. `lib/` is compiled from `src/`, so the third-party edges the - * pass records are the same either way. - * - * rolldown is resolved through tsdown deliberately: the dry run must use the - * exact bundler the real build uses, which a separate root pin could drift from. - */ - -import { existsSync, globSync, readFileSync } from 'node:fs' -import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' - -/** The plugin-context member the recorder needs to resolve before recording. */ -interface ResolveContext { - resolve: ( - source: string, - importer: string, - options: { skipSelf: boolean }, - ) => Promise<{ id: string } | null> -} - -/** A rolldown/Vite plugin shape, narrowed to what the recorder needs. */ -interface RecorderPlugin { - name: string - enforce?: 'pre' - resolveId: ( - this: ResolveContext, - source: string, - importer: string | undefined, - ) => Promise<{ id: string; external: true } | null> -} - -/** - * The package a resolved module file belongs to. - * @param file - absolute path of a resolved module. - * @returns the package name, or undefined when the file is not inside a package. - */ -function packageOfFile(file: string): string | undefined { - const marker = file.lastIndexOf('node_modules/') - if (marker < 0) return undefined - const rest = file.slice(marker + 'node_modules/'.length) - const parts = rest.split('/') - return rest.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] -} - -/** - * Source aliases for the workspace packages the shell does not already alias. - * - * A clean checkout has no `lib/`, so a workspace name would otherwise resolve - * through `node_modules` to an entry that does not exist yet. Aliases are the - * right seam rather than a plugin hook, because Vite resolves a stylesheet - * `@import` through them too — the theme package publishes its stylesheets from - * `lib/styles/`. `lib/` is compiled from `src/`, so the third-party edges the - * pass records are the same either way. - * @param root - repository root. - * @param existing - the shell's own alias patterns, whose entry choices win. - * @returns alias entries mapping each remaining workspace name to its source. - */ -function workspaceSourceAliases(root: string, existing: readonly string[]): { find: RegExp | string; replacement: string }[] { - const aliases: { find: RegExp | string; replacement: string }[] = [] - for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) { - for (const relative of globSync(pattern, { cwd: root })) { - const dir = join(root, dirname(relative)) - const manifest = JSON.parse(readFileSync(join(root, relative), 'utf8')) as Manifest & { name?: string } - const name = manifest.name - if (name === undefined || !existsSync(join(dir, 'src'))) continue - if (existing.some(find => find.includes(name))) continue - const root_ = manifest.exports?.['.'] - const target = typeof root_ === 'string' ? root_ : root_?.default - const stem = (target ?? './lib/index.js') - .replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '') - const entry = [`${stem}.ts`, `${stem}.tsx`, `${stem}/index.ts`, `${stem}/index.tsx`] - .map(candidate => join(dir, 'src', candidate)) - .find(candidate => existsSync(candidate)) - // The subpath prefix carries `./client`, `./types`, and `./styles/*` alike: - // each published subpath mirrors a path under `src/`. - aliases.push({ find: `${name}/`, replacement: `${join(dir, 'src')}/` }) - if (entry !== undefined) aliases.push({ find: new RegExp(`^${name.replaceAll('/', '\\/')}$`), replacement: entry }) - } - } - return aliases -} - -/** - * Build the plugin that records bare specifiers and stops the walk at them. - * @param seen - set the recorder adds package names to. - * @returns the recording plugin. - */ -function recorder(seen: Set): RecorderPlugin { - return { - name: 'dsh-record-direct-externals', - enforce: 'pre', - async resolveId(source, importer) { - if (importer === undefined) return null // the entry itself - if (source.startsWith('.') || source.startsWith('/') || source.startsWith('\0')) return null - if (source.startsWith('virtual:') || source.includes('?')) return null - // A workspace name that reaches here is one no alias mapped to source, so - // nothing of ours is left to walk; it is never a third-party disclosure. - if (source.startsWith('@deepseek-ai/')) return { id: source, external: true } - if (source.startsWith('node:')) return { id: source, external: true } - if (!source.startsWith('@deepseek-ai/')) { - const resolved = await this.resolve(source, importer, { skipSelf: true }) - const name = resolved === null ? undefined : packageOfFile(resolved.id) - if (name !== undefined) seen.add(name) - } - return { id: source, external: true } - }, - } -} - -interface Manifest { - exports?: Record - files?: string[] -} - -/** Read one workspace manifest. */ -function manifestOf(dir: string): Manifest { - return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Manifest -} - -/** Whether a manifest publishes a tsdown browser bundle at `lib/client.js`. */ -function publishesClientBundle(manifest: Manifest): boolean { - const target = manifest.exports?.['./client'] - return typeof target === 'object' && target !== null && target.default === './lib/client.js' -} - -/** - * Record every external package the plugin client bundles carry. - * @param root - repository root. - * @param seen - set the recorder adds package names to. - */ -async function collectFromClientBundles(root: string, seen: Set): Promise { - const requireFromTsdown = createRequire(createRequire(import.meta.url).resolve('tsdown')) - const { rolldown } = await import(requireFromTsdown.resolve('rolldown')) as { - rolldown: (options: Record) => Promise<{ - generate: (output: Record) => Promise - close: () => Promise - }> - } - - for (const relative of globSync('packages/*/*/tsdown.config.ts', { cwd: root }).sort()) { - const dir = join(root, dirname(relative)) - if (!publishesClientBundle(manifestOf(dir))) continue - const loaded = await import(join(root, relative)) as { default: unknown } - const factory = loaded.default - const configs = (typeof factory === 'function' - ? (factory as (inline: { env: Record }) => unknown[])({ env: {} }) - : [factory]) as { name?: string; entry?: unknown; plugins?: unknown[] }[] - // The `/client` config is the browser bundle; its siblings emit the node half. - const client = configs.find(config => config.name?.endsWith('/client') === true) - if (client === undefined) continue - const bundle = await rolldown({ - cwd: dir, - input: client.entry, - plugins: [recorder(seen), ...(client.plugins ?? [])], - platform: 'browser', - }) - await bundle.generate({ format: 'cjs', minify: false, sourcemap: false }) - await bundle.close() - } -} - -/** - * Record every external package the prebuilt shell bundle carries. - * @param root - repository root. - * @param seen - set the recorder adds package names to. - */ -async function collectFromShellBundle(root: string, seen: Set): Promise { - for (const relative of globSync('apps/*/vite.config.ts', { cwd: root }).sort()) { - const dir = join(root, dirname(relative)) - // Vite belongs to the app that builds with it, so it resolves from there. - const { build, resolveConfig } = await import(createRequire(join(dir, 'package.json')).resolve('vite')) as { - build: (options: Record) => Promise - resolveConfig: (options: Record, command: string) => Promise<{ - resolve: { alias: { find: string | RegExp }[] } - }> - } - // The shell already aliases some workspace names to source, and its entry - // choices win: a stylesheet `@import` resolves through aliases rather than a - // plugin hook, so only the names it leaves out get one from here. - const resolved = await resolveConfig({ root: dir, logLevel: 'error' }, 'build') - await build({ - root: dir, - logLevel: 'error', - plugins: [recorder(seen)], - resolve: { alias: workspaceSourceAliases(root, resolved.resolve.alias.map(entry => String(entry.find))) }, - build: { write: false, minify: false, sourcemap: false, reportCompressedSize: false }, - }) - } -} - -/** - * The external packages a published browser artifact carries a copy of. - * @param root - repository root. - * @returns package names, workspace names excluded. - */ -export async function browserBundledExternals(root: string): Promise> { - const seen = new Set() - await collectFromClientBundles(root, seen) - await collectFromShellBundle(root, seen) - return seen -} diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 5cd4f6b7fa..479a2f13b1 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -24,12 +24,11 @@ describe('THIRD_PARTY_NOTICES.md', () => { // already runs in the test lane, so the check costs no extra CI process. // Pre-commit regenerates the file whenever a manifest is staged, so reaching // this assertion means the notices were committed without that hook. - it('matches what the generator produces from the current manifests', async () => { - const generated = await render() + it('matches what the generator produces from the current manifests', () => { + const generated = render() expect(generated).toContain('It depends on the third-party software listed below.') expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated) - // Driving the two real bundlers to learn what ships costs a few seconds. - }, 60_000) + }) }) /** Build the (manifests, names) pair `tierExternalDeps` consumes. */ @@ -68,23 +67,6 @@ describe('tierExternalDeps', () => { ])) }) - it('keeps a devDependency runtime when a published browser artifact carries it', () => { - const { manifests, names } = workspace({ - // The client build inlines these, so a copy ships even though no manifest - // resolves the specifier at run time. - 'packages/client/ui-primitives/package.json': { - name: '@deepseek-ai/dsh-client-ui-primitives', - devDependencies: { katex: '^0.16', 'test-only-helper': '^1' }, - }, - }) - - expect(tierExternalDeps(manifests, names, new Set(['katex']))).toEqual(new Map([ - ['tsx', true], - ['katex', true], - ['test-only-helper', false], - ])) - }) - it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => { const { manifests, names } = workspace({ 'package.json': { devDependencies: { shared: '^1' } }, diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 1762e2d125..f2411f5eff 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -13,7 +13,6 @@ import { resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' -import { browserBundledExternals } from './browser-bundled-externals.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'THIRD_PARTY_NOTICES.md' @@ -348,17 +347,15 @@ function normalizeRepo(raw: string | undefined): string | undefined { } /** - * External npm dependencies, tiered by what reaches a user: a package is runtime - * when any manifest outside `DEV_ONLY_AREAS` names it in - * `dependencies`/`optionalDependencies`, or when a published browser artifact - * carries a copy of it. A package declared only by tooling, test infrastructure, - * the website, or the demo leaves — whatever the declaring section is called, and - * with no shipped artifact carrying it — is development-only. - * @returns every external dependency with its tier and metadata. + * External npm dependencies, tiered by which workspace area declares them at + * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS` + * names it in `dependencies`/`optionalDependencies`. A package declared only + * by tooling, test infrastructure, the website, or the demo leaves — whatever + * the declaring section is called — is development-only. */ -async function collectNpmDeps(): Promise { +function collectNpmDeps(): ExternalDep[] { const { manifests, names } = loadWorkspaceManifests() - return [...tierExternalDeps(manifests, names, await browserBundledExternals(root))] + return [...tierExternalDeps(manifests, names)] .filter(([name]) => !FIRST_PARTY.has(name)) .sort(([a], [b]) => a.localeCompare(b)) .map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime })) @@ -366,23 +363,11 @@ async function collectNpmDeps(): Promise { /** * Tier every external dependency the workspace declares. - * - * A package a published browser artifact carries is runtime whatever section - * declares it: the client build inlines its code, or the shell `dist` answers it - * from the frozen module table, so a copy is redistributed even though nothing on - * a user's machine resolves the specifier. Those packages are declared as - * `devDependencies` — `verify-client-runtime-deps` owns that rule — and tiering - * them by section alone would understate the notice. * @param manifests - workspace manifests keyed by repository-relative path. * @param names - every workspace package name, which never counts as external. - * @param bundled - external packages a published browser artifact carries. * @returns each external package mapped to whether it is a runtime dependency. */ -export function tierExternalDeps( - manifests: Map, - names: Set, - bundled: ReadonlySet = new Set(), -): Map { +export function tierExternalDeps(manifests: Map, names: Set): Map { const tiers = new Map() // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook. tiers.set('tsx', true) @@ -391,7 +376,7 @@ export function tierExternalDeps( for (const kind of ALL_KINDS) { for (const [dep, range] of Object.entries(manifest[kind] ?? {})) { if (names.has(dep) || range.startsWith('workspace:')) continue - const runtime = bundled.has(dep) || (!devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)) + const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind) tiers.set(dep, (tiers.get(dep) ?? false) || runtime) } } @@ -675,9 +660,9 @@ ${rows.join('\n')} * Render the complete notices document. * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. */ -export async function render(): Promise { +export function render(): string { verifyBuildTimePins() - const npm = await collectNpmDeps() + const npm = collectNpmDeps() const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) const vendored = collectVendored() @@ -722,7 +707,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.u ## Runtime npm dependencies -External packages that reach a user: a workspace package resolves them at runtime, or a published browser artifact carries a copy of their code. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default — and the packages the client build inlines into a plugin bundle or the shell \`dist\`, which are declared as \`devDependencies\` because nothing on a user's machine resolves their specifiers. +External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. ${renderNpmTable(runtimeDeps)} @@ -733,7 +718,7 @@ ${renderClaudeDistribution(claudeDistribution)} ## Development-only npm dependencies -External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace, and carried by no published artifact. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. +External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. ${renderNpmTable(devDeps)} ${renderNonPermissiveNote(nonPermissiveDev)} @@ -761,8 +746,8 @@ ${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.lice /** CLI entry: default writes the notices, `--check` fails if the committed copy * is stale. Guarded behind an entry-point check so importing this module for * tests neither regenerates the committed file nor calls process.exit. */ -async function main(): Promise { - const content = await render() +function main(): void { + const content = render() if (process.argv.includes('--check')) { let committed: string | null = null try { @@ -786,5 +771,5 @@ async function main(): Promise { // Run only when invoked as a script, not when imported by a test. if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - await main() + main() } diff --git a/scripts/verify-client-runtime-deps.ts b/scripts/verify-client-runtime-deps.ts deleted file mode 100644 index 0d42f8c135..0000000000 --- a/scripts/verify-client-runtime-deps.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Keep browser-only external packages out of installed dependency sections. - * - * A browser artifact resolves nothing on the user's machine: tsdown inlines - * every non-platform specifier into `lib/client.js`, the shell `dist` answers - * `PLATFORM_MODULES` from its frozen module table, and Vite inlines the shell's - * own imports into that published `dist`. A specifier only browser source - * reaches is therefore a build-time input and belongs in `devDependencies`, - * because npm installs `dependencies` and non-optional `peerDependencies` for - * every consumer of the published package. - * - * Each face is walked from the entries the manifest publishes, not by a - * directory rule, so a module under `src/` that only the browser entry reaches - * counts as browser source: - * - * `./client` is `lib/client.js` host: the other export targets; browser: the bundle - * `packages/client/*` with no browser-only library: host is `src/invariant.ts`, - * `./client` export the companion the host mounts; `.` is browser code - * no `.` export, ships a `dist` prebuilt browser bundle: no host face at all - * - * Only external packages are subject: they are what an install downloads. A - * workspace name stays where its manifest puts it, because that declaration also - * states which package supplies an injected service or a mounted Remote - * contribution, and the app installs it either way. A reference from the host - * face, an erased type import included, likewise keeps a declaration in place. - * - * Run: pnpm exec tsx scripts/verify-client-runtime-deps.ts [--json] - */ - -import { existsSync, globSync, readFileSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' -import ts from 'typescript' -import { TypeScriptProject, type CompilerFace } from './ts-project.ts' - -const root = resolve(import.meta.dirname, '..') - -/** - * `@deepseek-ai/cordis` placement belongs to check-workspace-constraints, which - * requires it as a peerDependency plus devDependency of every harness package - * regardless of face. - */ -const PLACEMENT_OWNED_ELSEWHERE = new Set(['@deepseek-ai/cordis']) - -/** Dependency sections npm installs for a consumer of the published package. */ -const INSTALLED_SECTIONS = ['dependencies', 'peerDependencies'] as const - -type Section = (typeof INSTALLED_SECTIONS)[number] - -interface Manifest { - name?: string - files?: string[] - exports?: Record - dependencies?: Record - peerDependencies?: Record - peerDependenciesMeta?: Record -} - -/** How a package reaches the browser, which fixes the entries Node can load. */ -type Kind = 'bundle-half' | 'browser-library' | 'prebuilt-dist' - -/** What settles an external specifier as build-time only. */ -type Reached = 'browser' | 'nothing' - -interface Violation { - readonly section: Section - readonly dep: string - readonly reached: Reached - /** Whether the browser face names it, which decides dev-move versus deletion. */ - readonly browserReferenced: boolean -} - -interface Offender { - readonly name: string - readonly dir: string - readonly kind: Kind - readonly violations: Violation[] -} - -/** Why each class needs no install, for the failure report. */ -const REASON: Record = { - browser: 'only a browser artifact reaches it, and that resolves nothing on the user machine', - nothing: 'no reference names it at all', -} - -/** - * Classify a package by the browser artifact it produces. - * @param dir - repository-relative package directory. - * @param manifest - the package manifest. - * @returns the package kind, or undefined when the package has no browser face. - */ -function kindOf(dir: string, manifest: Manifest): Kind | undefined { - if (manifest.exports?.['./client'] !== undefined) return 'bundle-half' - if (dir.startsWith('packages/client/')) return 'browser-library' - const shipsDist = (manifest.files ?? []).some(entry => entry === 'dist' || entry.startsWith('dist/')) - if (shipsDist && manifest.exports?.['.'] === undefined) return 'prebuilt-dist' - return undefined -} - -/** The bare package name a specifier names, keeping a leading scope. */ -function packageOf(specifier: string): string { - const parts = specifier.split('/') - return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] ?? specifier -} - -/** One compiler face's bound program plus its module resolution state. */ -interface Face { - readonly project: TypeScriptProject - readonly host: ts.CompilerHost - readonly cache: ts.ModuleResolutionCache -} - -const faces = new Map() -for (const face of ['host', 'client'] as const) { - const project = new TypeScriptProject(root, face) - const options = project.program.getCompilerOptions() - faces.set(face, { - project, - host: ts.createCompilerHost(options, false), - cache: ts.createModuleResolutionCache(root, fileName => fileName, options), - }) -} - -/** Which face's program bound each workspace module, keyed by absolute path. */ -const boundIn = new Map() -for (const [face, { project }] of faces) { - for (const sourceFile of project.sourceFiles()) { - if (sourceFile.isDeclarationFile) continue - if (!boundIn.has(sourceFile.fileName)) boundIn.set(sourceFile.fileName, face) - } -} - -/** - * Read every module specifier one source file names. - * - * An import clause is not the only way to reach a package: `require`, - * `require.resolve`, and a dynamic `import()` on a literal each name one, and a - * type-only import still names a package the build must resolve. - * @param sourceFile - a bound source file. - * @returns every specifier, relative ones included. - */ -function specifiersOf(sourceFile: ts.SourceFile): string[] { - const specifiers: string[] = [] - const visit = (node: ts.Node): void => { - if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isImportEqualsDeclaration(node)) { - const specifier = ts.isImportEqualsDeclaration(node) - ? (ts.isExternalModuleReference(node.moduleReference) ? node.moduleReference.expression : undefined) - : node.moduleSpecifier - if (specifier !== undefined && ts.isStringLiteralLike(specifier)) specifiers.push(specifier.text) - } else if (ts.isCallExpression(node)) { - const target = node.expression - const isRequire = ts.isIdentifier(target) && target.text === 'require' - const isRequireResolve = ts.isPropertyAccessExpression(target) - && ts.isIdentifier(target.expression) && target.expression.text === 'require' - && target.name.text === 'resolve' - const argument = node.arguments[0] - if ((isRequire || isRequireResolve || target.kind === ts.SyntaxKind.ImportKeyword) - && argument !== undefined && ts.isStringLiteralLike(argument)) { - specifiers.push(argument.text) - } - } - ts.forEachChild(node, visit) - } - visit(sourceFile) - return specifiers -} - -/** - * Walk one face from its entries and collect the packages it names. - * @param entries - absolute entry module paths. - * @param packageDir - absolute package directory; the walk stops at its edge. - * @returns package names the walk reaches. - */ -function walk(entries: readonly string[], packageDir: string): Set { - const found = new Set() - const seen = new Set() - const queue = entries.filter(entry => boundIn.has(entry)) - while (queue.length > 0) { - const file = queue.pop() - if (file === undefined || seen.has(file)) continue - seen.add(file) - const faceName = boundIn.get(file) - const face = faceName === undefined ? undefined : faces.get(faceName) - const sourceFile = face?.project.program.getSourceFile(file) - if (face === undefined || sourceFile === undefined) continue - - for (const specifier of specifiersOf(sourceFile)) { - if (!specifier.startsWith('.')) { - if (!specifier.startsWith('node:')) found.add(packageOf(specifier)) - continue - } - const resolved = ts.resolveModuleName( - specifier, file, face.project.program.getCompilerOptions(), face.host, face.cache, - ).resolvedModule?.resolvedFileName - // A relative specifier resolving outside the package is a packaging error - // verify-package-paths owns; either way it is not this package's own module. - if (resolved !== undefined && resolved.startsWith(`${packageDir}/`)) queue.push(resolved) - } - } - return found -} - -/** - * The source module behind one published JavaScript export target. - * - * `lib/` holds the tsdown bundles and `lib/types/` the tsc emit, so both - * prefixes lead back to one `src` module. - * @param dir - absolute package directory. - * @param emitted - the export target, as written in the manifest. - * @returns the absolute source path, or undefined when nothing in `src` emits it. - */ -function sourceBehind(dir: string, emitted: string): string | undefined { - const stem = emitted.replace(/^\.\/lib\/types\//, '').replace(/^\.\/lib\//, '').replace(/\.js$/, '') - return [`src/${stem}.ts`, `src/${stem}.tsx`, `src/${stem}/index.ts`, `src/${stem}/index.tsx`] - .map(candidate => join(dir, candidate)) - .find(candidate => existsSync(candidate)) -} - -interface Entries { - readonly host: string[] - readonly browser: string[] - /** - * Published JavaScript entries no `src` module emits — a generated artifact - * such as `lib/typert.host.js`, whose own runtime imports are invisible here. - */ - readonly generated: string[] -} - -/** - * The entry modules of each face, derived from what the manifest publishes. - * @param dir - absolute package directory. - * @param manifest - the package manifest. - * @param kind - the package kind. - * @returns absolute entry module paths per face, plus unmapped published entries. - */ -function faceEntries(dir: string, manifest: Manifest, kind: Kind): Entries { - if (kind === 'prebuilt-dist') return { host: [], browser: [], generated: [] } - if (kind === 'browser-library') { - return { host: [join(dir, 'src/invariant.ts')], browser: [join(dir, 'src/index.ts')], generated: [] } - } - const host = [join(dir, 'src/index.ts'), join(dir, 'src/invariant.ts')] - const browser: string[] = [] - const generated: string[] = [] - for (const [key, target] of Object.entries(manifest.exports ?? {})) { - if (key === '.' || key === './package.json' || key.includes('*')) continue - const emitted = typeof target === 'string' ? target : (target as { default?: unknown }).default - if (typeof emitted !== 'string' || !emitted.endsWith('.js')) continue - // Keyed on the artifact path, not the subpath name: `./client` is the tsdown - // browser bundle only when it resolves to lib/client.js, while other packages - // publish a plain browser-shared module under the same subpath. - const source = emitted === './lib/client.js' - ? sourceBehind(dir, './lib/client/index.js') - : sourceBehind(dir, emitted) - if (source === undefined) generated.push(`${key} -> ${emitted}`) - else if (key === './client') browser.push(source) - else host.push(source) - } - return { host, browser, generated } -} - -/** Every installed dependency of a manifest, paired with its section. */ -function installedDeps(manifest: Manifest): { section: Section; dep: string }[] { - const deps: { section: Section; dep: string }[] = [] - for (const section of INSTALLED_SECTIONS) { - for (const dep of Object.keys(manifest[section] ?? {})) { - if (section === 'peerDependencies' && manifest.peerDependenciesMeta?.[dep]?.optional === true) continue - if (PLACEMENT_OWNED_ELSEWHERE.has(dep)) continue - deps.push({ section, dep }) - } - } - return deps -} - -/** - * Test whether a Loader config names a package as a whole word. - * @param text - raw config text. - * @param dep - package name to look for. - * @returns true when the name appears outside a longer specifier. - */ -function namesPackage(text: string, dep: string): boolean { - const escaped = dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return new RegExp(`(^|[^\\w@/.-])${escaped}(?![\\w.-])`).test(text) -} - -interface Candidate { - readonly name: string - readonly relativeDir: string - readonly manifest: Manifest - readonly kind: Kind -} - -const candidates: Candidate[] = [] -for (const path of [ - ...globSync('packages/*/*/package.json', { cwd: root }), - ...globSync('apps/*/package.json', { cwd: root }), -].sort()) { - const relativeDir = dirname(path) - const manifest = JSON.parse(readFileSync(join(root, path), 'utf8')) as Manifest - if (manifest.name === undefined) continue - const kind = kindOf(relativeDir, manifest) - if (kind !== undefined) candidates.push({ name: manifest.name, relativeDir, manifest, kind }) -} - -const offenders: Offender[] = [] -const unchecked: string[] = [] -for (const { name, relativeDir, manifest, kind } of candidates) { - const dir = join(root, relativeDir) - const entries = faceEntries(dir, manifest, kind) - // A generated Node entry carries runtime imports of its own that no source - // states, so this package's declarations cannot be judged from `src` alone. - if (entries.generated.length > 0) { - unchecked.push(`${name}: generated entry ${entries.generated.join(', ')}`) - continue - } - const host = walk(entries.host, dir) - const browser = walk(entries.browser, dir) - // A Loader row names its plugin package instead of importing it, so a config - // the package owns is part of its host face. YAML keys carry no quotes, so - // these are matched as whole names against the raw text. - const configs = globSync('cordis*.yml', { cwd: dir }).map(config => readFileSync(join(dir, config), 'utf8')) - - const violations = installedDeps(manifest) - // A workspace name stays where the manifest puts it. Such a declaration also - // states which package supplies an injected service, which Remote contribution - // an assembly mounts, or which Loader row must resolve; the app installs the - // package regardless, so moving one saves no download while deleting what - // verify-runtime-closure and the Loader read. External packages are the - // download, and this gate is about the download. - .filter(({ dep }) => !dep.startsWith('@deepseek-ai/')) - .filter(({ dep }) => !host.has(dep) && !configs.some(text => namesPackage(text, dep))) - .map(({ section, dep }) => ({ - section, - dep, - browserReferenced: browser.has(dep), - // A prebuilt bundle publishes no Node entry, so everything it declares is - // build-time by construction, named in its Vite graph rather than in src. - reached: kind === 'prebuilt-dist' || browser.has(dep) ? 'browser' as const : 'nothing' as const, - })) - if (violations.length > 0) offenders.push({ name, dir: relativeDir, kind, violations }) -} - -if (process.argv.includes('--json')) { - console.log(JSON.stringify(offenders, null, 2)) - process.exit(0) -} - -if (unchecked.length > 0) { - console.log(`verify-client-runtime-deps: ${String(unchecked.length)} package(s) not checked, no source states their entry's imports:`) - for (const entry of unchecked) console.log(` ${entry}`) -} - -if (offenders.length > 0) { - const all = offenders.flatMap(offender => offender.violations) - console.error(`verify-client-runtime-deps: ${String(all.length)} build-time specifier(s) in installed sections:`) - for (const { name, dir, kind, violations } of offenders) { - console.error(` ${name} (${dir}, ${kind})`) - for (const { section, dep, reached } of violations) { - console.error(` ${section}.${dep} -> devDependencies [${reached}]`) - } - } - console.error('') - for (const reached of ['browser', 'nothing'] as const) { - const count = all.filter(violation => violation.reached === reached).length - if (count > 0) console.error(` ${String(count).padStart(4)} ${reached}: ${REASON[reached]}`) - } - console.error('\nDeclaration rules: packages/client/AGENTS.md.') - process.exit(1) -} -console.log(`verify-client-runtime-deps: browser-face specifiers are dev-only across ${String(candidates.length)} browser-facing packages.`) From a8dc6f9776d20d2e846e8373628ffd1a03808c84 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 15 Aug 2026 11:06:57 +0800 Subject: [PATCH 079/146] fix(pty): keep the controlled prompt so persistent bash settles fast tool-bash-persistent overwrote the backend's PS1, so terminal-bash prompt readiness never matched and every send degraded to the 3.5s silence tier (idleSilenceMs + handoffGraceMs) under production defaults. The controlled PROMPT_COMMAND now re-asserts PS1 before every prompt, so an in-shell override never survives to the next prompt. The tool initializes with stty -echo alone and detects the no-end-marker fallback through the seam's stdin_read wait reason instead of matching its own prompt text. Tool calls drop from 7180/3560/3566 ms to 355/88/91 ms (spawn+init+echo, echo, pwd; darwin, production defaults). The loader composition suite now pins the fast path by pushing idleSilenceMs beyond the send bound, and a real-PTY case proves PS1 self-healing. Fixes #2585 --- ...ent-bash-keeps-controlled-prompt.i18n.yaml | 6 ++++ ...persistent-bash-keeps-controlled-prompt.md | 35 +++++++++++++++++++ ...sistent-bash-keeps-controlled-prompt.zh.md | 35 +++++++++++++++++++ docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tool-bash-persistent/README.i18n.yaml | 4 +-- packages/shell/tool-bash-persistent/README.md | 3 +- .../shell/tool-bash-persistent/README.zh.md | 3 +- .../shell/tool-bash-persistent/src/index.ts | 33 ++++++++--------- .../tests/loader-composition.spec.ts | 11 +++++- .../tool-bash-persistent/tests/tools.spec.ts | 13 ++++--- .../terminal/terminal-bash/README.i18n.yaml | 4 +-- packages/terminal/terminal-bash/README.md | 2 +- packages/terminal/terminal-bash/README.zh.md | 2 +- packages/terminal/terminal-bash/src/index.ts | 5 ++- .../terminal-bash/tests/index.spec.ts | 1 + .../terminal-bash/tests/local.spec.ts | 20 +++++++++++ 18 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.i18n.yaml new file mode 100644 index 0000000000..b981cd7c0b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.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 .agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md +2026-08-15-persistent-bash-keeps-controlled-prompt.md: 9ee71f2adc0d473c5490dbe2b29ce55c8377e275 +2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md: 48b2cc323e745a184cefc9603d461fe8fb27e15e diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md b/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md new file mode 100644 index 0000000000..9ee71f2adc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.md @@ -0,0 +1,35 @@ +# Agent Note: Persistent bash keeps the backend's controlled prompt + +Status: implemented + +English | [中文](2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md) + +## Problem + +`dsh-tool-bash-persistent` initialized its shell with `stty -echo; PS1='__DSH_PERSISTENT_BASH_PROMPT__ '`, overwriting the `PS1` that `dsh-terminal-bash` sets in the spawn environment. The backend's prompt readiness requires the printable tail after the OSC `133;D` marker to exactly equal the controlled prompt ([design](../feature/2026-07-16-persistent-pty-sessions.md)), so after initialization no send could ever settle through it. `PROMPT_COMMAND` survived the override, so the marker kept arriving and every send paid the silence tier plus handoff grace — 3.5 s per tool call under production defaults, 7.2 s for the first call because the initialization send degraded too, and an extra 3.5 s tail after every long command. macOS has no exact stdin-wait tier, and on Linux the exact probe cannot observe a sub-poll-interval command leaving its stdin wait, so the degradation applied to effectively every call. Package tests masked it by configuring `idleSilenceMs: 100`. + +The override existed to give the tool a known prompt for two consumers: a viewport-suffix fallback that detected "shell at a prompt without the end marker", and cosmetic stripping of prompt text from partial output. + +## Decision + +The backend owns its prompt protocol and repairs it itself: the controlled `PROMPT_COMMAND` re-asserts `PS1` after printing the marker, so any in-shell prompt override — this tool's former initialization, a model command, a sourced script — lasts zero prompts. This also protects providers that cannot report foreground state, where the exact prompt text is the only readiness evidence. + +The tool stops overwriting `PS1` (initialization is `stty -echo` alone) and replaces its viewport-suffix fallback with the seam's existing signal: a send that settles as `stdin_read` without the end marker in scrollback returns the captured partial output. The private prompt constant and its stripping are deleted; partial output may now end with the backend's own prompt text, which the tool cannot and should not know. + +## Alternatives considered + +**Fix only the tool, leaving `PROMPT_COMMAND` unchanged.** Rejected because the seam would stay silently fragile: any later consumer or model command that touches `PS1` reintroduces the 3.5 s degradation with no failing signal, and providers without foreground inspection lose their only readiness factor. + +**Import the controlled prompt into the tool.** Rejected because the prompt is one provider's protocol constant; a Consumer matching it would couple the tool to `dsh-terminal-bash` specifically, and any other mounted backend would break it again. + +**Drop the prompt-text factor from backend readiness.** Rejected because for providers whose `inspectForeground` reports nothing, marker-plus-text is the defense against command output that embeds the raw OSC marker sequence; weakening it trades a fast path for a false-settle risk. + +**Widen `handoffGraceMs`/`idleSilenceMs` tuning instead.** Rejected because no silence value fixes a dead fast path; it only rebalances how much every call overpays. + +## Consequences + +Measured on darwin with production defaults: raw sends settle in ~86 ms with the controlled prompt intact versus ~3540 ms after an override; tool calls drop from 7180/3560/3566 ms to 355/88/91 ms for spawn+init+echo, echo, and pwd. + +The `stdin_read` fallback is behavior, not only cosmetics: after `exec`, an interrupt, or an interactive foreground child whose stdin wait the provider proves (the Linux exact tier), the call now returns captured partial output instead of spinning to the command deadline. Where no provider proves the wait (macOS), an interactive child still runs to `timeoutMs` — recorded as a known limitation in the tool README. Partial output can carry the backend's trailing prompt; complete marker-delimited output is byte-identical to before, which the keyless jsonrpc-agent snapshots confirm. + +The loader-composition suite now sets `idleSilenceMs` above the send bound, so silence can settle nothing and every case fails if prompt readiness regresses; a real-PTY case overrides `PS1` in-shell and requires the next send to settle as `stdin_read` with the healed prompt. The self-repair cannot survive a command that overwrites `PROMPT_COMMAND` itself; the silence tier remains the bound there, unchanged from the prior design. diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md b/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md new file mode 100644 index 0000000000..48b2cc323e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-15-persistent-bash-keeps-controlled-prompt.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 持久 bash 保留后端的受控提示符 + +Status: implemented + +[English](2026-08-15-persistent-bash-keeps-controlled-prompt.md) | 中文 + +## Problem + +`dsh-tool-bash-persistent` 用 `stty -echo; PS1='__DSH_PERSISTENT_BASH_PROMPT__ '` 初始化其 shell,覆盖了 `dsh-terminal-bash` 在 spawn 环境中设定的 `PS1`。后端的提示符就绪检测要求 OSC `133;D` 标记之后的可打印尾部与受控提示符完全相等([设计](../feature/2026-07-16-persistent-pty-sessions.md)),因此初始化之后任何 send 都无法经由该路径结算。`PROMPT_COMMAND` 未被覆盖,标记仍持续到达,于是每次 send 都要支付静默层加交接宽限——生产默认值下每次工具调用 3.5 秒;首次调用 7.2 秒,因为初始化 send 同样退化;每条长命令结束后还要多等 3.5 秒。macOS 没有精确 stdin 等待层,而 Linux 的精确探测无法观察到在一个轮询周期内完成的命令脱离其 stdin 等待,因此退化实际覆盖了几乎每次调用。包测试把 `idleSilenceMs` 配成 100 毫秒,掩盖了该问题。 + +这个覆盖存在的目的是给工具一个已知提示符,服务两个消费点:用视口后缀检测「shell 已回到提示符但没有结束标记」的回退判定,以及从部分输出中剥离提示符文本的美化。 + +## Decision + +后端拥有自己的提示符协议并自行修复:受控 `PROMPT_COMMAND` 在打印标记后重新设定 `PS1`,因此任何 shell 内的提示符覆盖——本工具从前的初始化、模型命令、被 source 的脚本——都存活不到下一个提示符。这同时保护了无法报告前台状态的提供方:在那里,确切的提示符文本是唯一的就绪证据。 + +工具不再覆盖 `PS1`(初始化只剩 `stty -echo`),并用 seam 已有的信号替换其视口后缀回退:一次以 `stdin_read` 结算而 scrollback 中没有结束标记的 send,返回已捕获的部分输出。私有提示符常量及其剥离逻辑删除;部分输出现在可能以后端自己的提示符文本结尾,工具无法也不应知道该文本。 + +## Alternatives considered + +**只改工具,不动 `PROMPT_COMMAND`。** 被拒绝:seam 仍然静默脆弱——之后任何触碰 `PS1` 的消费方或模型命令都会在没有失败信号的情况下重新引入 3.5 秒退化,且无前台检查的提供方失去唯一的就绪因子。 + +**把受控提示符导入工具。** 被拒绝:提示符是单个提供方的协议常量;Consumer 匹配它就把工具与 `dsh-terminal-bash` 具体耦合,换任何其他后端都会再次损坏。 + +**从后端就绪检测中去掉提示符文本因子。** 被拒绝:对 `inspectForeground` 无法报告任何信息的提供方而言,标记加文本是对抗「命令输出中嵌入原始 OSC 标记序列」的防御;削弱它是拿误结算风险换快速路径。 + +**改为调大 `handoffGraceMs`/`idleSilenceMs`。** 被拒绝:任何静默值都修不好已死的快速路径,只是重新分配每次调用多付多少。 + +## Consequences + +darwin 上以生产默认值实测:受控提示符完好时裸 send 约 86 毫秒结算,覆盖后约 3540 毫秒;工具调用从 7180/3560/3566 毫秒(spawn+init+echo、echo、pwd)降至 355/88/91 毫秒。 + +`stdin_read` 回退是行为而不只是美化:在 `exec`、中断,或提供方能证明其 stdin 等待的交互式前台子进程(Linux 精确层)之后,调用现在返回已捕获的部分输出,而不是空转到命令期限。没有提供方证明该等待时(macOS),交互式子进程仍会运行到 `timeoutMs`——已记入工具 README 的已知限制。部分输出可能带有后端的尾部提示符;由标记界定的完整输出与之前逐字节相同,无密钥 jsonrpc-agent 快照确认了这一点。 + +loader 组合套件现在把 `idleSilenceMs` 设在 send 上限之上,静默无法结算任何 send,提示符就绪一旦回归,每个用例都会失败;一个真实 PTY 用例在 shell 内覆盖 `PS1`,并要求下一次 send 以 `stdin_read` 结算且提示符已修复。自我修复无法在 `PROMPT_COMMAND` 本身被覆盖的命令后存活;那里静默层仍是边界,与先前设计一致。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ef4931f765..10cae6e914 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc -config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 +config-catalog.md: 4f22ed3da7de81f94d6fc5ee55a305d117c126e7 +config-catalog.zh.md: 7054ec8b52a8c46bc1ace97112f086119a61cfec diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 82f6d26c79..4f22ed3da7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2370,7 +2370,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 958d311544..7054ec8b52 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2372,7 +2372,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:400`](../packages/shell/tool-bash-persistent/src/index.ts) diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml index a9503a57a9..d81a95be5f 100644 --- a/packages/shell/tool-bash-persistent/README.i18n.yaml +++ b/packages/shell/tool-bash-persistent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/shell/tool-bash-persistent/README.md -README.md: 37c259443cba566350d8ec7963857d5f6ce7396c -README.zh.md: 8f3cf4e4d20aad1537b48644cee1a9d5706f4b3c +README.md: 606920d087b42344f34b70103e167b0046d3cfd5 +README.zh.md: dd88db87cb617fb2f4d35ada9550d0142e32e979 diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md index 37c259443c..606920d087 100644 --- a/packages/shell/tool-bash-persistent/README.md +++ b/packages/shell/tool-bash-persistent/README.md @@ -33,7 +33,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. +Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers. When the shell reads stdin again without having printed the completion marker — after `exec`, an interrupt, or an interactive foreground child whose stdin wait the provider proves — the call returns the captured partial output, which can end with the backend's own prompt text. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. #### Token effect @@ -46,5 +46,6 @@ Append-only tool results follow the reusable request prefix. ## Known Limitations and Deferred Work - The tool requires an owning Agent and a real PTY backend. +- An interactive foreground child (for example a REPL) returns early with partial output only where the subprocess provider proves its stdin wait; elsewhere the call runs to `timeoutMs`. - Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell. - Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default. diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md index 8f3cf4e4d2..dd88db87cb 100644 --- a/packages/shell/tool-bash-persistent/README.zh.md +++ b/packages/shell/tool-bash-persistent/README.zh.md @@ -33,7 +33,7 @@ #### 模型所见 -每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。 +每个 Agent 的命令共享一个 shell,因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记。当 shell 在未打印完成标记的情况下再次读取 stdin 时——例如 `exec`、中断,或提供方能证明其 stdin 等待的交互式前台子进程——调用返回已捕获的部分输出,其末尾可能带有后端自己的提示符文本。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell,并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell,并报告该重置。 #### Token 影响 @@ -46,5 +46,6 @@ ## 已知限制与延后工作 - 工具需要拥有它的 Agent 和真实 PTY 后端。 +- 交互式前台子进程(例如 REPL)只有在进程管理提供方能证明其 stdin 等待时才会提前返回部分输出;否则调用会一直运行到 `timeoutMs`。 - 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。 - 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。 diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 16d127bbe2..61deb3afb7 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' +import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -15,7 +15,6 @@ import { defineTool } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' -const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' // One page is enough to find a just-emitted completion marker; the full // scrollback is assembled only when a command settles or needs partial output. @@ -82,12 +81,8 @@ function wrapCommand(command: string, marker: CommandMarkers): string { return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"` } -function stripPrompt(text: string): string { - let result = text.replace(/\r?\n$/, '') - while (result.endsWith(SHELL_PROMPT)) { - result = result.slice(0, -SHELL_PROMPT.length) - } - return result.endsWith('\n') ? result.slice(0, -1) : result +function trimTrailingNewline(text: string): string { + return text.replace(/\r?\n$/, '') } function commandOutput( @@ -101,18 +96,12 @@ function commandOutput( const startMarker = text.lastIndexOf(marker.start, end) const start = startMarker < 0 ? 0 : startMarker + marker.start.length return { - text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')), + text: trimTrailingNewline(text.slice(start, end).replace(/^\r?\n/, '')), incomplete: startMarker < 0, exitCode: Number(status), } } -function promptCompleted(result: TerminalSendResult): boolean { - return result.viewport.endsWith(SHELL_PROMPT) - || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) - || result.viewport.endsWith(`${SHELL_PROMPT}\n`) -} - function partialOutput( snapshot: RetainedOutput, marker: CommandMarkers, @@ -122,7 +111,7 @@ function partialOutput( const startMarker = snapshot.text.lastIndexOf(marker.start) if (startMarker >= 0) { return { - text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + text: trimTrailingNewline(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), incomplete: false, } } @@ -133,7 +122,7 @@ function partialOutput( const fallbackEnd = afterStart.lastIndexOf(marker.end) const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) return { - text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')), + text: trimTrailingNewline(beforeEnd), incomplete: fallbackTruncated || fallbackStart < 0, } } @@ -243,8 +232,10 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell live.delete(owner) }, 'tool-bash-persistent owner cache cleanup') } + // Echo suppression only: the prompt stays the backend's own, so the + // backend's prompt-based readiness detection keeps working. const setup = ctx.terminals.startSend(owner, spawned.sessionId, { - text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`, + text: 'stty -echo', submit: true, signal: combinedSignal, }) @@ -339,7 +330,11 @@ async function executeCommand( SHELL_RESET_MESSAGE, ].filter(part => part.length > 0).join('\n') } - if (promptCompleted(result)) { + // The shell reads stdin again (its prompt, or a foreground child's own + // read) without having printed the end marker — e.g. `exec`, an interrupt, + // or an interactive child. Return what was captured instead of spinning + // until the command deadline. + if (result.waitReason === 'stdin_read') { const snapshot = retainedScrollback(ctx, owner, id, latest) return renderCaptured( partialOutput(snapshot, marker, fallback, fallbackTruncated), diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 55e89bc9f4..6d6affdb44 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -84,7 +84,10 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ' config:', ' pollIntervalMs: 10', ' exactProbeAfterMs: 20', - ' idleSilenceMs: 100', + // The silence tier is pushed beyond the send bound, so no send below can + // settle as inferred_idle: every case proves the controlled-prompt fast + // path that the production defaults (3.5s silence) would otherwise mask. + ' idleSilenceMs: 30000', ' handoffGraceMs: 100', ' scrollbackLines: 20000', ' timeoutMs: 2000', @@ -154,6 +157,12 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { expect(large).toContain('') expect(large).not.toContain('beginning of this command output was dropped') + // `exec` replaces the wrapper before its end marker prints; the seam's + // stdin_read readiness is what returns the replacement shell's prompt + // instead of spinning until the tool deadline. + const execed = text(await execute('exec-replacement', 'exec bash --noprofile --norc -i')) + expect(execed).toBe('dsh> ') + const exited = text(await execute('exit', 'exit')) expect(exited).toContain('next bash call starts from the workspace') expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root) diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index b3de46643c..0386295c4c 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -100,7 +100,7 @@ type StubMode = | 'paged-scrollback' class StubPtySession implements TerminalBackendSession { - readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' + readonly motd = 'stub> ' readonly pid = 123 statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd @@ -325,7 +325,7 @@ describe('tool-bash-persistent', () => { expect(ctx.tools.get('bash')).toBeUndefined() }) - it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + it('handles inferred idle, stdin_read fallback, shell exit, clipping, and cleanup', async () => { const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', maxOutputChars: 10, @@ -338,18 +338,16 @@ describe('tool-bash-persistent', () => { session.mode = 'incremental-fallback' session.scrollback = '' - expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + expect(text(await call(ctx, owner, 'incremental fallback'))).toContain('increment') session.mode = 'prompt-only' const promptFallback = text(await call(ctx, owner, 'bad {')) expect(promptFallback).toContain('bash: synt') - expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') session.mode = 'prompt-crlf' session.scrollback = '' const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) expect(crlfPromptFallback).toContain('bash: synt') - expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT') session.mode = 'end-only' session.scrollback = '' @@ -435,7 +433,7 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') }) - it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + it('returns a stdin_read fallback reached after multiple polling rounds', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') const session = stub.sessions[0]! @@ -444,7 +442,8 @@ describe('tool-bash-persistent', () => { const result = text(await call(ctx, owner, 'bad {')) expect(result).toContain('partial syntax output') expect(result).toContain('bash: syntax error') - expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT') + // The backend owns the prompt text, so the fallback retains it verbatim. + expect(result.endsWith('stub> ')).toBe(true) expect(result).not.toContain('DSH_PERSISTENT_BASH_START') }) diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 231dac9190..e0d5920efb 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/terminal/terminal-bash/README.md -README.md: 36e725fd4ce86be09755768a9e21ccb66d025251 -README.zh.md: 080f45eb03dfdeece91269a3253aeb3fb280864d +README.md: 72f5b57335febe40b36de85e7df9b6df4bf7cb10 +README.zh.md: 48c051564130d283717828de35d625d65e825052 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 36e725fd4c..72f5b57335 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,7 +8,7 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal` The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. The controlled `PROMPT_COMMAND` re-asserts that `PS1` before every prompt, so an in-shell prompt override cannot degrade later sends to silence readiness. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 080f45eb03..48c0515641 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 +就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。受控 `PROMPT_COMMAND` 会在每次输出提示符前重新设定该 `PS1`,因此在 shell 内覆盖提示符不会使后续 send 退化到静默就绪。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。 diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 82e864e7ab..d0b2d6a564 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -60,7 +60,10 @@ function childEnvironment(spec: TerminalBackendSpawnSpec): Record { graceMs: 10, env: { TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1', + PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"; PS1=\'dsh> \'', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', }, }) diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index d8c8fe6b59..c7af8668a3 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -137,6 +137,26 @@ describe('terminal-bash real shell', () => { } }, 10_000) + it('restores the controlled prompt after an in-shell PS1 override', async () => { + // The silence tier is pushed beyond every assertion below, so each settle + // proves prompt-based readiness survives the override rather than the + // inferred_idle fallback absorbing a broken prompt. + const { ctx, agent } = await harness('danger-full-access', { + idleSilenceMs: 5_000, + timeoutMs: 8_000, + }) + const created = await ctx.terminals.spawn(agent, { type: 'shell' }) + + const override = ctx.terminals.startSend(agent, created.sessionId, { text: 'PS1=broken-prompt', submit: true }) + expect((await override.done).waitReason).toBe('stdin_read') + + const after = ctx.terminals.startSend(agent, created.sessionId, { text: 'printf "healed=[%s]\\n" "$PS1"', submit: true }) + const result = await after.done + expect(result.waitReason).toBe('stdin_read') + expect(result.viewport).toContain('healed=[dsh> ]') + await ctx.terminals.kill(agent, created.sessionId) + }, 20_000) + it('wraps the exact shell argv under confined policy and unregisters on reload', async () => { const { ctx, root, agent, fiber, sandbox } = await harness('workspace-write') const created = await ctx.terminals.spawn(agent, { type: 'shell' }) From 7e95a00c8a5eed37fc8d16487b6a1a9b772b075c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 15 Aug 2026 16:07:30 +0800 Subject: [PATCH 080/146] fix(llm): align replay state with assembled content and degrade unusable state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A max-tokens response that included a tool call persisted assembler-transformed content next to replay metadata projected from the untransformed native message, so the next request died in history reconstruction with INVALID_REPLAY_STATE and the session stayed permanently stuck. Write side: the finish chunk's replayState becomes a typed ReplayEnvelope — opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence. BlockAssembler computes one keep/drop decision for blocks and entries together, so stored metadata always describes stored content and retained blocks keep their signatures. pi-ai splits its state into a version-2 response half and per-block signature entries. Read side: durable content is authoritative. toPiAssistant degrades any unusable state — foreign kind, other versions (including the flat v1 form already on disk), malformed metadata, or content/block mismatches — to the existing provider-neutral conversion with an onReplayDegrade diagnostic instead of failing the request, which un-bricks sessions poisoned before this change. Covered by assembler and replay unit tests, an agent-loop continuation regression, keyless real-composition continuation tests (native pruned-envelope replay and legacy flat-state degrade), and the authored keyless snapshot scenario max-tokens-continue through the assembled ACP app. --- ...-14-provider-routed-llm-adapters.i18n.yaml | 4 +- ...2026-07-14-provider-routed-llm-adapters.md | 4 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 4 +- ...max-token-replay-state-alignment.i18n.yaml | 6 + ...-08-15-max-token-replay-state-alignment.md | 33 +++ ...-15-max-token-replay-state-alignment.zh.md | 33 +++ docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 39 ++- docs/subsystems/llm-streaming.zh.md | 39 ++- examples/acp-agent/tests/acp.snapshot.ts | 7 + .../snapshots/max-tokens-continue/input.json | 8 + .../max-tokens-continue/session.jsonl | 33 +++ .../max-tokens-continue/stdout.expected.jsonl | 6 + .../tests/contract-regressions.spec.ts | 2 +- packages/core/agent-loop/tests/loop.spec.ts | 35 ++- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 12 +- packages/llm/llm-pi-ai/src/context.ts | 37 ++- packages/llm/llm-pi-ai/src/index.ts | 6 + packages/llm/llm-pi-ai/src/replay.ts | 98 +++++--- packages/llm/llm-pi-ai/tests/convert.spec.ts | 234 +++++++++++------- .../tests/loader-composition.spec.ts | 130 +++++++++- .../llm/llm-pi-ai/tests/provider-apis.e2e.ts | 18 +- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/assembler.ts | 41 ++- packages/llm/llm/src/types.ts | 25 +- packages/llm/llm/tests/assembler.spec.ts | 79 ++++++ scripts/type-equiv.manifest.json | 5 + 33 files changed, 782 insertions(+), 186 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md create mode 100644 examples/acp-agent/tests/snapshots/max-tokens-continue/input.json create mode 100644 examples/acp-agent/tests/snapshots/max-tokens-continue/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index a79104468c..3f4683f480 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md -2026-07-14-provider-routed-llm-adapters.md: e1eaf52f21481a7c65e85effb7607b16f9b0ffdd -2026-07-14-provider-routed-llm-adapters.zh.md: 4e620408dabffc8368293635613afb5778b6e822 +2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4 +2026-07-14-provider-routed-llm-adapters.zh.md: 5e73cab5f1f2b1296c9a486d1c833e95bb5674a0 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index e1eaf52f21..78c8d67880 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -40,9 +40,9 @@ pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` reje Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload. -A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant message's model source without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history. +A terminal successful `finish` chunk may carry replay state as a `ReplayEnvelope`: opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence. `BlockAssembler` makes one keep/drop decision for content and metadata — when max-token assembly drops a tool call, the envelope loses the entry at the same position — so the state the loop attaches to the assembled assistant message's model source always describes the stored blocks, per the [max-token replay-state alignment decision](../bug-fix/2026-08-15-max-token-replay-state-alignment.md). The loop exposes no response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history. -The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmRuntime` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content plus provider/model fields. +The pi-ai replay state fills that envelope with a versioned, minimal projection of its successful `AssistantMessage`: a response half (source API/provider/model, response id/model, stop reason) and per-block text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmRuntime` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. Durable content stays authoritative: an adapter receiving replay state it cannot use — an unknown kind or version, malformed metadata, or a block shape that no longer matches the content — degrades that message to provider-neutral conversion with a diagnostic; a different adapter receives only provider-neutral content plus provider/model fields. This state is model-visible replay input and therefore follows the existing [reconstructable-request rule](2026-07-05-reconstructable-requests.md): it is present in both the terminal `finish` chunk and the assembled `assistant/message` model source that drives derivation. Resume and fork preserve it verbatim. Compaction that shadows the assistant message also removes its replay state from the active surface; the summary is ordinary provider-neutral content. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 4e620408da..5e73cab5f1 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -40,9 +40,9 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定 助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 -成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到已组装助手消息的模型来源中,但不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 +成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 -pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包含源 API/提供方/模型、响应 ID/模型、停止原因,以及按索引对齐的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容以及提供方/模型字段。 +pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充该结构:一个响应半区(源 API/提供方/模型、响应 ID/模型、停止原因),以及逐块的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。持久化内容保持权威:适配器收到无法使用的回放状态——未知 kind 或版本、格式错误的元数据、或与内容不再匹配的块结构——会把该消息降级为提供方无关转换并带出诊断;其他适配器只能收到提供方无关的内容以及提供方/模型字段。 该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.i18n.yaml new file mode 100644 index 0000000000..af691f1175 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.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 .agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md +2026-08-15-max-token-replay-state-alignment.md: 256a64403a08377cf35ba645175698678eaa7f8b +2026-08-15-max-token-replay-state-alignment.zh.md: a24f3e194dca100d2ea0faf659b1868c605c26c1 diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md new file mode 100644 index 0000000000..256a64403a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.md @@ -0,0 +1,33 @@ +# Agent Note: Replay state aligns with assembled content by construction + +Status: implemented + +English | [中文](2026-08-15-max-token-replay-state-alignment.zh.md) + +## Problem + +pi-ai recorded one opaque replay blob per response, projected from the provider's native message, while `BlockAssembler.blocks()` separately dropped tool calls from a `max-tokens` response because a truncated call is unsafe to execute. The durable assistant message therefore stored transformed content next to metadata describing the untransformed native block list. The next request failed during history reconstruction with `INVALID_REPLAY_STATE: block count does not match assistant content`, and because the mismatch was already on disk, every later request on that session failed the same way — the session was permanently stuck. The root cause is structural: two representations of one response were snapshotted at different pipeline points, with their index alignment enforced only by a read-time hard error. + +## Decision + +Two changes, one per side of the durable boundary. + +**Write side — one keep/drop decision.** The finish chunk's `replayState` becomes a typed `ReplayEnvelope`: an opaque `response` half plus optional opaque per-block entries aligned with the emitted block sequence. `BlockAssembler` computes its keep/drop decision once and applies it to blocks and envelope entries together, so any transformation assembly performs — today's max-token tool-call drop or a future one — prunes the matching metadata by construction. Retained blocks keep their entries, so a truncated response keeps signatures for the reasoning and text it kept. An envelope whose entries do not match the emitted block count is discarded whole (a misemitting adapter must not publish misattributed metadata). pi-ai splits its former flat state into a version-2 response half and per-block signature entries. + +**Read side — durable content is authoritative.** `toPiAssistant` treats replay state as fidelity metadata, not as a load-bearing input: any state the reading build cannot use — another adapter's kind, another version (including the flat version-1 form already on disk), malformed metadata, or a block shape that no longer matches the content — degrades that one message to the existing foreign provider-neutral conversion and reports the `INVALID_REPLAY_STATE` diagnostic through the plugin's `onReplayDegrade` hook (a logger warning). The request proceeds. This is what lets sessions poisoned before this change continue instead of erroring forever, and it bounds every future divergence source to a fidelity loss on one message. + +## Verification + +Assembler unit tests prove pruning, misalignment discard, and pass-through for untransformed and per-block-free envelopes. pi-ai unit tests prove the version-2 envelope round-trip and that every formerly-throwing invalid-state case now degrades to foreign conversion with the diagnostic. An agent-loop regression drives a truncated text-plus-tool-call response through persistence and shows the follow-up request carrying the pruned envelope. Keyless real-composition tests boot `dsh-llm-pi-ai` through the Loader and prove a native continuation without `tool_calls` after truncation, and a successful continuation over a legacy flat-state message whose block count no longer matches. The authored keyless snapshot scenario `max-tokens-continue` pins the assembled application's durable log — truncated turn, pruned envelope on the stored message, continued turn — through the real ACP subprocess path. + +## Alternatives considered + +**Suppress the whole replay state when assembly drops a tool call.** Works for today's one transformation, but re-derives the drop condition beside `blocks()` (the two drift silently), discards valid signatures for the retained blocks, and leaves read-time divergence — legacy sessions on disk foremost — a hard error. + +**Keep the state and relax pi-ai's block-count validation to attach what fits.** Rejected: index-aligned signatures attached to a different block list would present false native history to the provider. Degrading attaches nothing. + +**Teach each adapter to rewrite its state after assembly.** Rejected as an adapter obligation with an opaque blob; the envelope moves exactly the needed structure — and nothing else — into shared vocabulary, and the assembler's single decision does the rewrite mechanically. + +## Consequences + +Continuing after a max-token response that included a tool call works, retains the kept blocks' native signatures, and replays as a native pi-ai message. Sessions recorded before this change replay their affected assistant messages as provider-neutral content (with a diagnostic) instead of failing the turn; on-disk `replayState` values changed shape under the pre-release no-compatibility stance, with the old flat form handled by the same degrade path. This supersedes the read-time hard-error rule in the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md) for unusable state; validation itself is unchanged and still precedes any native reconstruction. diff --git a/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md new file mode 100644 index 0000000000..a24f3e194d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 回放状态与组装内容按构造对齐 + +Status: implemented + +[English](2026-08-15-max-token-replay-state-alignment.md) | 中文 + +## 问题 + +pi-ai 为每个响应记录一个从提供方原生消息投影而来的不透明回放数据,而 `BlockAssembler.blocks()` 会另行从 `max-tokens` 响应中丢弃工具调用,因为被截断的调用不能安全执行。持久化的 assistant 消息因此把变换后的内容与描述未变换原生块清单的元数据存在一起。下一个请求在历史重建阶段以 `INVALID_REPLAY_STATE: block count does not match assistant content` 失败;由于不一致已经落盘,该会话之后的每个请求都以同样方式失败——会话被永久卡死。根因是结构性的:同一响应的两种表示在流水线的不同位置各自拍摄快照,其索引对齐只靠读取时的硬错误来维持。 + +## 决定 + +两处改动,各覆盖持久化边界的一侧。 + +**写侧——一次保留/丢弃决定。** finish 分片的 `replayState` 变为有类型的 `ReplayEnvelope`:一个不透明的 `response` 半区,加上与发射块序列对齐的可选不透明逐块条目。`BlockAssembler` 只计算一次保留/丢弃决定,并把它同时应用于块和逐块条目,因此组装执行的任何变换——今天的 max-token 工具调用丢弃或未来的其他变换——都按构造裁剪掉对应元数据。保留的块保留其条目,所以被截断的响应仍为其保留的推理(reasoning)与文本保有签名。条目数与发射块数不一致的数据整体丢弃(发射不当的适配器不得发布归属错误的元数据)。pi-ai 把原先的平铺状态拆为版本 2 的 response 半区和逐块签名条目。 + +**读侧——持久化内容是权威记录。** `toPiAssistant` 把回放状态当作保真度元数据,而非承重输入:读取方无法使用的任何状态——其他适配器的 kind、其他版本(包括已落盘的平铺版本 1 形式)、格式错误的元数据、或与内容不再匹配的块结构——都把这一条消息降级为既有的外来提供方无关转换,并通过插件的 `onReplayDegrade` 钩子(logger 警告)上报 `INVALID_REPLAY_STATE` 诊断。请求继续执行。正是这一点让本次改动之前已被毒化的会话得以继续而不是永远报错,也把未来一切分叉源约束为单条消息的保真度损失。 + +## 验证 + +组装器单元测试证明裁剪、错位丢弃、以及未变换与无逐块条目数据的透传。pi-ai 单元测试证明版本 2 数据的往返,以及先前每个抛错的无效状态用例现在都降级为外来转换并带出诊断。agent loop 回归用例驱动一个被截断的文本加工具调用响应穿过持久化,并证明后续请求携带裁剪后的数据。无密钥真实组合测试通过 loader 启动 `dsh-llm-pi-ai`,证明截断后不带 `tool_calls` 的原生续聊,以及在块数不再匹配的旧平铺状态消息之上成功续聊。手工编写的无密钥快照场景 `max-tokens-continue` 通过真实 ACP 子进程路径钉住组装应用的持久化日志——截断轮次、存储消息上裁剪后的数据、以及继续的轮次。 + +## 已考虑的替代方案 + +**组装丢弃工具调用时抑制整个回放状态。** 对今天唯一的变换有效,但在 `blocks()` 旁边重新推导丢弃条件(两处会无声漂移),丢掉保留块的有效签名,并让读取时的分叉——首当其冲是已落盘的旧会话——仍然是硬错误。 + +**保留状态并放宽 pi-ai 的块数校验、能贴多少贴多少。** 否决:索引对齐的签名贴到不同的块清单上,会向提供方呈现虚假的原生历史。降级则什么都不贴。 + +**让每个适配器在组装后改写自己的状态。** 否决:这把义务压给持有不透明数据的适配器;信封只把恰好需要的结构——不多一分——纳入共享词汇,组装器的单一决定即可机械完成改写。 + +## 影响 + +包含工具调用的 max-token 响应之后的续聊可以工作,保留块保有原生签名,并作为原生 pi-ai 消息回放。本次改动之前记录的会话,其受影响的 assistant 消息作为提供方无关内容回放(带诊断)而不是让轮次失败;`replayState` 落盘形状在预发布无兼容承诺立场下发生变化,旧平铺形式由同一降级路径处理。对不可用状态而言,这取代了[提供方路由适配器决定](../architecture/2026-07-14-provider-routed-llm-adapters.md)中读取时硬错误的规则;校验本身不变,仍先于任何原生重建执行。 diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 5708a7b6d5..8f287e2a0f 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/llm-streaming.md -llm-streaming.md: 0d3a0d53c875c9d943146ba44b775d81fc9cae01 -llm-streaming.zh.md: fbaa47d14d57e7377be4db6ecaa04f11997572a6 +llm-streaming.md: 7c0e0865f8dcc0e7722bb2205d0129d9e0ca3086 +llm-streaming.zh.md: 5c31909ee79137c6c5eef101235b43a2419b1339 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 0d3a0d53c8..7c0e0865f8 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -157,6 +157,29 @@ type ContextFormed = A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it. +```ts type-equiv +/** + * Adapter-private lossless-JSON state for replaying a successful response, + * carried by a terminal `finish` chunk and stored on the assembled assistant + * message's model source. Both halves stay opaque to the harness; only the + * split is shared vocabulary, so assembly can keep stored metadata aligned + * with stored content without reading either half. + */ +interface ReplayEnvelope { + /** Response-level adapter-private metadata (ids, native stop reason). */ + response: unknown + /** + * Per-block adapter-private metadata, one entry per emitted block in + * first-seen stream order. When assembly drops a block it drops the entry at + * the same position; entries whose length does not match the emitted block + * count discard the whole envelope. An adapter whose metadata is independent + * of block structure omits this field and the envelope passes through + * assembly unchanged. + */ + blocks?: readonly unknown[] +} +``` + ```ts type-equiv /** * Raw streaming protocol emitted by adapters. @@ -176,8 +199,8 @@ type StreamChunk = | { type: 'finish' reason: FinishReason - /** Adapter-private lossless-JSON state for replaying a successful response. */ - replayState?: unknown + /** Replay metadata for a successful response; see {@link ReplayEnvelope}. */ + replayState?: ReplayEnvelope } ``` @@ -213,7 +236,7 @@ Every adapter MUST obey these, and every consumer may rely on them: - **Context overflow has one canonical code.** Both DeepSeek adapters classify explicit provider detail through `isContextWindowExceededError()` and surface `CONTEXT_WINDOW_EXCEEDED`, whether the failure arrives as a thrown HTTP `LlmError` or an in-band finish error. Consumers route on the code, never provider text. - **An empty completion is a retryable error, not a silent success.** Both adapters map a terminal `stop` finish that carried no content blocks to `finish {kind:'error'}` with the canonical `EMPTY_RESPONSE` code, and `dsh-llm-retry` retries it by default; see [empty model responses are retryable](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md). - **Every provider HTTP request carries the app-attribution header.** Adapters send `attributionHeaders()` (below) - the `User-Agent` baseline - and prove it with a wire-level test. -- **Replay state is adapter-owned.** A successful `finish` may carry lossless-JSON state needed to reconstruct a native provider response. The loop stores it with the assembled assistant message. On a later request, `LlmRuntime` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content plus provider/model fields without the private state. +- **Replay state is adapter-owned; its split is shared.** A successful `finish` may carry a `ReplayEnvelope`: opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence. The alignment is the harness's vocabulary — when assembly drops a block it drops the entry at the same position, so stored metadata always describes stored content. The loop stores the pruned envelope with the assembled assistant message. On a later request, `LlmRuntime` passes the state only when the historical provider and target provider are currently registered to the exact same adapter instance. That adapter validates the state and owns any cross-model or cross-provider conversion; other adapters receive the provider-neutral content plus provider/model fields without the private state. Durable content stays authoritative: a stored state the reading adapter cannot use degrades that one message to provider-neutral conversion with a diagnostic instead of failing the request. ## `ResolvedRetryPolicy` @@ -267,6 +290,8 @@ interface TokenUsage { `BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with the provider and model that produced it. A consumer that needs the assembled result without re-implementing the fold uses this. +One keep/drop decision covers content and metadata together: a `max-tokens` finish drops every tool call because a truncated call is unsafe to execute, and the same decision prunes the replay envelope's per-block entry at each dropped position. `blocks()` and `replayState` therefore cannot disagree, whatever assembly removes. + ```ts public-api /** * Incrementally assembles raw {@link StreamChunk}s into complete @@ -296,8 +321,12 @@ declare class BlockAssembler { get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ get finish(): FinishReason; - /** Adapter-private replay state from the terminal finish chunk, if any. */ - get replayState(): unknown; + /** + * Replay metadata from the terminal finish chunk, if any, with per-block + * entries pruned in step with {@link blocks}. Undefined when the envelope's + * entries do not align with the emitted blocks. + */ + get replayState(): ReplayEnvelope | undefined; /** * The assembled assistant message. * @param source - producer attribution for the assembled message. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index fbaa47d14d..5c31909ee7 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -157,6 +157,29 @@ type ContextFormed = 一个流式响应交错包含多种类型的块(文本、推理(reasoning)、多个工具调用)。`index` 将每个 delta 关联到其所属块;`block-end` 携带完整组装好的 `ContentBlock`,消费方无需自行重新组装 delta。这是一个**封闭的**可辨识联合类型:对 `type` 的 `switch` 以 `assertNever` 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。 +```ts type-equiv +/** + * Adapter-private lossless-JSON state for replaying a successful response, + * carried by a terminal `finish` chunk and stored on the assembled assistant + * message's model source. Both halves stay opaque to the harness; only the + * split is shared vocabulary, so assembly can keep stored metadata aligned + * with stored content without reading either half. + */ +interface ReplayEnvelope { + /** Response-level adapter-private metadata (ids, native stop reason). */ + response: unknown + /** + * Per-block adapter-private metadata, one entry per emitted block in + * first-seen stream order. When assembly drops a block it drops the entry at + * the same position; entries whose length does not match the emitted block + * count discard the whole envelope. An adapter whose metadata is independent + * of block structure omits this field and the envelope passes through + * assembly unchanged. + */ + blocks?: readonly unknown[] +} +``` + ```ts type-equiv /** * Raw streaming protocol emitted by adapters. @@ -176,8 +199,8 @@ type StreamChunk = | { type: 'finish' reason: FinishReason - /** Adapter-private lossless-JSON state for replaying a successful response. */ - replayState?: unknown + /** Replay metadata for a successful response; see {@link ReplayEnvelope}. */ + replayState?: ReplayEnvelope } ``` @@ -215,7 +238,7 @@ interface LlmFailure { - **上下文溢出只有一个规范 code。** 两个 DeepSeek 适配器都通过 `isContextWindowExceededError()` 对提供方的显式细节分类并暴露 `CONTEXT_WINDOW_EXCEEDED`,无论失败以抛出的 HTTP `LlmError` 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。 - **空 completion 是可重试错误,而不是静默的成功结果。** 两个适配器都把没有携带任何内容块的终止性 `stop` 结束映射为携带规范 `EMPTY_RESPONSE` code 的 `finish {kind:'error'}`,`dsh-llm-retry` 默认会重试它;详见[空模型响应可重试](../../.agents/notes/implemented/bug-fix/2026-07-24-empty-model-response-is-retryable.md)。 - **每个提供方 HTTP 请求都携带应用归属头。** 适配器发送 `attributionHeaders()`(见下文)作为 `User-Agent` 基线,并通过协议级测试加以证明。 -- **回放状态归适配器所有。** 成功的 `finish` 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmRuntime` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容以及提供方/模型字段,不会收到私有状态。 +- **回放状态归适配器所有;其切分是共享词汇。** 成功的 `finish` 可以携带一个 `ReplayEnvelope`:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。对齐关系是 harness 的词汇——组装丢弃某个块时,同一位置的条目一并丢弃,因此存储的元数据始终描述存储的内容。循环把裁剪后的数据与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,`LlmRuntime` 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容以及提供方/模型字段,不会收到私有状态。持久化内容保持权威:读取适配器无法使用的已存状态只会把这一条消息降级为提供方无关转换并带出诊断,而不是让请求失败。 ## `ResolvedRetryPolicy` @@ -273,6 +296,8 @@ interface TokenUsage { `BlockAssembler`([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts))是唯一的共享实现,负责把 `StreamChunk` 流折叠回 `ContentBlock`、usage、结束原因与回放状态。循环在记录原始分片的同时,把同一批分片送入 assembler,再将组装后的 assistant 内容连同生成它的提供方和模型一起存储。需要组装结果、又不想重新实现 fold 的消费方使用它。 +内容与元数据共用同一次保留/丢弃决定:`max-tokens` 结束会丢弃每个工具调用,因为被截断的调用不能安全执行,而同一决定会在每个被丢弃的位置裁剪回放数据的逐块条目。无论组装移除什么,`blocks()` 与 `replayState` 都不可能不一致。 + ```ts public-api /** * Incrementally assembles raw {@link StreamChunk}s into complete @@ -302,8 +327,12 @@ declare class BlockAssembler { get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ get finish(): FinishReason; - /** Adapter-private replay state from the terminal finish chunk, if any. */ - get replayState(): unknown; + /** + * Replay metadata from the terminal finish chunk, if any, with per-block + * entries pruned in step with {@link blocks}. Undefined when the envelope's + * entries do not align with the emitted blocks. + */ + get replayState(): ReplayEnvelope | undefined; /** * The assembled assistant message. * @param source - producer attribution for the assembled message. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index db4a2b5d2f..87ef397ee2 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -348,6 +348,13 @@ const SCENARIOS: Scenario[] = [ // reply, and a clean completed retry turn. Its overlay only pins a deterministic // 1 ms zero-jitter delay, so it shares the default header class. { name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG }, + // Keyless, authored (like error-finish): a live model cannot be coaxed into + // a deterministic mid-tool-call output-limit truncation. Turn 1's script ends + // at `max-tokens` with an unfinished tool call and adapter replay metadata for + // both blocks; the durable assistant/message pins assembly dropping the tool + // call AND pruning its per-block replay entry in the same decision, and turn 2 + // proves the session continues past the truncated step. + { name: 'max-tokens-continue', hasModelTurn: true, recorded: false }, // Keyless, authored (like error-finish/cancel): deterministically forcing a // LIVE model to repeat one call three times is not a stable recording, so // the fixture scripts five identical todo_write calls and pins BOTH reminder diff --git a/examples/acp-agent/tests/snapshots/max-tokens-continue/input.json b/examples/acp-agent/tests/snapshots/max-tokens-continue/input.json new file mode 100644 index 0000000000..ebd0c642bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/max-tokens-continue/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "This turn is cut off at the output limit while calling a tool." }, + { "op": "prompt", "text": "Continue: summarize what happened without retrying the tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/max-tokens-continue/session.jsonl b/examples/acp-agent/tests/snapshots/max-tokens-continue/session.jsonl new file mode 100644 index 0000000000..6e2f7a6d1c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/max-tokens-continue/session.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"7f1c9a04-5b52-4a7e-9a63-1d2ab7c90d11","createdAt":1786348800000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786348800001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"This turn is cut off at the output limit while calling a tool."}],"source":{"kind":"user"},"role":"user","id":"3a6a5c9e-0f9c-4c8f-9f57-6f2f7f3d5a01"}]}} +{"type":"turn/start","seq":1,"time":1786348800002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786348800002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786348800003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786348800004,"data":{"content":[{"type":"text","text":"This turn is cut off at the output limit while calling a tool."}],"source":{"kind":"user"},"role":"user","id":"3a6a5c9e-0f9c-4c8f-9f57-6f2f7f3d5a01"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786348800005,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"5b7f2d1c-9c44-4c58-8a3e-2f6f8b9d4c02"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786348800005,"data":{"title":"This turn is cut off","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786348800006,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786348800006,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786348800010,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":1786348800011,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"Starting the write now."}}} +{"type":"assistant/chunk","seq":11,"time":1786348800012,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Starting the write now."}}}} +{"type":"assistant/chunk","seq":12,"time":1786348800013,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1786348800014,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call-cut","name":"bash","argumentsDelta":"{\"command\":\"echo demo > "}}} +{"type":"assistant/chunk","seq":14,"time":1786348800015,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":12}}}} +{"type":"assistant/chunk","seq":15,"time":1786348800016,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"max-tokens"},"replayState":{"response":{"kind":"pi-ai","version":2,"api":"openai-completions","provider":"deepseek-official","model":"deepseek-v4-flash","stopReason":"length"},"blocks":[{"type":"text"},{"type":"tool-call"}]}}}} +{"type":"assistant/message","seq":16,"time":1786348800016,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Starting the write now."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"pi-ai","version":2,"api":"openai-completions","provider":"deepseek-official","model":"deepseek-v4-flash","stopReason":"length"},"blocks":[{"type":"text"}]}},"id":"9d5f7c2a-1e63-4d6b-8f14-7a2c5e9b3d03"},"usage":{"inputTokens":2864,"outputTokens":12}},"sourceEventSeqs":[9,10,11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786348800016,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1786348800016,"data":{"turn":1,"reason":{"kind":"max-tokens"}}} +{"type":"agent/inbox/spliced","seq":19,"time":1786348800020,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue: summarize what happened without retrying the tool."}],"source":{"kind":"user"},"role":"user","id":"1c8e6b4f-3d27-4a91-b5c8-9e4f7a2d6c04"}]}} +{"type":"turn/start","seq":20,"time":1786348800021,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":21,"time":1786348800021,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":22,"time":1786348800022,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":23,"time":1786348800023,"data":{"content":[{"type":"text","text":"Continue: summarize what happened without retrying the tool."}],"source":{"kind":"user"},"role":"user","id":"1c8e6b4f-3d27-4a91-b5c8-9e4f7a2d6c04"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":24,"time":1786348800030,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":1786348800031,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"The previous reply hit the output limit while a tool call was still streaming, so that call was discarded and no tool ran."}}} +{"type":"assistant/chunk","seq":26,"time":1786348800032,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The previous reply hit the output limit while a tool call was still streaming, so that call was discarded and no tool ran."}}}} +{"type":"assistant/chunk","seq":27,"time":1786348800033,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}}} +{"type":"assistant/chunk","seq":28,"time":1786348800034,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1786348800034,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The previous reply hit the output limit while a tool call was still streaming, so that call was discarded and no tool ran."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7e3d9f6b-5a18-4c72-9b4e-1f8c6d2a7e05"},"usage":{"inputTokens":64,"outputTokens":28}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1786348800034,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":31,"time":1786348800034,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl new file mode 100644 index 0000000000..bf555a8d1c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Starting the write now."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The previous reply hit the output limit while a tool call was still streaming, so that call was discarded and no tool ran."}}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index acdc66865e..c085dc7de0 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -62,7 +62,7 @@ function inboxText(message: UserMessage): string { describe('assistant replay provider and model fields', () => { it('records adapter replay state with the assembled assistant content', async () => { const response = textResponse('unchanged') - const replayState = { private: 'state' } + const replayState = { response: { private: 'state' }, blocks: ['block-meta'] } response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState } const adapter = new MockAdapter([response]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index ca4a5309c9..2105b86f42 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1192,15 +1192,29 @@ describe('agent loop', () => { { type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } }, { type: 'block-start', index: 1, blockType: 'tool-call' }, { type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' }, - { type: 'finish', reason: { kind: 'max-tokens' } }, - ]]) + { + type: 'finish', + reason: { kind: 'max-tokens' }, + replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta', 'tool-meta'] }, + }, + ], textResponse('continued')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) + send(agent, 'continue') + await waitForIdle(ctx, agent) expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + // The follow-up request replays the truncated message with its replay + // metadata pruned in step with the dropped tool call. + expect(adapter.requests[1]?.messages[1]?.source).toEqual({ + kind: 'model', + provider: 'mock', + model: 'mock', + replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] }, + }) expect(agent.session.deriveMessages()).toEqual([ { id: expect.any(String) as unknown, @@ -1212,6 +1226,23 @@ describe('agent loop', () => { id: expect.any(String) as unknown, role: 'assistant', content: [{ type: 'text', text: 'partial text' }], + source: { + kind: 'model', + provider: 'mock', + model: 'mock', + replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] }, + }, + }, + { + id: expect.any(String) as unknown, + role: 'user', + content: [{ type: 'text', text: 'continue' }], + source: { kind: 'user' }, + }, + { + id: expect.any(String) as unknown, + role: 'assistant', + content: [{ type: 'text', text: 'continued' }], source: { kind: 'model', provider: 'mock', model: 'mock' }, }, ]) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5a812806da..7fb624d21f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3577,6 +3577,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', }, + { + name: 'ReplayEnvelope', + declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}', + }, { name: 'RequestContext', declaration: 'export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n}', @@ -4091,7 +4095,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'StreamChunk', - declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', + declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: ReplayEnvelope;\n};', }, { name: 'SubagentCapabilities', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 31e6ac3b8a..553b7d4557 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/llm/llm-pi-ai/README.md -README.md: 6120f8d982c6d475cd508e6cf9e41cabfc9ba159 -README.zh.md: 4b47976c6c6c67968b5b93edbdfd5dfa9530eb1d +README.md: d775e72616822ce0deee063ac0f3fc453af1a126 +README.zh.md: 621d67d1c181c6d4c78ea0078f521acccce92653 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 6120f8d982..d775e72616 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -137,9 +137,9 @@ Credentials never enter that collection. The harness resolves a route's key thro The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name. -Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them. At request time, `LlmRuntime` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response. +Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them, as a `ReplayEnvelope`: a response-level half (kind, version, API, route, response ids, native stop reason) plus one per-block entry per streamed block carrying that block's signatures. The per-block alignment is what `BlockAssembler` prunes when assembly drops a block (a `max-tokens` tool call), so the stored entries always describe the stored content — the retained blocks keep their signatures. At request time, `LlmRuntime` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response. -If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provider/model mismatches between the message and replay state, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`. +Durable content is the authoritative record; replay state only restores native fidelity. A stored state this build cannot use — another adapter's kind, another version (including the flat pre-envelope form older logs carry), malformed metadata, provider/model mismatches between the message and replay state, or content/block mismatches — degrades that one assistant message to the same foreign provider-neutral conversion instead of failing the request, and the plugin logs the `INVALID_REPLAY_STATE` diagnostic through its `onReplayDegrade` hook. ## Vocabulary differences diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 4b47976c6c..621d67d1c1 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -138,9 +138,9 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。 -成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储。请求时,`LlmRuntime` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。 +成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储,其形式是 `ReplayEnvelope`:一个响应级半区(kind、版本、API、路由、响应 id、原生停止原因),加上每个流式块一条、携带该块 signature 的逐块条目。逐块对齐正是 `BlockAssembler` 在组装丢弃某个块(`max-tokens` 下的工具调用)时裁剪的对象,因此存储的条目始终描述存储的内容——保留的块保有其 signature。请求时,`LlmRuntime` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。 -如果 listener 改写已组装 assistant 内容,loop 会在记录消息前丢弃回放状态,因为其提供方元数据不再描述该内容。无效版本、格式错误元数据、消息与回放状态之间的提供方/模型不匹配,以及内容/块不匹配都会显式以 `LlmError('INVALID_REPLAY_STATE')` 失败。 +持久化内容是权威记录;回放状态只负责恢复原生保真度。当前构建无法使用的已存状态——其他适配器的 kind、其他版本(包括旧日志携带的平铺前信封形式)、格式错误的元数据、消息与回放状态之间的提供方/模型不匹配,或内容/块不匹配——会把这一条 assistant 消息降级为同样的外来提供方无关转换而不是让请求失败,插件通过其 `onReplayDegrade` 钩子记录 `INVALID_REPLAY_STATE` 诊断。 ## 词汇差异 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 66964c5339..ab1c784351 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -76,6 +76,11 @@ export interface PiAiAdapterOptions { resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined + /** + * Observe one assistant history message degrading to provider-neutral + * conversion because its stored replay state is unusable by this build. + */ + onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ @@ -307,9 +312,12 @@ export class PiAiAdapter extends LlmAdapter { if (containsImage && attachments === undefined) { throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT') } + const onReplayDegrade = (reason: string): void => { + this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) + } const context = attachments === undefined - ? toPiContext(options) - : await toPiContext(options, attachments) + ? toPiContext(options, undefined, onReplayDegrade) + : await toPiContext(options, attachments, onReplayDegrade) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 678820510e..dcbaabc815 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -84,7 +84,7 @@ function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext { } } -function textOnlyContext(options: GenerateOptions): PiContext { +function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext { const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { @@ -96,7 +96,7 @@ function textOnlyContext(options: GenerateOptions): PiContext { continue } if (message.role === 'assistant') { - const assistant = toPiAssistant(message) + const assistant = toPiAssistant(message, onReplayDegrade) for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name) messages.push(assistant) continue @@ -125,22 +125,43 @@ function textOnlyContext(options: GenerateOptions): PiContext { * Convert text-only harness history to a synchronous pi-ai Context. Tool * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. + * @param attachments - absent; selects the synchronous conversion. + * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @returns the pi-ai context; `tools` is omitted when the request declares none. */ -export function toPiContext(options: GenerateOptions): PiContext +export function toPiContext( + options: GenerateOptions, + attachments?: undefined, + onReplayDegrade?: (reason: string) => void, +): PiContext /** * Convert harness history to a pi-ai Context while resolving durable images. * Tool result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. * @param attachments - durable byte resolver for image references. + * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @returns the asynchronously resolved pi-ai context. */ -export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise -export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise { - return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments) +export function toPiContext( + options: GenerateOptions, + attachments: AttachmentStore, + onReplayDegrade?: (reason: string) => void, +): Promise +export function toPiContext( + options: GenerateOptions, + attachments?: AttachmentStore, + onReplayDegrade?: (reason: string) => void, +): PiContext | Promise { + return attachments === undefined + ? textOnlyContext(options, onReplayDegrade) + : toPiContextWithImages(options, attachments, onReplayDegrade) } -async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise { +async function toPiContextWithImages( + options: GenerateOptions, + attachments: AttachmentStore, + onReplayDegrade?: (reason: string) => void, +): Promise { const toolNames = new Map() const messages: PiMessage[] = [] @@ -156,7 +177,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta continue } if (message.role === 'assistant') { - const assistant = toPiAssistant(message) + const assistant = toPiAssistant(message, onReplayDegrade) for (const block of assistant.content) { if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 2e550771fc..1bbeec79db 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -201,6 +201,12 @@ export function apply(ctx: Context, config: Config): void { profiles, resolveApiKey, resolveAttachments: () => ctx.get('attachments'), + onReplayDegrade: ({ provider, model, reason }) => { + ctx.logger.warn( + `llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";` + + ` sending that message as provider-neutral content (${reason})`, + ) + }, }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 10a39c655b..aa9d542e33 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -9,24 +9,30 @@ */ import { LlmError } from '@deepseek-ai/dsh-llm' -import type { Message, ModelMessageSource } from '@deepseek-ai/dsh-llm' +import type { Message, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm' import type { Api, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai' -type PiAiReplayBlock = +/** Per-block half of the pi-ai replay envelope, one entry per content block. */ +export type PiAiReplayBlock = | { type: 'text'; textSignature?: string } | { type: 'reasoning'; thinkingSignature?: string; redacted?: boolean } | { type: 'tool-call'; thoughtSignature?: string } -/** Versioned adapter-private projection required to replay a pi-ai response. */ -export interface PiAiReplayState { +/** Versioned response-level half of the pi-ai replay envelope. */ +export interface PiAiReplayResponse { kind: 'pi-ai' - version: 1 + version: 2 api: Api provider: string model: string responseModel?: string responseId?: string stopReason: AssistantMessage['stopReason'] +} + +/** The validated halves of one pi-ai replay envelope. */ +interface PiAiReplayState { + response: PiAiReplayResponse blocks: PiAiReplayBlock[] } @@ -57,19 +63,25 @@ function emptyPiUsage(): PiUsage { /** * Project a successful pi-ai response into the minimal durable replay state. + * The per-block half is index-aligned with the streamed blocks (pi-ai content + * order), so `BlockAssembler` prunes an entry with its block whenever assembly + * removes one. * @param message - completed native pi-ai assistant response. * @returns the versioned lossless-JSON replay projection. */ -export function toPiReplayState(message: AssistantMessage): PiAiReplayState { - return { +export function toPiReplayState(message: AssistantMessage): ReplayEnvelope { + const response: PiAiReplayResponse = { kind: 'pi-ai', - version: 1, + version: 2, api: message.api, provider: message.provider, model: message.model, ...message.responseModel === undefined ? {} : { responseModel: message.responseModel }, ...message.responseId === undefined ? {} : { responseId: message.responseId }, stopReason: message.stopReason, + } + return { + response, blocks: message.content.map((block): PiAiReplayBlock => { switch (block.type) { case 'text': return { @@ -94,22 +106,26 @@ function invalidReplay(message: string): never { throw new LlmError(`invalid pi-ai replay state: ${message}`, 'INVALID_REPLAY_STATE') } -/** Validate the adapter-private state before it reaches pi-ai. */ +/** Validate the durable adapter-private envelope before it reaches pi-ai. */ function readReplayState(value: unknown): PiAiReplayState { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected an object') - const state = value as Record - if (state['kind'] !== 'pi-ai') return invalidReplay('unknown state kind') - if (state['version'] !== 1) return invalidReplay(`unsupported version ${String(state['version'])}`) + if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected a replay envelope') + const envelope = value as Record + const rawResponse = envelope['response'] + if (typeof rawResponse !== 'object' || rawResponse === null || Array.isArray(rawResponse)) return invalidReplay('expected a response object') + const response = rawResponse as Record + if (response['kind'] !== 'pi-ai') return invalidReplay('unknown state kind') + if (response['version'] !== 2) return invalidReplay(`unsupported version ${String(response['version'])}`) for (const key of ['api', 'provider', 'model'] as const) { - if (typeof state[key] !== 'string' || state[key].length === 0) return invalidReplay(`${key} must be a non-empty string`) + if (typeof response[key] !== 'string' || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`) } - if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(state['stopReason']))) { + if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(response['stopReason']))) { return invalidReplay('unknown stopReason') } - if (state['responseModel'] !== undefined && typeof state['responseModel'] !== 'string') return invalidReplay('responseModel must be a string') - if (state['responseId'] !== undefined && typeof state['responseId'] !== 'string') return invalidReplay('responseId must be a string') - if (!Array.isArray(state['blocks'])) return invalidReplay('blocks must be an array') - for (const [index, value] of state['blocks'].entries()) { + if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string') + if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string') + const blocks = envelope['blocks'] + if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array') + for (const [index, value] of blocks.entries()) { if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`) const block = value as Record if (!['text', 'reasoning', 'tool-call'].includes(String(block['type']))) return invalidReplay(`block ${index} has an unknown type`) @@ -118,7 +134,10 @@ function readReplayState(value: unknown): PiAiReplayState { } if (block['redacted'] !== undefined && typeof block['redacted'] !== 'boolean') return invalidReplay(`block ${index} redacted must be boolean`) } - return state as unknown as PiAiReplayState + return { + response: response as unknown as PiAiReplayResponse, + blocks: blocks as PiAiReplayBlock[], + } } /** Convert provider-neutral blocks without trusting them as same-model replay. */ @@ -159,8 +178,8 @@ function foreignAssistant(message: Message): AssistantMessage { /** Recombine durable Harness content with validated pi-ai replay metadata. */ function replayedAssistant(message: Message, source: ModelMessageSource, rawState: unknown): AssistantMessage { const state = readReplayState(rawState) - if (state.provider !== source.provider) return invalidReplay('provider does not match assistant source') - if (state.model !== source.model) return invalidReplay('model does not match assistant source') + if (state.response.provider !== source.provider) return invalidReplay('provider does not match assistant source') + if (state.response.model !== source.model) return invalidReplay('model does not match assistant source') if (state.blocks.length !== message.content.length) return invalidReplay('block count does not match assistant content') const content: AssistantMessage['content'] = message.content.map((block, index) => { const replay = state.blocks[index] @@ -191,25 +210,40 @@ function replayedAssistant(message: Message, source: ModelMessageSource, rawStat return { role: 'assistant', content, - api: state.api, - provider: state.provider, - model: state.model, - ...state.responseModel === undefined ? {} : { responseModel: state.responseModel }, - ...state.responseId === undefined ? {} : { responseId: state.responseId }, + api: state.response.api, + provider: state.response.provider, + model: state.response.model, + ...state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel }, + ...state.response.responseId === undefined ? {} : { responseId: state.response.responseId }, usage: emptyPiUsage(), - stopReason: state.stopReason, + stopReason: state.response.stopReason, timestamp: 0, } } /** * Convert one durable Harness assistant message into pi-ai history. + * + * Durable content is the authoritative record; replay metadata only restores + * native fidelity (ids, signatures). A replay state this build cannot use — + * another adapter's kind, another version, a malformed value, or metadata that + * no longer matches the content — therefore degrades the one message to + * provider-neutral history instead of failing the request. * @param message - assistant content with required source and optional adapter-owned replay metadata. + * @param onDegrade - called with the diagnostic reason when an unusable replay + * state falls back to provider-neutral conversion. * @returns a native pi-ai assistant message reconstructed from durable content. */ -export function toPiAssistant(message: Message): AssistantMessage { +export function toPiAssistant(message: Message, onDegrade?: (reason: string) => void): AssistantMessage { const source = message.source - return source.kind !== 'model' || source.replayState === undefined - ? foreignAssistant(message) - : replayedAssistant(message, source, source.replayState) + if (source.kind !== 'model' || source.replayState === undefined) return foreignAssistant(message) + try { + return replayedAssistant(message, source, source.replayState) + } catch (error: unknown) { + /* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors today; the + guard keeps a future non-replay failure loud instead of silently degrading it */ + if (!(error instanceof LlmError) || error.code !== 'INVALID_REPLAY_STATE') throw error + onDegrade?.(error.message) + return foreignAssistant(message) + } } diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 5af58e6630..1a42e4b085 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, createMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { toPiContext } from '../src/context.ts' @@ -415,35 +415,68 @@ describe('toPiContext', () => { expect(context.messages[0]).not.toHaveProperty('responseId') }) - it('rejects unsupported replay-state versions with a stable error code', () => { - try { - toPiContext({ - provider: 'deepseek', - model: 'm', - messages: [createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'done' }], - source: { - kind: 'model', - ...{ - provider: 'deepseek', - model: 'old', - replayState: { kind: 'pi-ai', version: 2 }, - }, + it('degrades unsupported replay-state versions to provider-neutral history', () => { + const onDegrade = vi.fn() + const context = toPiContext({ + provider: 'deepseek', + model: 'm', + messages: [createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + source: { + kind: 'model', + ...{ + provider: 'deepseek', + model: 'old', + replayState: { response: { kind: 'pi-ai', version: 3 }, blocks: [] }, }, - })], - }) - expect.fail('expected invalid replay state') - } catch (error: unknown) { - expect(error).toBeInstanceOf(LlmError) - expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE') - expect((error as Error).message).toContain('unsupported version 2') - } + }, + })], + }, undefined, onDegrade) + expect(context.messages[0]).toMatchObject({ + role: 'assistant', + api: 'dsh-foreign', + provider: 'deepseek', + model: 'old', + content: [{ type: 'text', text: 'done' }], + }) + expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('unsupported version 3')) }) - it('rejects replay metadata whose blocks do not match the durable content', () => { + it('degrades the flat pre-envelope replay state a legacy session log carries', () => { + const onDegrade = vi.fn() + const context = toPiContext({ + provider: 'deepseek', + model: 'm', + messages: [createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + source: { + kind: 'model', + ...{ + provider: 'deepseek', + model: 'deepseek-v4-flash', + replayState: { + kind: 'pi-ai', + version: 1, + api: 'openai-completions', + provider: 'deepseek', + model: 'deepseek-v4-flash', + stopReason: 'stop', + blocks: [{ type: 'text' }], + }, + }, + }, + })], + }, undefined, onDegrade) + expect(context.messages[0]).toMatchObject({ role: 'assistant', api: 'dsh-foreign' }) + expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('expected a response object')) + }) + + it('degrades replay metadata whose blocks do not match the durable content', () => { + const onDegrade = vi.fn() const state = toPiReplayState(assistant({ content: [{ type: 'text', text: 'done' }] })) - expect(() => toPiContext({ + const context = toPiContext({ provider: 'deepseek', model: 'm', messages: [createMessage({ @@ -454,12 +487,19 @@ describe('toPiContext', () => { ...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state }, }, })], - })).toThrow(/block 0 does not match assistant content/) + }, undefined, onDegrade) + expect(context.messages[0]).toMatchObject({ + role: 'assistant', + api: 'dsh-foreign', + content: [{ type: 'thinking', thinking: 'done' }], + }) + expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('block 0 does not match assistant content')) }) - it('rejects replay metadata whose block count differs from durable content', () => { + it('degrades replay metadata whose block count differs from durable content', () => { + const onDegrade = vi.fn() const state = toPiReplayState(assistant()) - expect(() => toPiContext({ + const context = toPiContext({ provider: 'deepseek', model: 'm', messages: [createMessage({ @@ -470,66 +510,34 @@ describe('toPiContext', () => { ...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state }, }, })], - })).toThrow(/block count does not match assistant content/) + }, undefined, onDegrade) + expect(context.messages[0]).toMatchObject({ + role: 'assistant', + api: 'dsh-foreign', + provider: 'deepseek', + model: 'deepseek-v4-flash', + content: [{ type: 'text', text: 'done' }], + stopReason: 'stop', + }) + expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('block count does not match assistant content')) }) - const validReplay = { + const validResponse = { kind: 'pi-ai', - version: 1, + version: 2, api: 'openai-completions', provider: 'deepseek', model: 'deepseek-v4-flash', stopReason: 'stop', - blocks: [{ type: 'text' }], } + const validReplay = { response: validResponse, blocks: [{ type: 'text' }] } - it.each([ - ['provider', { ...validReplay, provider: 'openai' }], - ['model', { ...validReplay, model: 'deepseek-v4-pro' }], - ])('rejects replay metadata whose %s differs from assistant source', (field, replayState) => { - try { - toPiContext({ - provider: 'deepseek', - model: 'next-model', - messages: [createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'done' }], - source: { - kind: 'model', - ...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState }, - }, - })], - }) - expect.fail('expected invalid replay state') - } catch (error: unknown) { - expect(error).toBeInstanceOf(LlmError) - expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE') - expect((error as Error).message).toContain(`${field} does not match assistant source`) - } - }) - - it.each([ - ['number state', 1, 'expected an object'], - ['null state', null, 'expected an object'], - ['array state', [], 'expected an object'], - ['unknown kind', { ...validReplay, kind: 'other' }, 'unknown state kind'], - ['non-string api', { ...validReplay, api: 1 }, 'api must be a non-empty string'], - ['empty provider', { ...validReplay, provider: '' }, 'provider must be a non-empty string'], - ['missing model', { ...validReplay, model: undefined }, 'model must be a non-empty string'], - ['unknown stop reason', { ...validReplay, stopReason: 'pause' }, 'unknown stopReason'], - ['non-string response model', { ...validReplay, responseModel: 1 }, 'responseModel must be a string'], - ['non-string response id', { ...validReplay, responseId: 1 }, 'responseId must be a string'], - ['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'], - ['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'], - ['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'], - ['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'], - ['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'], - ['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'], - ['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'], - ])('rejects malformed replay state: %s', (_name, replayState, message) => { - expect(() => toPiContext({ + /** Convert with the given state and assert the message degraded to foreign with the given reason. */ + function expectDegraded(replayState: unknown, message: string): void { + const onDegrade = vi.fn() + const context = toPiContext({ provider: 'deepseek', - model: 'm', + model: 'next-model', messages: [createMessage({ role: 'assistant', content: [{ type: 'text', text: 'done' }], @@ -538,7 +546,45 @@ describe('toPiContext', () => { ...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState }, }, })], - })).toThrow(message) + }, undefined, onDegrade) + expect(context.messages[0]).toMatchObject({ + role: 'assistant', + api: 'dsh-foreign', + content: [{ type: 'text', text: 'done' }], + }) + expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining(message)) + } + + it.each([ + ['provider', { ...validReplay, response: { ...validResponse, provider: 'openai' } }], + ['model', { ...validReplay, response: { ...validResponse, model: 'deepseek-v4-pro' } }], + ])('degrades replay metadata whose %s differs from assistant source', (field, replayState) => { + expectDegraded(replayState, `${field} does not match assistant source`) + }) + + it.each([ + ['number state', 1, 'expected a replay envelope'], + ['null state', null, 'expected a replay envelope'], + ['array state', [], 'expected a replay envelope'], + ['missing response', { blocks: [] }, 'expected a response object'], + ['array response', { ...validReplay, response: [] }, 'expected a response object'], + ['unknown kind', { ...validReplay, response: { ...validResponse, kind: 'other' } }, 'unknown state kind'], + ['non-string api', { ...validReplay, response: { ...validResponse, api: 1 } }, 'api must be a non-empty string'], + ['empty provider', { ...validReplay, response: { ...validResponse, provider: '' } }, 'provider must be a non-empty string'], + ['missing model', { ...validReplay, response: { ...validResponse, model: undefined } }, 'model must be a non-empty string'], + ['unknown stop reason', { ...validReplay, response: { ...validResponse, stopReason: 'pause' } }, 'unknown stopReason'], + ['non-string response model', { ...validReplay, response: { ...validResponse, responseModel: 1 } }, 'responseModel must be a string'], + ['non-string response id', { ...validReplay, response: { ...validResponse, responseId: 1 } }, 'responseId must be a string'], + ['missing blocks', { response: validResponse }, 'blocks must be an array'], + ['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'], + ['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'], + ['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'], + ['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'], + ['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'], + ['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'], + ['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'], + ])('degrades malformed replay state: %s', (_name, replayState, message) => { + expectDegraded(replayState, message) }) }) @@ -565,12 +611,14 @@ describe('toStreamChunks', () => { type: 'finish', reason: { kind: 'stop' }, replayState: { - kind: 'pi-ai', - version: 1, - api: 'openai-completions', - provider: 'deepseek', - model: 'deepseek-v4-flash', - stopReason: 'stop', + response: { + kind: 'pi-ai', + version: 2, + api: 'openai-completions', + provider: 'deepseek', + model: 'deepseek-v4-flash', + stopReason: 'stop', + }, blocks: [{ type: 'text' }], }, }, @@ -614,12 +662,14 @@ describe('toStreamChunks', () => { type: 'finish', reason: { kind: 'tool-calls' }, replayState: { - kind: 'pi-ai', - version: 1, - api: 'openai-completions', - provider: 'deepseek', - model: 'deepseek-v4-flash', - stopReason: 'toolUse', + response: { + kind: 'pi-ai', + version: 2, + api: 'openai-completions', + provider: 'deepseek', + model: 'deepseek-v4-flash', + stopReason: 'toolUse', + }, blocks: [{ type: 'tool-call' }], }, }, diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 0ed1eee440..a4e89424f6 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -16,13 +16,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' +/** One text block, then a tool call truncated by the output-token ceiling. */ +const truncatedToolCallEvents = [ + '{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{"content":"partial"},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\\"text\\":"}}]},"index":0,"finish_reason":null}]}', + '{"choices":[{"delta":{},"index":0,"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":4}}', + '[DONE]', +] + let root: string | undefined let context: Context | undefined @@ -113,4 +122,123 @@ describe('llm-pi-ai real dormant composition', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) expect(server.headers[0]?.authorization).toBe('Bearer key-from-store') }) + + it('continues natively after max-token assembly drops a tool call, with pruned replay metadata', async () => { + vi.stubEnv('PI_COMPOSITION_KEY', '') + const server = await mockServer([ + { events: truncatedToolCallEvents }, + { events: textEvents }, + ]) + const { ctx, settingsPath } = await loadComposition() + await writeFile(settingsPath, [ + 'llm-pi-ai:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, + '', + ].join('\n')) + await vi.waitFor(() => { + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + }, { timeout: 5000 }) + + const truncated = await assemble(ctx, { + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [], + }) + expect(truncated.finish).toEqual({ kind: 'max-tokens' }) + expect(truncated.message.content).toEqual([{ type: 'text', text: 'partial' }]) + expect(truncated.message.source).toEqual({ + kind: 'model', + provider: 'deepseek', + model: 'deepseek-v4-flash', + replayState: { + response: { + kind: 'pi-ai', + version: 2, + api: 'openai-completions', + provider: 'deepseek', + model: 'deepseek-v4-flash', + stopReason: 'length', + }, + blocks: [{ type: 'text' }], + }, + }) + + const continued = await assemble(ctx, { + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [ + truncated.message, + createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }), + ], + }) + expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.requests).toHaveLength(2) + expect(server.requests[1]).toMatchObject({ + messages: [ + { role: 'assistant', content: 'partial' }, + { role: 'user', content: 'continue' }, + ], + }) + const followup = server.requests[1] as { messages?: unknown[] } + expect(followup.messages?.[0]).not.toHaveProperty('tool_calls') + }) + + it('continues a legacy session whose stored replay state no longer matches its content', async () => { + vi.stubEnv('PI_COMPOSITION_KEY', '') + const server = await mockServer([{ events: textEvents }]) + const { ctx, settingsPath } = await loadComposition() + await writeFile(settingsPath, [ + 'llm-pi-ai:', + ' providers:', + ' deepseek:', + ' apiKeyEnv: PI_COMPOSITION_KEY', + ` baseURL: ${server.url}`, + '', + ].join('\n')) + await vi.waitFor(() => { + expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek']) + }, { timeout: 5000 }) + + // A pre-envelope session log entry: max-token assembly dropped the tool + // call from content while the flat v1 state still describes both blocks. + const poisoned = createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'partial' }], + source: { + kind: 'model', + ...{ + provider: 'deepseek', + model: 'deepseek-v4-flash', + replayState: { + kind: 'pi-ai', + version: 1, + api: 'openai-completions', + provider: 'deepseek', + model: 'deepseek-v4-flash', + stopReason: 'length', + blocks: [{ type: 'text' }, { type: 'tool-call' }], + }, + }, + }, + }) + const continued = await assemble(ctx, { + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [ + poisoned, + createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }), + ], + }) + expect(continued.finish).toEqual({ kind: 'stop' }) + expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }]) + expect(server.requests[0]).toMatchObject({ + messages: [ + { role: 'assistant', content: 'partial' }, + { role: 'user', content: 'continue' }, + ], + }) + }) }) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index b1731d7071..a2a583abd8 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -11,7 +11,7 @@ import type { import LlmRuntime, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' -import type { PiAiReplayState } from '../src/replay.ts' +import type { PiAiReplayResponse } from '../src/replay.ts' import { assemble, type AssembledResult } from './assemble.ts' interface ProviderCase { @@ -118,18 +118,20 @@ function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): expect(result.finish.kind).toBe(expected) } -function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState { +function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayResponse { const replayState = result.message.source.kind === 'model' ? result.message.source.replayState : undefined expect(replayState).toMatchObject({ - kind: 'pi-ai', - version: 1, - api: profile.api, - provider: profile.provider, - model: profile.model, + response: { + kind: 'pi-ai', + version: 2, + api: profile.api, + provider: profile.provider, + model: profile.model, + }, }) - return replayState as PiAiReplayState + return (replayState as { response: PiAiReplayResponse }).response } const lookupTool: ToolSchema = { diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 2d0d68e7ac..fce8fa059b 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/llm/llm/README.md -README.md: 2cae9a05a58295b06d25382729a3304dbdc9a6fa -README.zh.md: 110cc128f1c19bef1741ae59d8106139c4a72649 +README.md: fb6bd84240b41dd730d45b3eb34c35827dc4c991 +README.zh.md: 5c22767a7c654972cbf614505382fa4755d7d318 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 2cae9a05a5..fb6bd84240 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -53,7 +53,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it. -Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. +Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content. ### Call configuration (`call-config.ts`) diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 110cc128f1..5c22767a7c 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -53,7 +53,7 @@ 消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。 -流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。 +流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。 ### 调用配置(`call-config.ts`) diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index a0e1332417..5eb3668915 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -10,7 +10,7 @@ import { CallId } from './brand.ts' import { assertNever } from './never.ts' import { createMessage } from './message.ts' import type { Message, MessageSource } from './message.ts' -import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from './types.ts' +import type { ContentBlock, FinishReason, ReplayEnvelope, StreamChunk, TokenUsage } from './types.ts' interface PartialBlock { blockType: string @@ -38,7 +38,7 @@ export class BlockAssembler { private order: number[] = [] private _usage: TokenUsage | undefined private _finish: FinishReason | undefined - private _replayState: unknown = undefined + private _replayState: ReplayEnvelope | undefined /** * Feed one chunk into the assembly state. @@ -125,6 +125,28 @@ export class BlockAssembler { return partial } + /** + * The one shared keep/drop decision over all seen blocks: max-token + * truncation drops tool calls that cannot be executed safely. Emitted blocks + * and replay metadata both derive from this result, so they cannot disagree. + */ + private assembled(): { blocks: ContentBlock[]; replay: ReplayEnvelope | undefined } { + const all = this.order.map(index => this.assemble(this.mustGet(index), index)) + const kept = this.finish.kind === 'max-tokens' + ? all.map(block => block.type !== 'tool-call') + : undefined + const blocks = kept === undefined ? all : all.filter((_, position) => kept[position]) + const envelope = this._replayState + if (envelope?.blocks === undefined) return { blocks, replay: envelope } + if (envelope.blocks.length !== all.length) return { blocks, replay: undefined } + return { + blocks, + replay: kept === undefined || blocks.length === all.length + ? envelope + : { response: envelope.response, blocks: envelope.blocks.filter((_, position) => kept[position]) }, + } + } + /** * Assemble all blocks seen so far, in stream order. * @returns one block per seen index, except that max-token truncation drops @@ -132,10 +154,7 @@ export class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[] { - const blocks = this.order.map(index => this.assemble(this.mustGet(index), index)) - return this.finish.kind === 'max-tokens' - ? blocks.filter(block => block.type !== 'tool-call') - : blocks + return this.assembled().blocks } /** Usage from the `usage` chunk; undefined until one arrives. */ @@ -148,9 +167,13 @@ export class BlockAssembler { return this._finish ?? { kind: 'stop' } } - /** Adapter-private replay state from the terminal finish chunk, if any. */ - get replayState(): unknown { - return this._replayState + /** + * Replay metadata from the terminal finish chunk, if any, with per-block + * entries pruned in step with {@link blocks}. Undefined when the envelope's + * entries do not align with the emitted blocks. + */ + get replayState(): ReplayEnvelope | undefined { + return this.assembled().replay } /** diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 326db1cb14..8c5be187dd 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -280,6 +280,27 @@ export interface LlmResolvedModelInfo extends LlmModelInfo { reasoning?: LlmModelReasoningInfo } +/** + * Adapter-private lossless-JSON state for replaying a successful response, + * carried by a terminal `finish` chunk and stored on the assembled assistant + * message's model source. Both halves stay opaque to the harness; only the + * split is shared vocabulary, so assembly can keep stored metadata aligned + * with stored content without reading either half. + */ +export interface ReplayEnvelope { + /** Response-level adapter-private metadata (ids, native stop reason). */ + response: unknown + /** + * Per-block adapter-private metadata, one entry per emitted block in + * first-seen stream order. When assembly drops a block it drops the entry at + * the same position; entries whose length does not match the emitted block + * count discard the whole envelope. An adapter whose metadata is independent + * of block structure omits this field and the envelope passes through + * assembly unchanged. + */ + blocks?: readonly unknown[] +} + /** * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the @@ -298,8 +319,8 @@ export type StreamChunk = | { type: 'finish' reason: FinishReason - /** Adapter-private lossless-JSON state for replaying a successful response. */ - replayState?: unknown + /** Replay metadata for a successful response; see {@link ReplayEnvelope}. */ + replayState?: ReplayEnvelope } /** diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index bf2276a218..9f73ee3f96 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -118,6 +118,85 @@ describe('BlockAssembler', () => { }) }) +describe('BlockAssembler replay metadata', () => { + const response = { responseId: 'resp-1' } + + it('prunes per-block replay entries with the tool calls a max-tokens finish drops', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'lead' } }) + assembler.push({ + type: 'block-end', + index: 1, + block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{"text":' }, + }) + assembler.push({ type: 'block-end', index: 2, block: { type: 'reasoning', text: 'tail' } }) + assembler.push({ + type: 'finish', + reason: { kind: 'max-tokens' }, + replayState: { response, blocks: ['meta-0', 'meta-1', 'meta-2'] }, + }) + + expect(assembler.blocks()).toEqual([ + { type: 'text', text: 'lead' }, + { type: 'reasoning', text: 'tail' }, + ]) + expect(assembler.replayState).toEqual({ response, blocks: ['meta-0', 'meta-2'] }) + }) + + it('omits replay metadata whose per-block entries misalign with the emitted blocks', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'one' } }) + assembler.push({ type: 'block-end', index: 1, block: { type: 'text', text: 'two' } }) + assembler.push({ + type: 'finish', + reason: { kind: 'stop' }, + replayState: { response, blocks: ['meta-0'] }, + }) + + expect(assembler.blocks()).toHaveLength(2) + expect(assembler.replayState).toBeUndefined() + }) + + it('passes replay metadata through unchanged when assembly drops nothing', () => { + const replayState = { response, blocks: ['meta-0', 'meta-1'] } + const assembler = new BlockAssembler() + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } }) + assembler.push({ + type: 'block-end', + index: 1, + block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }, + }) + assembler.push({ type: 'finish', reason: { kind: 'tool-calls' }, replayState }) + + expect(assembler.replayState).toBe(replayState) + }) + + it('keeps a max-tokens replay state with no per-block entries across a tool-call drop', () => { + const replayState = { response } + const assembler = new BlockAssembler() + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } }) + assembler.push({ + type: 'block-end', + index: 1, + block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{"text":' }, + }) + assembler.push({ type: 'finish', reason: { kind: 'max-tokens' }, replayState }) + + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'partial' }]) + expect(assembler.replayState).toBe(replayState) + }) + + it('keeps a text-only max-tokens response and its replay metadata intact', () => { + const replayState = { response, blocks: ['meta-0'] } + const assembler = new BlockAssembler() + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } }) + assembler.push({ type: 'finish', reason: { kind: 'max-tokens' }, replayState }) + + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'partial' }]) + expect(assembler.replayState).toBe(replayState) + }) +}) + describe('assertNever', () => { it('throws with diagnostics when a value escapes a closed union at runtime', async () => { const { assertNever } = await import('@deepseek-ai/dsh-llm') diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5e6184b88d..95a573541c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -351,6 +351,11 @@ "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" }, + { + "doc": "docs/subsystems/llm-streaming.md", + "symbol": "ReplayEnvelope", + "source": "packages/llm/llm/src/types.ts" + }, { "doc": "docs/subsystems/llm-streaming.md", "symbol": "StreamChunk", From 4d03472cd098dc48a630e526ca620f4f37f18a0e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 16:36:32 +0800 Subject: [PATCH 081/146] feat(subagent): add Claude Code non-interactive permission modes --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 2 +- ...ct-subagent-providers-in-shared-host.zh.md | 2 +- ...nt-empty-terminal-message-output.i18n.yaml | 4 +- ...-subagent-empty-terminal-message-output.md | 2 +- ...bagent-empty-terminal-message-output.zh.md | 2 +- ...6-06-21-subagent-capability-seam.i18n.yaml | 4 +- .../2026-06-21-subagent-capability-seam.md | 4 +- .../2026-06-21-subagent-capability-seam.zh.md | 4 +- ...-07-08-background-subagent-tasks.i18n.yaml | 4 +- .../2026-07-08-background-subagent-tasks.md | 6 +- ...2026-07-08-background-subagent-tasks.zh.md | 6 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 12 +- ...ude-code-and-codex-subagent-backends.zh.md | 12 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 12 +- ...t-subagent-one-shot-background-tasks.zh.md | 12 +- ...agent-noninteractive-permissions.i18n.yaml | 6 + ...uct-subagent-noninteractive-permissions.md | 72 +++++++ ...-subagent-noninteractive-permissions.zh.md | 72 +++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 +- docs/config-catalog.zh.md | 14 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 9 +- docs/subsystems/subagent.zh.md | 9 +- .../product-subagent-both.cordis.snapshot.yml | 2 + .../product-subagent-both.cordis.yml | 2 + ...gent-result-diagnostic.cordis.snapshot.yml | 29 +++ .../subagent-result-diagnostic.cordis.yml | 17 ++ examples/acp-agent/tests/acp.snapshot.ts | 16 +- .../fixtures/subagent-result-diagnostic.ts | 50 +++++ .../subagent/subagent-claude-code/cordis.yml | 2 + .../input.json | 7 + .../replay.override.json | 42 ++++ .../session.jsonl | 51 +++++ .../stdout.expected.jsonl | 4 + knip.json | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 24 ++- .../subagent-claude-code/README.zh.md | 24 ++- .../subagent-claude-code/src/index.ts | 18 +- .../subagent/subagent-claude-code/src/run.ts | 103 ++++++++- .../tests/messages-fixture.ts | 74 +++++++ .../tests/real-product.spec.ts | 73 ++++++- .../tests/subagent-claude-code.spec.ts | 198 ++++++++++++++++-- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/out-of-process.ts | 42 +++- .../subagent/subagent/src/run-settlement.ts | 12 +- packages/subagent/subagent/src/types.ts | 7 + .../subagent/tests/run-settlement.spec.ts | 59 +++++- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 8 +- packages/subagent/tool-subagent/README.zh.md | 8 +- packages/subagent/tool-subagent/src/index.ts | 23 +- .../tool-subagent/tests/tool-subagent.spec.ts | 78 +++++++ 60 files changed, 1168 insertions(+), 128 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md create mode 100644 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md create mode 100644 examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml create mode 100644 examples/acp-agent/subagent-result-diagnostic.cordis.yml create mode 100644 examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 205757eacc..331a7a8f5d 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: a747d641ae112d114912958c289fe00b592e6ea5 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: fef69e8a2d18135cbc9d5f0d80134fa5701bbbd0 +2026-08-10-product-subagent-providers-in-shared-host.md: dd5cd2b3b9c424da1f9f126d4ec9cb1fa4ca7083 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: 0d946fa30240a130b27f85709382595cf29f5ead diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index a747d641ae..dd5cd2b3b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -16,7 +16,7 @@ Product providers remain process-scoped host-plane registrations. The [productio This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. -The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation. +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply the mounted Provider's deployment configuration, including the Claude Code `permissionMode` owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving that choice into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index fef69e8a2d..0d946fa302 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -16,7 +16,7 @@ Status: implemented 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的 Claude Code `permissionMode`,但不会把该选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml index 612916a290..bca41cb330 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md -2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb -2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179 +2026-08-10-subagent-empty-terminal-message-output.md: 24bab01ad844a5b48e0bf6fe0fc54df6403f4bb7 +2026-08-10-subagent-empty-terminal-message-output.zh.md: ab3488806a4f7c019d783e563c79e06aeeb86f33 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md index 693013f681..24bab01ad8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -12,7 +12,7 @@ The agent loop appends an empty-content `assistant/message` when a `max-tokens` `dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason. -The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output. +The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message presents the optional safe Provider diagnostic owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md) after the stop-reason headline and appends the child's partial text afterward. The parent model receives the failure, separate infrastructure detail, and available assistant output without conflating them. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md index 64d61af21f..ab3488806a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -12,7 +12,7 @@ Status: implemented `dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。 -前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。 +前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后呈现由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全提供方诊断,再附上子 agent 的部分文本。父模型会同时收到失败、独立的基础设施说明与已有 assistant 输出,而且不会把它们混为一体。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml index e8ece4d624..36fa7f01b2 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md -2026-06-21-subagent-capability-seam.md: fa3b4f570bfccdc849a38b3eda16c1c8dd7b1827 -2026-06-21-subagent-capability-seam.zh.md: b25fe64377f98af92dbccb87f755627926975ef2 +2026-06-21-subagent-capability-seam.md: bc84d88d701a5f3018bf00f0ecf8b60750917407 +2026-06-21-subagent-capability-seam.zh.md: baec829a1a01018b490992fa0982bb23e43ba740 diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md index fa3b4f570b..bc84d88d70 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -54,11 +54,11 @@ Fresh and forked children are separate providers, not a request flag. `dsh-subag ### Child isolation and the parent log -Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output), while child steps and tool calls remain outside the parent log. +Each in-process subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. Remote ACP and one-shot product providers instead mint a parent-scoped lifecycle id and expose no local `Agent` or child `Session`; their internal state remains in the remote process. Across both forms, the parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output, or a failed result with optional provider diagnostic), while child steps and tool calls remain outside the parent log. ### Synchronous collect (first cut) -`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output, and independent result and disposal rejections retain both diagnostics. +`dsh-tool-subagent` passes its execution signal to `start()`, awaits the child result, and disposes the run before reporting. Non-completed outcomes become error results rather than successful partial output; they present the optional safe diagnostic owned by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) separately from partial assistant text. Independent result and disposal rejections remain independently observable. ### Provider selection is config, not model-facing diff --git a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md index b25fe64377..baec829a1a 100644 --- a/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md @@ -54,11 +54,11 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在 ### 子 agent 隔离与父日志 -每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出),而子 agent 的步骤和工具调用均留在父日志之外。 +每个进程内 subagent 运行在**自己的 `Session`** 中(独立 id、`parentSession` 谱系),独立持久化。远端 ACP 和一次性产品提供方则会生成一个父级作用域的生命周期 id,且不暴露本地 `Agent` 或子 `Session`;其内部状态留在远端进程中。两种形式下,父日志都仅记录 spawn `tool/call` 及其 `tool/result`(子 agent 的最终输出,或带可选提供方诊断的失败结果),而子 agent 的步骤和工具调用均留在父日志之外。 ### 同步收集(首版) -`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;结果与 dispose 的拒绝相互独立,且两项诊断信息都会保留。 +`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;它会把由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责的可选安全诊断与部分 assistant 文本分开呈现。结果与 dispose 的拒绝仍可彼此独立地观察。 ### 提供方选择是配置,不面向模型 diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml index 0c0cf829a3..715da62619 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md -2026-07-08-background-subagent-tasks.md: 412ec61dcdecae1a273c5993d25a4a099a22e864 -2026-07-08-background-subagent-tasks.zh.md: 9d108440c992e150ed62edaef8bf860813a471b8 +2026-07-08-background-subagent-tasks.md: 4dcd961ee5a5db925f8f6ad83e97890eaedb8e63 +2026-07-08-background-subagent-tasks.zh.md: dd8ba47e018bdeeaffccca1fdaf52df75728057b diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md index 412ec61dcd..4dcd961ee5 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md @@ -8,7 +8,7 @@ English | [中文](2026-07-08-background-subagent-tasks.zh.md) The [subagent seam](2026-06-21-subagent-capability-seam.md) returns a `SubagentRun`, but the model-facing tool originally collected every run synchronously. Independent, slow delegations therefore held the parent call open or ran serially. -Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer and job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit. +Subagents need the same start, collect, list, stop, ownership, notification, and cleanup behavior as other long-running tools without adopting process-stream semantics. The child session remains the detailed trace; the parent needs the final answer or safe failure detail plus job status. A background child also outlives its starting tool call, so its cancellation and owner-disposal contracts must be explicit. ## Decision @@ -16,7 +16,7 @@ Each `dsh-tool-subagent` instance may expose `run_in_background`, controlled by Background subagents use the [generic background job runtime](../architecture/2026-06-20-generic-long-running-tool-runtime.md). Collection, listing, cancellation, completion notices, and prompt guidance come from `job_output`, `job_list`, and `job_kill`; there are no subagent-specific companion tools. -Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result, and always dispose the run before returning. +Foreground calls retain their synchronous contract: await provider startup and `run.result`, return final text only for `completed`, map other terminal reasons to an errored tool result with the optional safe diagnostic described by the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md), and always dispose the run before returning. For a background call, the tool validates the parent and refuses an already-aborted execution signal before calling `ctx.jobs.start()`. The job runtime preflights the control API and owner cleanup before invoking the producer starter. That starter creates an independent `AbortController` and begins `ctx.subagents.start()`; after the id is returned, the tool-call signal no longer owns the child. @@ -24,7 +24,7 @@ The task registration maps the subagent seam as follows: - `kind` is `subagent`, `label` is the model-supplied description, and `owner` is the parent agent. - `cancel(reason?)` aborts the task-owned controller. The same signal covers pending provider startup and the published run's remaining work. -- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed`. Startup, result, and disposal failures become failed outcomes rather than rejected task promises. +- `done` awaits provider startup, the child result, and `run.dispose()`. Completed runs return final text, aborted runs become `killed`, and other stop reasons become `failed` with the Provider diagnostic when present. Startup, result, and disposal failures become failed outcomes rather than rejected task promises. - `readOutput` is absent. While live, `job_output` returns status only; after settlement, it returns final output idempotently. Intermediate child activity remains in the child session. ## Lifecycle diff --git a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md index 9d108440c9..dd8ba47e01 100644 --- a/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.zh.md @@ -8,7 +8,7 @@ Status: implemented [subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。 -subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。 +subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案或安全失败说明,以及任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放约定。 ## 决策 @@ -16,7 +16,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列 后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`job_output`、`job_list` 和 `job_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。 -前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。 +前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果,并在存在时附上由[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)描述的可选安全诊断;而且始终在返回前释放该运行。 对于后台调用,工具会验证父级,并在调用 `ctx.jobs.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制 API 和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。 @@ -24,7 +24,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列 - `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。 - `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已发布 run 的剩余工作。 -- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 +- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`,并在存在时携带提供方诊断。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。 - `readOutput` 不存在。任务存活期间,`job_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。 ## 生命周期 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 48773ba819..c642b870b8 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 666945c4d8039874729a7f9da34d9cf82bfd479d -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 35b8dd51f1c45200a495c68f19f055a224123263 +2026-08-04-claude-code-and-codex-subagent-backends.md: d0e48bb2c048351f71687a66a31c8ecdda123328 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3fd604927c447c24e9047424ab255eb9fd628226 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 666945c4d8..d0e48bb2c0 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, and the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile-selected mode and the shared failure diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -50,9 +50,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp `@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. -The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. +The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. -The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted`. +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail. Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable. @@ -66,7 +66,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -82,7 +82,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. -**Plugin-managed login, product home, models, settings, or permissions.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. The providers expose only an explicit environment overlay and teardown grace; unattended interaction fails closed. +**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Claude Code exposes only one native non-interactive mode choice in addition to environment and teardown configuration; it does not mirror product rules or add a human interaction channel. **Continuation, progress, product-native background state, and shared parent context.** The provider payload remains one final answer for one self-contained task. The generic Job layer may add its id, status, notice, collection, and cancellation results, but product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and provider-specific background state need separate user contracts and are not prebuilt. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. The product payload reaching the parent is final text only; background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed Claude Code run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 35b8dd51f1..3fd604927c 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责 Claude Code 的 Profile 模式选择与共享失败诊断。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -50,9 +50,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 `@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 -公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 +公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 -只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`。 +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。 启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。 @@ -66,7 +66,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -82,7 +82,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 -**由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 +**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。Claude Code 除环境和清理配置外只公开一个原生非交互模式选择;它不会镜像产品规则,也不会增加人工交互通道。 **续接、进度、产品原生后台状态和共享父级上下文。** 提供方载荷仍是一项自包含任务的一个最终回答。通用 Job 层可以额外提供 id、状态、通知、收集与取消结果,但产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和提供方专属后台状态都需要独立的用户约定,当前实现不会预先构建这些功能。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。到达父级的产品载荷仍只有最终文本;后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的 Claude Code 运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index 2310e148a0..b8ef147519 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: b8865cf94852396c32dd6da996bc9f5c2c7d806b -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: bbc18ebb6cf0de1b04f0c2a10ddf51cea282c24e +2026-08-12-product-subagent-one-shot-background-tasks.md: e389c0b8b6587cf699ea3fd30e75531bb6069108 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d424fa9d1ccb1f14fa73e342964e95b7181c8274 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index b8865cf948..e389c0b8b6 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,9 +14,9 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile configuration and diagnostic production. -No provider configuration, service interface, event, wire field, persistence format, or product identifier is added. Foreground and background differ only in which existing consumer waits for the same one-shot run. +This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. ### Ownership and lifecycle @@ -37,7 +37,7 @@ product tool call | Product selection and exposure | Agent Preset | Bind one fixed tool name to one fixed provider | Enabling one row exposes only that product tool | | Foreground or background choice | `dsh-tool-subagent` | Resolve `run_in_background` under `one-shot` policy | Omission is foreground; explicit `true` returns a Job id | | Job id, state, output, cancellation, and notice | `ctx.jobs` and `dsh-tool-jobs` | Register and present the existing one-shot run | Generic job tools collect or stop the run for the exact parent | -| Native answer and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return both wait for disposal | +| Native result, optional diagnostic, and process quiescence | Product provider and `dsh-subprocess` | Produce one final result and release one process tree | Job settlement and foreground return consume the same result and both wait for disposal | ## Published composition @@ -49,7 +49,7 @@ The ACP product compositions use the same fixed product rows and generic job con ## Verification -The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, cancellation, completion notices, owner disposal, and provider disposal. +The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. ## Alternatives considered @@ -65,6 +65,6 @@ The Web composition test explicitly mounts both optional providers from the repo ## Consequences -Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground callers retain their existing result and error behavior. +Agents can continue useful work while Codex or Claude Code handles an independent one-shot task, then collect the final answer or cancel it through the same Job controls used by other background producers. Foreground and one-shot background consumers present the same safe Provider diagnostic when a failed result supplies one. -Every product delegation still starts a fresh native process or query, produces final text as its only product payload, and ends with provider disposal and whole-tree exit. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. +Every product delegation still starts a fresh native process or query, produces final assistant text as its only assistant payload, and ends with provider disposal and whole-tree exit. A failed result may separately carry a safe diagnostic. A background call additionally exposes the generic Job id, status, completion notice, and collection or cancellation results. Background Jobs are process-local and parent-owned: they do not survive parent disposal, do not expose intermediate product activity, and do not make a product conversation resumable. Production installs do not pay for either product integration unless a Profile explicitly installs it; any composition that exposes the background argument must also keep the generic Job provider and controls available. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index bbc18ebb6c..d424fa9d1c 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,9 +14,9 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责 Claude Code 的 Profile 配置与诊断生产。 -本决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。前台与后台的区别仅在于由哪个现有消费方等待同一个 one-shot 运行。 +本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 ### 归属与生命周期 @@ -37,7 +37,7 @@ product tool call | 产品选择与公开 | Agent Preset | 把一个固定工具名绑定到一个固定提供方 | 启用一行只会公开对应产品工具 | | 前台或后台选择 | `dsh-tool-subagent` | 按 `one-shot` 策略解析 `run_in_background` | 省略参数时在前台运行;显式传入 `true` 时返回 Job id | | Job id、状态、输出、取消与通知 | `ctx.jobs` 与 `dsh-tool-jobs` | 登记并展示现有 one-shot 运行 | 通用作业工具为准确父级收集或停止运行 | -| 原生答案与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回都会等待资源释放 | +| 原生结果、可选诊断与进程完全停稳 | 产品提供方与 `dsh-subprocess` | 产生一个最终结果并释放一棵进程树 | Job 结算与前台返回消费同一结果,且都会等待资源释放 | ## 发布组装 @@ -49,7 +49,7 @@ ACP 产品组装使用相同的固定产品行与通用作业控制工具。其 ## 验证 -Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、取消、完成通知、owner 资源释放与提供方资源释放。 +Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。 ## 曾考虑的替代方案 @@ -65,6 +65,6 @@ Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供 ## 后果 -agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。前台调用方继续获得既有结果与错误行为。 +agent 可以在 Codex 或 Claude Code 处理独立 one-shot 任务时继续推进其他工作,随后通过其他后台 producer 共用的 Job 控制工具收集最终回答或取消运行。若失败结果提供了安全的提供方诊断,前台与一次性后台消费方会呈现同一内容。 -每次产品委托仍会启动一个全新的原生进程或 query,把最终文本作为唯一产品载荷,并以提供方资源释放和整棵进程树退出结束。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 +每次产品委托仍会启动一个全新的原生进程或 query,把最终 assistant 文本作为唯一 assistant 载荷,并以提供方资源释放和整棵进程树退出结束。失败结果可以另行携带安全诊断。后台调用还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。后台 Job 仅存在于当前进程且由父级拥有:它不会在父级资源释放后继续存活,不会公开产品中间活动,也不会让产品对话变得可恢复。只有 Profile 显式安装产品集成时,生产安装才承担对应成本;公开后台参数的任何组装还必须让通用 Job 提供方与控制工具保持可用。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml new file mode 100644 index 0000000000..42e26c2f7e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md new file mode 100644 index 0000000000..f382bc7ad0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -0,0 +1,72 @@ +# Agent Note: Claude Code subagents use Profile-selected non-interactive permissions + +Status: implemented + +English | [中文](2026-08-15-product-subagent-noninteractive-permissions.zh.md) + +## Problem + +The [Claude Code product provider](2026-08-04-claude-code-and-codex-subagent-backends.md) runs without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on the product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority. + +A failed product run previously reached the [subagent seam](2026-06-21-subagent-capability-seam.md) only as a stop reason. Logs could retain the product error, but the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not distinguish a permission refusal from another failure. Reusing assistant output for that fact would misattribute infrastructure detail to the child model. + +## Decision + +The Claude Code Provider owns one Profile-level `permissionMode` value. It defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK: + +| Value | Native behavior | +| --- | --- | +| `dontAsk` | Deny operations that are not already authorized instead of prompting. | +| `acceptEdits` | Accept edits; deny any remaining permission prompt through the unattended callback. | +| `auto` | Let Claude Code's native classifier allow or deny permission requests. | +| `plan` | Use Claude Code's planning-only mode without tool execution. | +| `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. | + +The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. + +Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. + +### Failure diagnostic + +`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. + +Claude Code records only the effective mode, request category, unattended decision, and a fixed safe reason. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. + +The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. + +### Ownership and lifecycle + +| Fact or resource | Owner | Observable behavior | +| --- | --- | --- | +| Profile permission choice | Claude Code Provider Config | Invalid, interactive, or unknown values fail during configuration. | +| Permission and sandbox semantics | Claude Code and its Agent SDK | The Provider passes one native mode and does not mirror product policy. | +| Interaction decisions and safe diagnostic | One Claude Code run | Concurrent runs keep independent mode, callback, and diagnostic state. | +| Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | +| Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | +| Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | + +## Verification + +Package tests pin every allowed and rejected Config value, the exact SDK option mapping, bypass confirmation, callback terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, and disposal behavior. The real Agent SDK/CLI fixture proves that the default overrides an interactive native setting, denies an out-of-workspace write with safe diagnostic detail, executes an explicit bypass write only inside suite-owned temporary storage, and leaves the full process tree quiescent. Loader composition proves a non-default mode can be published without starting either product, and the keyless ACP snapshot records the same diagnostic in a foreground tool error and one-shot `job_output` while the model-facing product tool schema contains no permission parameter. + +## Alternatives considered + +**Use the product's ambient permission default.** A native setting may select an interactive mode and make unattended behavior deployment-dependent. The Provider must choose a non-interactive mode explicitly for every query. + +**Put permission mode in the model-facing tool or each start request.** That would let task content select authority and would duplicate a Profile deployment decision on every call. + +**Copy Claude settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of `auto` and bypass modes. + +**Forward prompts to a parent, Web client, or CLI.** The one-shot product run has no owned human-interaction lifecycle. Adding one would require durable request identity, routing, cancellation, and timeout semantics beyond this decision. + +**Return raw product errors, stderr, or tool inputs.** Those values can contain commands, paths, workspace data, environment values, or credentials. A fixed safe diagnostic keeps the failure actionable without exposing the product transcript. + +**Store a separate Job diagnostic.** The Job is only a scheduling adapter for the same `SubagentRun`; a second field would let foreground and background failure meanings drift. + +## Consequences + +Profiles can select Claude Code's native restricted, automatic, planning, edit-accepting, or bypass behavior before the Provider starts, while the safe default never asks a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. + +Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement. + +The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Codex and other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md new file mode 100644 index 0000000000..76cf53c7c9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -0,0 +1,72 @@ +# Agent Note: Claude Code subagent 使用 Profile 选择的非交互权限 + +Status: implemented + +[English](2026-08-15-product-subagent-noninteractive-permissions.md) | 中文 + +## Problem + +[Claude Code 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。 + +失败的产品运行此前只能把终止原因送入 [subagent seam](2026-06-21-subagent-capability-seam.md)。日志可以保留产品错误,但前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)无法区分权限拒绝和其他失败。若复用 assistant 输出承载该事实,则会把基础设施说明错误归因给子模型。 + +## Decision + +Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式: + +| 值 | 原生行为 | +| --- | --- | +| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | +| `acceptEdits` | 接受编辑;其余权限提示由无人值守回调拒绝。 | +| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | +| `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 | +| `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 | + +提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 + +每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 + +### 失败诊断 + +`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。 + +Claude Code 只记录有效模式、请求类别、无人值守决定与固定的安全原因。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 + +前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 + +### 所有权与生命周期 + +| 事实或资源 | Owner | 可观察行为 | +| --- | --- | --- | +| Profile 权限选择 | Claude Code 提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 | +| 权限与沙箱语义 | Claude Code 及其 Agent SDK | 提供方传入一个原生模式,不镜像产品策略。 | +| 交互决定与安全诊断 | 单次 Claude Code 运行 | 并发运行分别拥有独立的模式、回调与诊断状态。 | +| 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | +| 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | +| 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | + +## Verification + +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 选项映射、bypass 确认、回调终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail 和资源释放行为。真实 Agent SDK/CLI fixture 证明默认值会覆盖交互式原生设置,越出工作区的写入会被拒绝并返回安全诊断,显式 bypass 写入只会发生在测试拥有的临时存储中,而且完整进程树会完全停稳。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录同一诊断如何出现在前台工具错误与一次性 `job_output` 中,同时面向模型的产品工具 schema 不包含权限参数。 + +## Alternatives considered + +**使用产品环境中的权限默认值。** 原生设置可能选择交互模式,使无人值守行为依赖部署环境。提供方必须为每次 query 显式选择非交互模式。 + +**把权限模式放入面向模型的工具或每次 start 请求。** 这会让任务内容选择权限,并在每次调用中重复一个 Profile 部署决定。 + +**复制 Claude 设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖 `auto` 与 bypass 模式的原生沙箱后果。 + +**把提示转发给父 agent、Web 客户端或 CLI。** 一次性产品运行没有由其拥有的人工交互生命周期。新增该能力需要持久请求身份、路由、取消与 timeout 语义,超出本决策范围。 + +**返回原始产品错误、stderr 或工具输入。** 这些值可能包含命令、路径、工作区数据、环境值或凭证。固定的安全诊断既保留可操作性,也不会暴露产品 transcript。 + +**单独保存 Job 诊断。** Job 只是同一 `SubagentRun` 的调度适配器;第二个字段会让前台和后台的失败含义发生漂移。 + +## Consequences + +Profile 可以在提供方启动前选择 Claude Code 原生的受限、自动、仅规划、编辑放行或 bypass 行为,而安全默认值绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 + +权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。 + +本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。Codex 与其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ef4931f765..921934f9ac 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc -config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 +config-catalog.md: 8294c2187f2b80fbf36787c784ad8b73a16206c1 +config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 82f6d26c79..8294c2187f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2081,19 +2081,29 @@ Source: [`packages/subagent/subagent-acp/src/index.ts:27`](../packages/subagent/ Requires: `subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = + | 'dontAsk' + | 'acceptEdits' + | 'auto' + | 'plan' + | 'bypassPermissions' ``` -Source: [`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts) +Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 958d311544..f35392a5b0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2083,19 +2083,29 @@ export type PermissionPolicy = 'allow' | 'reject' 需要:`subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = + | 'dontAsk' + | 'acceptEdits' + | 'auto' + | 'plan' + | 'bypassPermissions' ``` -来源:[`packages/subagent/subagent-claude-code/src/index.ts:32`](../packages/subagent/subagent-claude-code/src/index.ts) +来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a86dc8de4a..0770ded7f6 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/subsystems/subagent.md -subagent.md: a683a679e6017351540ee4b73adc74375ef0a1d6 -subagent.zh.md: 61391cd297c0eb14f4c0d8eac4539b551cb60bda +subagent.md: 9a21cecce9144c3aa4c268d753c0aeff5f3ac178 +subagent.zh.md: 4a487fd655f45622bedaa224bad332d6c9ae3ace diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index a683a679e6..9a21cecce9 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -307,7 +307,7 @@ type SubagentDescendantListEntry = SubagentListEntry & { ## The terminal result: `SubagentResult` -The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. +The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A provider may attach a safe, non-assistant `diagnostic` to a non-`completed` result; the provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads and limits the complete value to 4096 UTF-8 bytes before consumers present it separately from `output`. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv /** @@ -330,6 +330,13 @@ interface SubagentResult { * schema-agnostic. */ readonly structured?: unknown + /** + * Provider-authored, non-assistant failure detail for a non-`completed` + * result. Providers keep this text free of tool inputs, file contents, + * environment values, credentials, and raw protocol payloads, and limit it + * to 4096 UTF-8 bytes. Consumers present it separately from {@link output}. + */ + readonly diagnostic?: string /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61391cd297..4a487fd655 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -307,7 +307,7 @@ type SubagentDescendantListEntry = SubagentListEntry & { ## 终态结果:`SubagentResult` -单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 +单次 run 的最终产出,由 `SubagentRun.result` resolve。`structured` 仅在请求了 `outputSchema` 且成功满足时才存在;请求 schema 不保证一定能得到它,当子 agent 失败或结束时未产出有效 capture 时,提供方可能返回 `stopReason: 'error'`。提供方可以为非 `completed` 结果附带安全且不属于 assistant 内容的 `diagnostic`;在消费方将它与 `output` 分开呈现前,提供方会排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整值限制在 4096 个 UTF-8 字节以内。非 `completed` 的 `stopReason` 意味着 `output` 可能不完整——消费方将其映射为 `isError` 的工具结果,而非将部分输出报告为成功。 ```ts type-equiv /** @@ -330,6 +330,13 @@ interface SubagentResult { * schema-agnostic. */ readonly structured?: unknown + /** + * Provider-authored, non-assistant failure detail for a non-`completed` + * result. Providers keep this text free of tool inputs, file contents, + * environment values, credentials, and raw protocol payloads, and limit it + * to 4096 UTF-8 bytes. Consumers present it separately from {@link output}. + */ + readonly diagnostic?: string /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index 0f8760cb91..39f464a6b7 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -22,6 +22,8 @@ name: '@deepseek-ai/dsh-subagent-codex' - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + config: + permissionMode: acceptEdits - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 6a75bec332..6c5154fc6b 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -11,6 +11,8 @@ name: '@deepseek-ai/dsh-subagent-codex' - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + config: + permissionMode: acceptEdits - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml b/examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml new file mode 100644 index 0000000000..563f7d6864 --- /dev/null +++ b/examples/acp-agent/subagent-result-diagnostic.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of subagent-result-diagnostic.cordis.yml: keep the same test +# provider/tool and replace only the external model adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-result-diagnostic + name: './tests/fixtures/subagent-result-diagnostic.ts' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: snapshot-diagnostic + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true diff --git a/examples/acp-agent/subagent-result-diagnostic.cordis.yml b/examples/acp-agent/subagent-result-diagnostic.cordis.yml new file mode 100644 index 0000000000..c82531c0e9 --- /dev/null +++ b/examples/acp-agent/subagent-result-diagnostic.cordis.yml @@ -0,0 +1,17 @@ +# Test-only product-shaped composition: mount a deterministic provider behind +# the same one-shot tool schema as the public Codex example. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-result-diagnostic + name: './tests/fixtures/subagent-result-diagnostic.ts' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: snapshot-diagnostic + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index db4a2b5d2f..0ee4fc179c 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -65,6 +65,9 @@ const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( ) const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG = fileURLToPath( + new URL('../subagent-result-diagnostic.cordis.yml', import.meta.url), +) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -145,7 +148,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsHeader: true, - headerClass: 'product-subagent-codex', + headerClass: 'product-subagent-result-diagnostic', configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, }, { @@ -157,6 +160,17 @@ const SCENARIOS: Scenario[] = [ systemPromptSource: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_BOTH_CONFIG, }, + { + name: 'product-subagent-result-diagnostic', + hasModelTurn: true, + recorded: false, + overridden: true, + pinsHeader: true, + headerClass: 'product-subagent-codex', + systemPromptSource: 'product-subagent-codex', + toolSchemasSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG, + }, { name: 'session-title-after-turn', hasModelTurn: true, diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts new file mode 100644 index 0000000000..f381bf3a81 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -0,0 +1,50 @@ +/** Deterministic provider for model-visible foreground and Job diagnostic snapshots. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + NO_START_CAPABILITIES, + type ResolvedSubagentStartRequest, + type SubagentProvider, +} from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' + +export const name = 'subagent-result-diagnostic' +export const inject = ['subagents'] + +const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt' + +class DiagnosticProvider implements SubagentProvider { + readonly name = 'snapshot-diagnostic' + readonly capabilities = NO_START_CAPABILITIES + readonly inheritsParentContext = false + private starts = 0 + + async start(request: ResolvedSubagentStartRequest) { + if (request.signal.aborted) { + throw new Error('snapshot diagnostic provider start aborted') + } + const index = this.starts++ + if (index > 1) { + throw new Error('snapshot diagnostic provider expected exactly two starts') + } + return { + id: SessionId(index === 0 + ? '00000000-0000-4000-8000-0000000000d1' + : '00000000-0000-4000-8000-0000000000d2'), + localAgent: undefined, + result: Promise.resolve({ + output: index === 0 + ? [{ type: 'text' as const, text: 'partial assistant text' }] + : [], + diagnostic: DIAGNOSTIC, + stopReason: 'error' as const, + }), + dispose: async () => {}, + } + } +} + +/** Register the fixed snapshot provider under the public product provider name. */ +export function apply(ctx: Context): void { + ctx.subagents.registerProvider(new DiagnosticProvider()) +} diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml index 45b62f880f..2e08c0036d 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-claude-code/cordis.yml @@ -14,6 +14,8 @@ - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + config: + permissionMode: acceptEdits - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json new file mode 100644 index 0000000000..b75f1d9580 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json new file mode 100644 index 0000000000..6fbff83b8c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json @@ -0,0 +1,42 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "PARENT_OBSERVED_DIAGNOSTICS" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "PARENT_OBSERVED_DIAGNOSTICS" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl new file mode 100644 index 0000000000..f1c5ffe374 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -0,0 +1,51 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Use subagent_codex in the foreground","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}} +{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}} +{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}} +{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}} +{"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}} +{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} +{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}} +{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}} +{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl new file mode 100644 index 0000000000..83e4ef4368 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/knip.json b/knip.json index 3017292382..8f4b21a97f 100644 --- a/knip.json +++ b/knip.json @@ -52,6 +52,7 @@ "acp-agent/tests/fixtures/parent-sandbox-override.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", + "acp-agent/tests/fixtures/subagent-result-diagnostic.ts", "acp-agent/tests/fixtures/subagent-report-fence.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", "acp-agent/tests/fixtures/workspace-context-compaction.ts", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5a812806da..c312f5d30e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4127,7 +4127,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentResult', - declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly stopReason: SubagentStopReason;\n}', + declaration: 'export interface SubagentResult {\n readonly output: ContentBlock[];\n readonly structured?: unknown;\n readonly diagnostic?: string;\n readonly stopReason: SubagentStopReason;\n}', }, { name: 'SubagentRun', diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index a165540575..f7286cee7d 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-claude-code/README.md -README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085 -README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428 +README.md: c74c092d58d7853cedee3c5f326467a5036c50fb +README.zh.md: e87f03399e6ffd926d0ea25c6f84339ba8c94d6f diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 1a0d6e32b8..c74c092d58 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns either the strict final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -14,9 +14,9 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id ## Native settings and interaction -The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. +The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK instead of waiting for a user interface this provider does not own. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context @@ -27,8 +27,17 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | +| `permissionMode` | `dontAsk` | Native non-interactive permission policy fixed for every run from this Provider instance. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | +| `permissionMode` value | Native behavior | +|---|---| +| `dontAsk` | Deny operations that are not already authorized instead of prompting. | +| `acceptEdits` | Accept file edits; any remaining permission prompt is denied by the unattended callback. | +| `auto` | Let Claude Code's native classifier allow or deny permission requests. | +| `plan` | Run Claude Code in its native planning-only mode without tool execution. | +| `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. | + Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. @@ -39,6 +48,7 @@ The standalone composition below shows the complete explicit capability. A Profi - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: + permissionMode: acceptEdits env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY @@ -69,7 +79,7 @@ The project owner's identity-scoped distribution authorization covers the offici #### What the model sees -The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd, while its model, system instructions, tools, permissions, and authentication come from the host's native Claude settings and product installation. +The Claude Code child receives the standalone text task as one fresh SDK query. Its workspace is the parent Session cwd; its model, system instructions, tools, sandbox, and authentication come from the host's native Claude settings and product installation, while the Provider's Profile configuration fixes the query's non-interactive permission mode. #### Token effect @@ -83,7 +93,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. #### Token effect @@ -99,7 +109,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. - **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. - **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. -- **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. -- **Product payload is final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. +- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 78dab14e5e..e87f03399e 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回严格的最终答案或安全的失败说明。 ## 启动与所有权 @@ -14,9 +14,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 原生设置与交互 -提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。 +提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 @@ -27,8 +27,17 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | +| `permissionMode` | `dontAsk` | 为该提供方实例的每次运行固定原生非交互权限策略。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | +| `permissionMode` 值 | 原生行为 | +|---|---| +| `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | +| `acceptEdits` | 接受文件编辑;其余权限提示由无人值守回调拒绝。 | +| `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | +| `plan` | 使用 Claude Code 原生的仅规划模式,不执行工具。 | +| `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 | + 生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 @@ -39,6 +48,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: + permissionMode: acceptEdits env: ANTHROPIC_API_KEY: !!js process.env.ANTHROPIC_API_KEY @@ -69,7 +79,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK #### 模型看到的内容 -Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、权限和身份验证来自宿主机原生 Claude 设置与产品安装。 +Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自宿主机原生 Claude 设置与产品安装,而提供方的 Profile 配置会固定该 query 的非交互权限模式。 #### 对 token 的影响 @@ -83,7 +93,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -99,7 +109,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 - **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 - **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 -- **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 -- **产品载荷仅包含最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 +- **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 +- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index ccd150b746..3894369fc6 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -18,8 +18,11 @@ import { type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { + CLAUDE_CODE_PERMISSION_MODES, + DEFAULT_CLAUDE_CODE_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, startClaudeCodeRun, + type ClaudeCodePermissionMode, type ClaudeCodeRunSpec, } from './run.ts' @@ -28,19 +31,23 @@ export const inject = ['subagents', 'subprocess'] /* jscpd:ignore-start -- sibling product providers intentionally expose the * same two deployment-owned fields without adding a shared config owner. */ -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } export const Config: z = z.object({ env: z.dict(z.string()).default({}), + permissionMode: z.union([...CLAUDE_CODE_PERMISSION_MODES]) + .default(DEFAULT_CLAUDE_CODE_PERMISSION_MODE), disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) @@ -78,6 +85,7 @@ class ClaudeCodeProvider implements SubagentProvider { parentCwd, ), executable, + permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), @@ -94,10 +102,14 @@ class ClaudeCodeProvider implements SubagentProvider { /** * Register the fixed `claude-code` provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - explicit child environment and disposal grace. + * @param config - permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const resolved: ResolvedConfig = { + env: config.env as Record, + permissionMode: config.permissionMode ?? DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + disposeGraceMs: config.disposeGraceMs as number, + } assertPositiveFinite( 'subagent-claude-code', 'disposeGraceMs', diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 6c1e0a8dbf..ffcac2bedf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -38,6 +38,37 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = + | 'dontAsk' + | 'acceptEdits' + | 'auto' + | 'plan' + | 'bypassPermissions' + +/** Claude Code permission modes that cannot wait for a human response. */ +export const CLAUDE_CODE_PERMISSION_MODES = [ + 'dontAsk', + 'acceptEdits', + 'auto', + 'plan', + 'bypassPermissions', +] as const satisfies readonly ClaudeCodePermissionMode[] + +/** Safe default for unattended Claude Code runs. */ +export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk' + +const SUPPORTED_UNATTENDED_DIALOG_KINDS = ['refusal_fallback_prompt'] + +function unattendedDiagnostic( + mode: ClaudeCodePermissionMode, + request: 'tool permission' | 'MCP elicitation' | 'user dialog', + decision: 'denied' | 'declined' | 'cancelled', + reason: string, +): string { + return `Claude Code unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` +} + /* jscpd:ignore-start -- sibling providers intentionally keep product-private * run inputs and error normalization instead of adding a shared lifecycle owner. */ /** Fully resolved inputs for one official Claude Agent SDK query. */ @@ -46,6 +77,8 @@ export interface ClaudeCodeRunSpec { readonly cwd: string /** Exact native Claude Code executable resolved from the host PATH. */ readonly executable: string + /** Profile-selected native non-interactive permission mode. */ + readonly permissionMode: ClaudeCodePermissionMode /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -107,13 +140,19 @@ export function successfulResult(message: SDKResultMessage): string { * Consume the complete SDK stream and require one strict success plus normal * iterator completion. * @param query - published official SDK query. + * @param onPermissionDenied - records a safe fact when the SDK reports native denial. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, + onPermissionDenied?: () => void, ): Promise { let answer: string | undefined for await (const message of query) { + if (message.type === 'system' && message.subtype === 'permission_denied') { + onPermissionDenied?.() + continue + } if (message.type !== 'result') continue answer = successfulResult(message) } @@ -172,12 +211,14 @@ export async function disposeClaudeCodeChild( * @param spec - Workspace, environment, process service, and disposal policy. * @param controller - per-run cancellation owner. * @param capture - receives the real managed child synchronously from the SDK hook. + * @param captureDiagnostic - receives safe facts from unattended interaction callbacks. * @returns options that inherit native settings while disabling persistence and user questions. */ export function claudeQueryOptions( spec: ClaudeCodeRunSpec, controller: AbortController, capture: (child: SubprocessHandle) => void, + captureDiagnostic: (diagnostic: string) => void, ): Options { return { abortController: controller, @@ -186,6 +227,42 @@ export function claudeQueryOptions( env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: ['AskUserQuestion'], + permissionMode: spec.permissionMode, + ...spec.permissionMode === 'bypassPermissions' + ? { allowDangerouslySkipPermissions: true } + : { + canUseTool: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'the provider does not request human approval', + )) + return Promise.resolve({ + behavior: 'deny' as const, + message: 'This unattended Claude Code subagent cannot request human approval.', + }) + }, + }, + onElicitation: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'MCP elicitation', + 'declined', + 'the provider does not collect interactive MCP input', + )) + return Promise.resolve({ action: 'decline' }) + }, + onUserDialog: () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'user dialog', + 'cancelled', + 'the provider does not render blocking dialogs', + )) + return Promise.resolve({ behavior: 'cancelled' as const }) + }, + supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS, spawnClaudeCodeProcess: (options: SpawnOptions) => { const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) capture(child) @@ -220,12 +297,21 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined + let diagnostic: string | undefined + const captureDiagnostic = (value: string): void => { + diagnostic = value + } try { query = officialQuery({ prompt, - options: claudeQueryOptions(spec, controller, (captured) => { - child = captured - }), + options: claudeQueryOptions( + spec, + controller, + (captured) => { + child = captured + }, + captureDiagnostic, + ), }) if (child === undefined || child.pid <= 0) { throw new Error( @@ -258,7 +344,6 @@ export async function startClaudeCodeRun( ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } @@ -268,8 +353,16 @@ export async function startClaudeCodeRun( const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery), + attempt: () => consumeClaudeQuery(publishedQuery, () => { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'Claude Code denied the request before an interactive prompt', + )) + }), collectOutput: () => [], + collectDiagnostic: () => diagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts index d8a04cf953..accab2951b 100644 --- a/packages/subagent/subagent-claude-code/tests/messages-fixture.ts +++ b/packages/subagent/subagent-claude-code/tests/messages-fixture.ts @@ -4,6 +4,12 @@ import { createServer, type IncomingHttpHeaders, type ServerResponse } from 'nod export type MessagesBehavior = | { readonly kind: 'complete'; readonly text: string } | { readonly kind: 'hold' } + | { + readonly kind: 'tool-use' + readonly toolName: string + readonly input: Record + readonly finalText?: string + } /** One recorded Anthropic Messages request. */ interface RecordedMessagesRequest { @@ -81,6 +87,67 @@ function complete( response.end() } +function toolUse( + response: ServerResponse, + body: Record, + toolName: string, + input: Record, +): void { + const model = typeof body.model === 'string' ? body.model : 'fixture-model' + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + event(response, 'message_start', { + type: 'message_start', + message: { + id: 'msg_dsh_fixture_tool_use', + type: 'message', + role: 'assistant', + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 7, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + event(response, 'content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_dsh_fixture', + name: toolName, + input: {}, + }, + }) + event(response, 'content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(input), + }, + }) + event(response, 'content_block_stop', { + type: 'content_block_stop', + index: 0, + }) + event(response, 'message_delta', { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 1 }, + }) + event(response, 'message_stop', { type: 'message_stop' }) + response.end() +} + /** * Start a loopback-only Anthropic Messages SSE fixture. * @param behavior - the single response behavior for this fixture. @@ -118,6 +185,13 @@ export async function startMessagesFixture( requestStartedResolve() if (behavior.kind === 'complete') { complete(response, body, behavior.text) + } else if (behavior.kind === 'tool-use' && requests.length === 1) { + toolUse(response, body, behavior.toolName, behavior.input) + } else if ( + behavior.kind === 'tool-use' + && behavior.finalText !== undefined + ) { + complete(response, body, behavior.finalText) } // A hold deliberately leaves the response pending until client abort. }) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index f6767817c8..cf0f837424 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process' import { + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -23,6 +24,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' +import type { ClaudeCodePermissionMode } from '../src/run.ts' import { startMessagesFixture, type MessagesBehavior, @@ -122,7 +124,10 @@ interface RealHarness { readonly executable: string } -async function realHarness(behavior: MessagesBehavior): Promise<{ +async function realHarness( + behavior: MessagesBehavior, + permissionMode?: ClaudeCodePermissionMode, +): Promise<{ readonly harness: RealHarness readonly fixture: MessagesFixture }> { @@ -144,7 +149,10 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ } writeFileSync( join(claudeConfig, 'settings.json'), - `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, + `${JSON.stringify({ + model: settingsModel, + permissions: { defaultMode: 'default' }, + }, null, 2)}\n`, ) const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) @@ -177,7 +185,11 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ handles.push(handle) return handle }) - await ctx.plugin(claudeCode, { env, disposeGraceMs: 3_000 }) + await ctx.plugin(claudeCode, { + env, + ...permissionMode === undefined ? {} : { permissionMode }, + disposeGraceMs: 3_000, + }) const parent = { id: 'real-parent', session: { header: { cwd: workspace } }, @@ -294,6 +306,61 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('overrides interactive settings, denies a write, and returns a safe diagnostic', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-denied-target-')) + roots.push(root) + const target = join(root, 'denied.txt') + const { harness } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'SECRET_TOKEN must not reach the diagnostic', + }, + }) + const run = await startRequest(harness, 'Write the requested fixture file.') + await vi.waitFor(() => { + expect(observedSdkMessages.some(message => + message.type === 'system' + && message.subtype === 'permission_denied')).toBe(true) + }, { timeout: 30_000 }) + expect(existsSync(target)).toBe(false) + harness.handles[0]!.terminate() + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain(target) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + await run.dispose() + await expectQuiescent(harness.handles) + }) + + it('runs an explicitly selected bypass write in the isolated workspace', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-bypass-target-')) + roots.push(root) + const target = join(root, 'bypass.txt') + const { harness } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'bypass write completed', + }, + finalText: 'write complete', + }, 'bypassPermissions') + const run = await startRequest(harness, 'Write the requested fixture file.') + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'write complete' }], + stopReason: 'completed', + }) + expect(readFileSync(target, 'utf8')).toBe('bypass write completed') + await run.dispose() + await expectQuiescent(harness.handles) + }) + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const controller = new AbortController() diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index a3df59a74f..595b9386e7 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -3,6 +3,7 @@ import type { Options, Query, SDKMessage, + SDKPermissionDeniedMessage, SDKResultMessage, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' @@ -36,6 +37,8 @@ import { sdkEnvironmentOverlay, } from '../src/process.ts' import { + CLAUDE_CODE_PERMISSION_MODES, + DEFAULT_CLAUDE_CODE_PERMISSION_MODE, claudeQueryOptions, consumeClaudeQuery, disposeClaudeCodeChild, @@ -189,6 +192,20 @@ function failure( } as SDKResultMessage } +function permissionDenied(): SDKPermissionDeniedMessage { + return { + type: 'system', + subtype: 'permission_denied', + tool_name: 'Bash', + tool_use_id: 'tool-secret', + decision_reason_type: 'mode', + decision_reason: 'contains /private/secret.txt', + message: 'command with SECRET_TOKEN was denied', + uuid: '00000000-0000-4000-8000-000000000001', + session_id: 'session-secret', + } +} + function queryFrom( messages: readonly SDKMessage[], after?: Error, @@ -249,6 +266,7 @@ function fakeRun( const spec: ClaudeCodeRunSpec = { cwd: '/workspace', executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -325,6 +343,27 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('accepts only the five fixed non-interactive permission modes', () => { + expect(claudeCode.Config({}).permissionMode) + .toBe(DEFAULT_CLAUDE_CODE_PERMISSION_MODE) + for (const permissionMode of CLAUDE_CODE_PERMISSION_MODES) { + expect(claudeCode.Config({ permissionMode }).permissionMode) + .toBe(permissionMode) + } + for (const permissionMode of ['default', 'interactive', 'future-mode']) { + expect(() => claudeCode.Config({ permissionMode } as never)).toThrow() + } + }) + + it('resolves the safe permission default when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) + expect(ctx.subagents.getProvider('claude-code')).toBeDefined() + await ctx.fiber.dispose() + }) + it('starts through the registered provider with its resolved config and diagnostics', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -341,6 +380,7 @@ describe('task admission and package contracts', () => { CLAUDE_CONFIG_DIR: '/private/tmp/dsh-claude-code-unit-config', HOME: '/private/tmp/dsh-claude-code-unit-home', }, + permissionMode: 'auto', disposeGraceMs: 29, }) @@ -377,6 +417,7 @@ describe('task admission and package contracts', () => { ) expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) .toBe('/native/claude') + expect(queryMock.mock.calls[0]?.[0].options.permissionMode).toBe('auto') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -530,16 +571,18 @@ describe('official spawn projection', () => { }) describe('query options and result mapping', () => { - it('builds the fixed unattended options over the scrubbed environment', () => { + it('builds the fixed unattended options over the scrubbed environment', async () => { vi.stubEnv('HOST_VISIBLE', 'visible') vi.stubEnv('HOST_SECRET_TOKEN', 'must-not-leak') vi.stubEnv('DSH_INTERNAL', 'must-not-leak') const child = fakeChild() const spawn = vi.fn(() => child.handle) const captured: SubprocessHandle[] = [] + const diagnostics: string[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', executable: '/native/claude', + permissionMode: 'acceptEdits', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -548,9 +591,14 @@ describe('query options and result mapping', () => { spawn, } const controller = new AbortController() - const options = claudeQueryOptions(spec, controller, (value) => { - captured.push(value) - }) + const options = claudeQueryOptions( + spec, + controller, + (value) => { + captured.push(value) + }, + value => diagnostics.push(value), + ) expect(options).toMatchObject({ abortController: controller, @@ -558,22 +606,55 @@ describe('query options and result mapping', () => { pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], + permissionMode: 'acceptEdits', + supportedDialogKinds: ['refusal_fallback_prompt'], }) + expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') expect(options.env).toMatchObject({ HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', }) expect(options.env).not.toHaveProperty('HOST_SECRET_TOKEN') expect(options.env).not.toHaveProperty('DSH_INTERNAL') - for (const omitted of [ - 'settingSources', - 'canUseTool', - 'onElicitation', - 'onUserDialog', - 'supportedDialogKinds', - ]) { - expect(options).not.toHaveProperty(omitted) - } + expect(options).not.toHaveProperty('settingSources') + + const callbackSignal = new AbortController().signal + await expect(options.canUseTool!( + 'Bash', + { command: 'cat /private/secret.txt', token: 'SECRET_TOKEN' }, + { + signal: callbackSignal, + toolUseID: 'tool-1', + requestId: 'request-1', + blockedPath: '/private/secret.txt', + decisionReason: 'SECRET_TOKEN in /private/secret.txt', + }, + )).resolves.toEqual({ + behavior: 'deny', + message: 'This unattended Claude Code subagent cannot request human approval.', + }) + await expect(options.onElicitation!( + { + serverName: 'private-server', + message: 'enter SECRET_TOKEN', + requestedSchema: { secret: true }, + }, + { signal: callbackSignal }, + )).resolves.toEqual({ action: 'decline' }) + await expect(options.onUserDialog!( + { + dialogKind: 'refusal_fallback_prompt', + payload: { path: '/private/secret.txt', token: 'SECRET_TOKEN' }, + }, + { signal: callbackSignal }, + )).resolves.toEqual({ behavior: 'cancelled' }) + expect(diagnostics).toEqual([ + 'Claude Code unattended decision (mode: acceptEdits; request: tool permission; decision: denied): the provider does not request human approval', + 'Claude Code unattended decision (mode: acceptEdits; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + 'Claude Code unattended decision (mode: acceptEdits; request: user dialog; decision: cancelled): the provider does not render blocking dialogs', + ]) + expect(diagnostics.join('\n')).not.toContain('SECRET_TOKEN') + expect(diagnostics.join('\n')).not.toContain('/private/secret.txt') const spawned = options.spawnClaudeCodeProcess!(sdkSpawnOptions()) expect(spawned).toBeInstanceOf(ManagedClaudeCodeProcess) @@ -585,6 +666,29 @@ describe('query options and result mapping', () => { })) }) + it.each(CLAUDE_CODE_PERMISSION_MODES)( + 'maps the %s mode and only confirms the dangerous bypass', + (permissionMode) => { + const child = fakeChild() + const options = claudeQueryOptions({ + cwd: '/workspace', + executable: '/native/claude', + permissionMode, + env: {}, + disposeGraceMs: 17, + spawn: () => child.handle, + }, new AbortController(), () => {}, () => {}) + expect(options.permissionMode).toBe(permissionMode) + if (permissionMode === 'bypassPermissions') { + expect(options.allowDangerouslySkipPermissions).toBe(true) + expect(options).not.toHaveProperty('canUseTool') + } else { + expect(options).not.toHaveProperty('allowDangerouslySkipPermissions') + expect(options.canUseTool).toBeTypeOf('function') + } + }, + ) + it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) @@ -614,6 +718,16 @@ describe('query options and result mapping', () => { await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), )).rejects.toThrow('ended without a result') + + const onPermissionDenied = vi.fn() + await expect(consumeClaudeQuery(queryFrom([ + permissionDenied(), + success('after denial'), + ]), onPermissionDenied)).resolves.toEqual({ + output: [{ type: 'text', text: 'after denial' }], + stopReason: 'completed', + }) + expect(onPermissionDenied).toHaveBeenCalledOnce() }) }) @@ -667,6 +781,62 @@ describe('run publication, cancellation, and settlement', () => { } }) + it('attaches a safe diagnostic when a permission denial precedes failure', async () => { + const fixture = fakeRun([ + permissionDenied(), + failure('error_during_execution'), + ]) + const run = await startClaudeCodeRun(request(), fixture.spec) + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + await run.dispose() + }) + + it('omits captured diagnostics on success and isolates concurrent runs', async () => { + const children = [fakeChild(), fakeChild()] + let childIndex = 0 + const spec: ClaudeCodeRunSpec = { + cwd: '/workspace', + executable: '/native/claude', + permissionMode: 'dontAsk', + env: {}, + disposeGraceMs: 5, + spawn: () => children[childIndex++]!.handle, + } + queryMock.mockImplementation(({ prompt, options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return prompt === 'denied then completed' + ? queryFrom([permissionDenied(), success('completed answer')]) + : queryFrom([failure('error_during_execution')]) + }) + + const [completed, failed] = await Promise.all([ + startClaudeCodeRun( + request([{ type: 'text', text: 'denied then completed' }]), + spec, + ), + startClaudeCodeRun( + request([{ type: 'text', text: 'unrelated failure' }]), + spec, + ), + ]) + await expect(completed.result).resolves.toEqual({ + output: [{ type: 'text', text: 'completed answer' }], + stopReason: 'completed', + }) + await expect(failed.result).resolves.toEqual({ + output: [], + stopReason: 'error', + }) + await Promise.all([completed.dispose(), failed.dispose()]) + }) + it('fails closed when iteration rejects after a result', async () => { const fixture = fakeRun( [success('partial final')], @@ -704,6 +874,7 @@ describe('run publication, cancellation, and settlement', () => { const spec: ClaudeCodeRunSpec = { cwd: '/workspace', executable: '/native/claude', + permissionMode: 'dontAsk', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -755,6 +926,7 @@ describe('run publication, cancellation, and settlement', () => { { cwd: '/workspace', executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, env: {}, disposeGraceMs: 5, spawn: () => child.handle, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 6d443fc9ba..6d136cf2d0 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent/README.md -README.md: ed4a9123a2dfa5b2fa5abc67f4513547feb3d140 -README.zh.md: 3ad2ee5738a210a776d1f0b2746dcbd21d46144c +README.md: 161159264ffadf32cc769d65f19caf6d74dc862d +README.zh.md: 561206ae56684329ca54b1a524b224a73e4f30b3 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index ed4a9123a2..161159264f 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract). +`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3ad2ee5738..561206ae56 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index d049dba2be..3da8c3bd28 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -16,6 +16,31 @@ import { isAbsolute, resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts' +/** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */ +export const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 + +const DIAGNOSTIC_TRUNCATION_SUFFIX = '\n[diagnostic truncated]' +const utf8Encoder = new TextEncoder() +const utf8Decoder = new TextDecoder() + +/** + * Limit provider-authored failure detail without splitting a UTF-8 sequence. + * @param diagnostic - safe diagnostic text produced by the provider. + * @returns the original text, or a visibly truncated value within the limit. + */ +export function limitSubagentDiagnostic(diagnostic: string): string { + const bytes = utf8Encoder.encode(diagnostic) + if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic + + const suffixBytes = utf8Encoder.encode(DIAGNOSTIC_TRUNCATION_SUFFIX).byteLength + let prefixBytes = MAX_SUBAGENT_DIAGNOSTIC_BYTES - suffixBytes + while (((bytes[prefixBytes] as number) & 0b1100_0000) === 0b1000_0000) { + prefixBytes -= 1 + } + return utf8Decoder.decode(bytes.subarray(0, prefixBytes)) + + DIAGNOSTIC_TRUNCATION_SUFFIX +} + /** * The capability advertisement of an out-of-process backend: NONE. A child in * another process cannot honor parent-enforced start features @@ -134,6 +159,8 @@ export interface RunResultSettlement { attempt: () => Promise /** Snapshot the provider exposes when cancellation or failure wins settlement. */ collectOutput: () => ContentBlock[] + /** Snapshot safe provider-authored detail when a failure wins settlement. */ + collectDiagnostic?: (() => string | undefined) | undefined /** Whether local cancellation settled before the attempt's outcome is observed. */ cancelled: () => boolean /** Diagnostic sink for a failure flattened to a stop reason; a throw from it is contained. */ @@ -168,7 +195,20 @@ export async function settleRunResult(parts: RunResultSettlement): Promise { it.each([ @@ -62,4 +67,56 @@ describe('outcome mapping helpers', () => { detail: 'Error: result failed; dispose failed: Error: reap failed', }) }) + + it('keeps provider diagnostics separate in failed background outcomes', async () => { + await expect(settleRun({ + id: SessionId('child-diagnostic'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'partial assistant text' }], + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', + }), + dispose: () => Promise.resolve(), + })).resolves.toEqual({ + status: 'failed', + detail: 'error; diagnostic: Claude Code denied a tool request', + }) + }) + + it('bounds multibyte diagnostics and marks truncation', async () => { + const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limitSubagentDiagnostic(exact)).toBe(exact) + + const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + const limited = limitSubagentDiagnostic(oversized) + expect(Buffer.byteLength(limited, 'utf8')) + .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limited.endsWith('[diagnostic truncated]')).toBe(true) + expect(limited).not.toContain('\uFFFD') + + const controller = new AbortController() + const result = await settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [], + collectDiagnostic: () => oversized, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + }) + expect(result.stopReason).toBe('error') + expect(result.diagnostic).toBe(limited) + + await expect(settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [{ type: 'text', text: 'partial' }], + collectDiagnostic: () => { throw new Error('collector failed') }, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + })).resolves.toEqual({ + output: [{ type: 'text', text: 'partial' }], + stopReason: 'error', + }) + }) }) diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 41f712ba34..b5e6ebf724 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/tool-subagent/README.md -README.md: 9d7ed2e364f6a9dff26a1c9006535f898bdaabcc -README.zh.md: 8650ee35588c2615e4d6c016cb672030ee2e8194 +README.md: 28e6213b903ffffa7934e244b2a74ada519b32b2 +README.zh.md: deae0f0ff9e19b627a04704eccf4b8874f34068f diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 9d7ed2e364..28e6213b90 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,9 +8,9 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text. Abort, refusal, token limit, and other failures become errored tool results whose message contains the stop-reason headline, an optional provider-authored `SubagentResult.diagnostic`, and then any preserved partial assistant text. The diagnostic remains separate from `SubagentResult.output`, so a truncated answer is never reported as success or confused with infrastructure detail. If result collection and disposal both reject, the errored result preserves both failures. -`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). +`backgroundMode` selects both the background route and the omitted `run_in_background` default. `one-shot` waits in the foreground by default; an explicit `true` registers a plain parent-owned Task and returns canonical `{ kind: 'background', jobId }`, rendered as `started background subagent job `, even when the provider supports continuable children. Generic task tools own its later status, collection, cancellation, and notices; a failed Task keeps the stop reason and the same optional provider diagnostic in its detail. `continuable` runs in the background when the argument is omitted or `true`; an explicit `false` waits for the result in the foreground. Its background route requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result. The child's transcript by that id remains the source of its detailed output, and the optional global `send_message` tool sends it more work. The continuation service delivers one settlement notice whenever the child's Activation ends, containing its outcome and any final assistant message independently of `report`. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [background-first delegation Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md). `toolFilter` changes the child's global tool layer but is not a parent-derived authority ceiling. See the [agent-scope security non-goal](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals). @@ -51,7 +51,7 @@ Prefix-stable while provider instances, names, descriptions, and schemas are unc #### What the model sees -The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `. Intermediate child steps stay out of the parent. +The call retains the description and prompt. Success contains only the child's final text; other outcomes become `Error: `, followed by a safe provider diagnostic when present and then any partial assistant text. Intermediate child steps stay out of the parent. #### Token effect @@ -65,7 +65,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent job ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. +Start returns exactly `started subagent ` in configured continuable mode, or `started background subagent job ` in configured one-shot mode. In one-shot mode the generic task surface provides later status, final output, cancellation responses, and notices; failed status detail includes the provider diagnostic when the result supplied one. In continuable mode this tool returns no result of its own; the child's settlement reaches the parent as a [service-owned notice](../subagent/README.md#settlement-notice), an independently loaded `send_message` tool delivers follow-ups, and the child's transcript by its id is the source of its detailed output. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 8650ee3558..deae0f0ff9 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -8,9 +8,9 @@ 每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子 agent 保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本。中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息依次包含终止原因标题、可选的提供方 `SubagentResult.diagnostic`,以及子 agent 保留下来的部分 assistant 文本。诊断与 `SubagentResult.output` 保持分离,因此被截断的回答不会被报告为成功,也不会与基础设施说明混淆。如果结果收集与 dispose(资源释放)都 reject,出错结果会保留两项失败。 -`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 +`backgroundMode` 同时选择后台路由与省略 `run_in_background` 时的默认行为。`one-shot` 默认在前台等待;显式传入 `true` 时,它会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', jobId }`,渲染为 `started background subagent job `,即使提供方支持可继续子 agent 也不例外。通用 Task 工具负责其后续状态、收集、取消和通知;失败 Task 的 detail 会保留终止原因与同一份可选提供方诊断。`continuable` 在参数省略或为 `true` 时于后台运行;显式传入 `false` 时则在前台等待结果。其后台路由要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。该路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果。通过该 id 查看其 transcript(文本记录)仍是其详细输出的来源,可选的全局 `send_message` 工具则向其发送更多工作。每当子 agent 的 Activation 结束,继续执行服务都会投递一条结算通知,其中包含结束结果及可能存在的最终 assistant 消息,且这项投递不依赖 `report`。启动可继续工作不要求加载 `send_message`。见[后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[后台优先委派 Agent Note](../../../.agents/notes/implemented/feature/2026-08-11-background-first-continuable-delegation.md)。 `toolFilter` 会改变子 agent 的全局工具层,但不是从父级派生的权限上限。见 [agent 作用域的安全非目标](../../../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果变为 `Error: `。子 agent 中间步骤不会进入父级。 +调用会保留描述和提示词。成功时只包含子 agent 的最终文本;其他结果会变为 `Error: <终止原因>`,随后在存在时附上安全的提供方诊断,再附上任何部分 assistant 文本。子 agent 中间步骤不会进入父级。 #### Token 影响 @@ -65,7 +65,7 @@ #### 模型看到的内容 -在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 +在配置的可继续模式下,启动时返回内容恰为 `started subagent `;在配置的一次性模式下,则返回 `started background subagent job `。一次性模式下,通用 Task 接口提供后续状态、最终输出、取消响应和通知;若结果携带提供方诊断,失败状态的 detail 会包含它。可继续模式下,本工具不返回自己的结果;子 agent 的结算会以[服务负责的通知](../subagent/README.md#settlement-notice)到达父级,独立加载的 `send_message` 工具会投递后续消息,而通过其 id 查看子 agent 的 transcript 即是其详细输出来源。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 711ae5a7f4..86d00c6d0b 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -142,18 +142,25 @@ function stopReasonError(result: SubagentResult): string | undefined { } /** - * Append the child's preserved partial answer to a stop-reason error so a - * truncated or cancelled child's real text still reaches the parent model. + * Append provider-authored failure detail and the child's preserved partial + * answer to a stop-reason error, keeping diagnostic text separate from the + * child's assistant output. * @param error - the stop-reason headline. - * @param output - the child's selected output (`SubagentResult.output`). - * @returns the headline, extended with the partial text when any exists. + * @param result - the child's terminal result. + * @returns the headline, diagnostic, and partial text that are present. */ -function withPartialText(error: string, output: ContentBlock[]): string { - const text = output +function withDiagnosticAndPartialText(error: string, result: SubagentResult): string { + const diagnostic = result.diagnostic === undefined + ? '' + : `\nDiagnostic: ${result.diagnostic}` + const text = result.output .filter((block): block is Extract => block.type === 'text') .map(block => block.text) .join('') - return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}` + const partial = text.length === 0 + ? '' + : `\nPartial output before the run ended:\n${text}` + return `${error}${diagnostic}${partial}` } type ForegroundToolResult = { @@ -173,7 +180,7 @@ async function settleForegroundRun(run: SubagentRun): Promise { expect(text(result)).toContain('scripted subagent reply') }) + it('renders provider diagnostics before preserved partial assistant output', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(SubagentRuntime) + ctx.subagents.registerProvider({ + name: 'diagnostic', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => ({ + id: SessionId('diagnostic-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'partial assistant text' }], + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', + }), + dispose: async () => {}, + }), + }) + await ctx.plugin(tool, { provider: 'diagnostic', maxDepth: 'provider-managed' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toBe( + 'Error: subagent run failed\n' + + 'Diagnostic: Claude Code denied a tool request\n' + + 'Partial output before the run ended:\npartial assistant text', + ) + }) + it('registers under a configurable toolName so multiple providers can coexist', async () => { // The defining multi-provider use case: two loads, two distinct tool names, // each bound to a different provider — the tool registry rejects duplicate @@ -852,6 +883,53 @@ describe('dsh-tool-subagent background mode', () => { expect(text(again)).toBe('background answer\n[status: completed]') }) + it('preserves provider diagnostics in one-shot background failure detail', async () => { + const ctx = await backgroundSetup({ provider: 'mock' }) + const parent = ownerAgent(ctx, 'sess-parent') + ctx.subagents.registerProvider({ + name: 'diagnostic-background', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: async () => ({ + id: SessionId('diagnostic-background-child'), + localAgent: undefined, + result: Promise.resolve({ + output: [{ type: 'text', text: 'not background output' }], + diagnostic: 'Claude Code cancelled an unattended dialog', + stopReason: 'error', + }), + dispose: async () => {}, + }), + }) + tool.apply(ctx, { + provider: 'diagnostic-background', + toolName: 'subagent_diagnostic_background', + backgroundMode: 'one-shot', + maxDepth: 'provider-managed', + }) + + const started = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('diagnostic-background-start'), + name: 'subagent_diagnostic_background', + arguments: { description: 'd', prompt: 'p', run_in_background: true }, + agent: parent, + }) + expect(text(started)).toBe('started background subagent job subagent-1') + + const output = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('diagnostic-background-output'), + name: 'job_output', + arguments: { job_id: 'subagent-1', wait: true }, + agent: parent, + }) + expect(text(output)).toBe( + '(no new output)\n' + + '[status: failed, error; diagnostic: Claude Code cancelled an unattended dialog]', + ) + }) + it('fails loud when the tasks runtime is not loaded', async () => { const ctx = await setup({ provider: 'mock' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p', run_in_background: true }) From 62da706b64b15b31a0a960d8987d6cc9d93950d3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 17:46:13 +0800 Subject: [PATCH 082/146] fix(subagent): address Claude permission review findings --- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 4 +- ...-subagent-noninteractive-permissions.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 ++--- docs/config-catalog.zh.md | 14 ++--- examples/acp-agent/tests/acp.snapshot.ts | 5 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 4 +- .../subagent-claude-code/README.zh.md | 4 +- .../subagent-claude-code/src/index.ts | 7 ++- .../subagent/subagent-claude-code/src/run.ts | 34 +++++++----- .../tests/real-product.spec.ts | 17 ++++++ .../tests/subagent-claude-code.spec.ts | 28 ++++++++++ .../subagent/subagent/src/out-of-process.ts | 17 +++--- .../subagent/tests/run-settlement.spec.ts | 39 ++++++-------- .../tool-subagent/tests/scripted-provider.ts | 18 +++++-- .../tool-subagent/tests/tool-subagent.spec.ts | 52 ++++--------------- 18 files changed, 148 insertions(+), 125 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 42e26c2f7e..477c20bdc4 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716 +2026-08-15-product-subagent-noninteractive-permissions.md: d4d29d982e5eb2a06f7cb710860ce72c506c4ade +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 3431465e6240e169dd8d240628d651348ac029b7 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index f382bc7ad0..d4d29d982e 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -19,12 +19,12 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau | `dontAsk` | Deny operations that are not already authorized instead of prompting. | | `acceptEdits` | Accept edits; deny any remaining permission prompt through the unattended callback. | | `auto` | Let Claude Code's native classifier allow or deny permission requests. | -| `plan` | Use Claude Code's planning-only mode without tool execution. | +| `plan` | Use planning mode, deny execution approval, and return the completed plan as the final answer. | | `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. | The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. -Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. +Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; in plan mode, `ExitPlanMode` receives a fixed denial that tells the model to return the completed plan without executing it. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. ### Failure diagnostic diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 76cf53c7c9..3431465e62 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -19,12 +19,12 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认 | `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | | `acceptEdits` | 接受编辑;其余权限提示由无人值守回调拒绝。 | | `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | -| `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 | +| `plan` | 使用规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 | | `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 | 提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 -每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 +每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;在 plan 模式下,`ExitPlanMode` 会收到一项固定拒绝,要求模型返回完整计划且不得执行。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 ### 失败诊断 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 921934f9ac..afd9934e6c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 8294c2187f2b80fbf36787c784ad8b73a16206c1 -config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd +config-catalog.md: 1c78a854366e9bcd4633c56fcf31f0c6a55cefb1 +config-catalog.zh.md: cb70da0ede446aabfada3e93bc3d23c73ccb8271 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8294c2187f..1c78a85436 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2088,19 +2088,19 @@ export interface Config { * credential-scrubbed parent environment. */ env?: Record - /** Native non-interactive permission mode fixed for this Provider instance. */ + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } /** Profile-selectable non-interactive Claude Code permission mode. */ -export type ClaudeCodePermissionMode = - | 'dontAsk' - | 'acceptEdits' - | 'auto' - | 'plan' - | 'bypassPermissions' +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f35392a5b0..cb70da0ede 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2090,19 +2090,19 @@ export interface Config { * credential-scrubbed parent environment. */ env?: Record - /** Native non-interactive permission mode fixed for this Provider instance. */ + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number } /** Profile-selectable non-interactive Claude Code permission mode. */ -export type ClaudeCodePermissionMode = - | 'dontAsk' - | 'acceptEdits' - | 'auto' - | 'plan' - | 'bypassPermissions' +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` 来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 0ee4fc179c..e56029545f 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -148,7 +148,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, pinsHeader: true, - headerClass: 'product-subagent-result-diagnostic', + headerClass: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, }, { @@ -165,10 +165,7 @@ const SCENARIOS: Scenario[] = [ hasModelTurn: true, recorded: false, overridden: true, - pinsHeader: true, headerClass: 'product-subagent-codex', - systemPromptSource: 'product-subagent-codex', - toolSchemasSource: 'product-subagent-codex', configPath: PRODUCT_SUBAGENT_RESULT_DIAGNOSTIC_CONFIG, }, { diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index f7286cee7d..28fc01c965 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-claude-code/README.md -README.md: c74c092d58d7853cedee3c5f326467a5036c50fb -README.zh.md: e87f03399e6ffd926d0ea25c6f84339ba8c94d6f +README.md: e7c5debddfdc740802d7bc25c2a863c7de287d07 +README.zh.md: 9e68b5f3f3824ffc3913fdba159c95b5c94353c9 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index c74c092d58..e7c5debddf 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -16,7 +16,7 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. In plan mode, the `ExitPlanMode` approval is denied with a fixed instruction to return the completed plan as the final answer without executing it. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context @@ -35,7 +35,7 @@ The provider advertises no optional start-time capabilities and reports `inherit | `dontAsk` | Deny operations that are not already authorized instead of prompting. | | `acceptEdits` | Accept file edits; any remaining permission prompt is denied by the unattended callback. | | `auto` | Let Claude Code's native classifier allow or deny permission requests. | -| `plan` | Run Claude Code in its native planning-only mode without tool execution. | +| `plan` | Run in native planning mode, deny execution approval, and return the completed plan as the final answer. | | `bypassPermissions` | Explicitly set the SDK's dangerous confirmation and bypass permission checks. | Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index e87f03399e..9e68b5f3f3 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -16,7 +16,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。在 plan 模式下,`ExitPlanMode` 审批会被拒绝,同时用固定指令要求模型把完整计划作为最终答案返回且不得执行。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 @@ -35,7 +35,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `dontAsk` | 不弹出提示,直接拒绝尚未获授权的操作。 | | `acceptEdits` | 接受文件编辑;其余权限提示由无人值守回调拒绝。 | | `auto` | 由 Claude Code 原生分类器允许或拒绝权限请求。 | -| `plan` | 使用 Claude Code 原生的仅规划模式,不执行工具。 | +| `plan` | 使用原生规划模式,拒绝执行审批,并把完整计划作为最终答案返回。 | | `bypassPermissions` | 显式设置 SDK 的危险确认并跳过权限检查。 | 生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 3894369fc6..4960e54def 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -38,7 +38,12 @@ export interface Config { * credential-scrubbed parent environment. */ env?: Record - /** Native non-interactive permission mode fixed for this Provider instance. */ + /** + * Native non-interactive mode fixed for this Provider instance. Defaults to + * `dontAsk`; `acceptEdits` accepts edits, `auto` uses the native classifier, + * `plan` returns a plan without approving execution, and + * `bypassPermissions` explicitly skips permission checks. + */ permissionMode?: ClaudeCodePermissionMode /** Grace in milliseconds for Claude Code process-tree termination. */ disposeGraceMs?: number diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index ffcac2bedf..0134c09086 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -38,14 +38,6 @@ import { /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 -/** Profile-selectable non-interactive Claude Code permission mode. */ -export type ClaudeCodePermissionMode = - | 'dontAsk' - | 'acceptEdits' - | 'auto' - | 'plan' - | 'bypassPermissions' - /** Claude Code permission modes that cannot wait for a human response. */ export const CLAUDE_CODE_PERMISSION_MODES = [ 'dontAsk', @@ -53,16 +45,21 @@ export const CLAUDE_CODE_PERMISSION_MODES = [ 'auto', 'plan', 'bypassPermissions', -] as const satisfies readonly ClaudeCodePermissionMode[] +] as const satisfies readonly NonNullable[] + +/** Profile-selectable non-interactive Claude Code permission mode. */ +export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] /** Safe default for unattended Claude Code runs. */ export const DEFAULT_CLAUDE_CODE_PERMISSION_MODE: ClaudeCodePermissionMode = 'dontAsk' -const SUPPORTED_UNATTENDED_DIALOG_KINDS = ['refusal_fallback_prompt'] +const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ + 'refusal_fallback_prompt', +] satisfies NonNullable function unattendedDiagnostic( mode: ClaudeCodePermissionMode, - request: 'tool permission' | 'MCP elicitation' | 'user dialog', + request: 'tool permission' | 'plan approval' | 'MCP elicitation' | 'user dialog', decision: 'denied' | 'declined' | 'cancelled', reason: string, ): string { @@ -231,7 +228,19 @@ export function claudeQueryOptions( ...spec.permissionMode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : { - canUseTool: () => { + canUseTool: (toolName) => { + if (spec.permissionMode === 'plan' && toolName === 'ExitPlanMode') { + captureDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'plan approval', + 'denied', + 'the provider returns the plan without approving execution', + )) + return Promise.resolve({ + behavior: 'deny' as const, + message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', + }) + } captureDiagnostic(unattendedDiagnostic( spec.permissionMode, 'tool permission', @@ -344,6 +353,7 @@ export async function startClaudeCodeRun( ) } } + // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. if (cancelledBeforeCleanup || request.signal.aborted) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index cf0f837424..c97f18481f 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -361,6 +361,23 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) + it('returns the completed plan without approving execution', async () => { + const { harness, fixture } = await realHarness({ + kind: 'tool-use', + toolName: 'ExitPlanMode', + input: {}, + finalText: 'PLAN_ONLY_RESULT', + }, 'plan') + const run = await startRequest(harness, 'Design the fixture change without implementing it.') + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }], + stopReason: 'completed', + }) + expect(fixture.requests).toHaveLength(2) + await run.dispose() + await expectQuiescent(harness.handles) + }) + it('settles cancellation and leaves the real SDK-spawned CLI tree quiescent', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const controller = new AbortController() diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 595b9386e7..f0e1f4a1e1 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -689,6 +689,34 @@ describe('query options and result mapping', () => { }, ) + it('returns a plan without approving ExitPlanMode execution', async () => { + const child = fakeChild() + const diagnostics: string[] = [] + const options = claudeQueryOptions({ + cwd: '/workspace', + executable: '/native/claude', + permissionMode: 'plan', + env: {}, + disposeGraceMs: 17, + spawn: () => child.handle, + }, new AbortController(), () => {}, value => diagnostics.push(value)) + await expect(options.canUseTool!( + 'ExitPlanMode', + {}, + { + signal: new AbortController().signal, + toolUseID: 'exit-plan', + requestId: 'exit-plan-request', + }, + )).resolves.toEqual({ + behavior: 'deny', + message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', + }) + expect(diagnostics).toEqual([ + 'Claude Code unattended decision (mode: plan; request: plan approval; decision: denied): the provider returns the plan without approving execution', + ]) + }) + it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) diff --git a/packages/subagent/subagent/src/out-of-process.ts b/packages/subagent/subagent/src/out-of-process.ts index 3da8c3bd28..abb6dd50e7 100644 --- a/packages/subagent/subagent/src/out-of-process.ts +++ b/packages/subagent/subagent/src/out-of-process.ts @@ -17,7 +17,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentCapabilities, SubagentResult, SubagentRun, SubagentStopReason } from './types.ts' /** Maximum UTF-8 size of {@link SubagentResult.diagnostic}. */ -export const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 +const MAX_SUBAGENT_DIAGNOSTIC_BYTES = 4_096 const DIAGNOSTIC_TRUNCATION_SUFFIX = '\n[diagnostic truncated]' const utf8Encoder = new TextEncoder() @@ -28,7 +28,7 @@ const utf8Decoder = new TextDecoder() * @param diagnostic - safe diagnostic text produced by the provider. * @returns the original text, or a visibly truncated value within the limit. */ -export function limitSubagentDiagnostic(diagnostic: string): string { +function limitSubagentDiagnostic(diagnostic: string): string { const bytes = utf8Encoder.encode(diagnostic) if (bytes.byteLength <= MAX_SUBAGENT_DIAGNOSTIC_BYTES) return diagnostic @@ -195,15 +195,10 @@ export async function settleRunResult(parts: RunResultSettlement): Promise { it.each([ ['completed', { status: 'completed', output: 'partial' }], @@ -86,16 +86,18 @@ describe('outcome mapping helpers', () => { it('bounds multibyte diagnostics and marks truncation', async () => { const exact = 'x'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) - expect(limitSubagentDiagnostic(exact)).toBe(exact) - const oversized = '权限'.repeat(MAX_SUBAGENT_DIAGNOSTIC_BYTES) - const limited = limitSubagentDiagnostic(oversized) - expect(Buffer.byteLength(limited, 'utf8')) - .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) - expect(limited.endsWith('[diagnostic truncated]')).toBe(true) - expect(limited).not.toContain('\uFFFD') - const controller = new AbortController() + const exactResult = await settleRunResult({ + attempt: async () => { throw new Error('provider failed') }, + collectOutput: () => [], + collectDiagnostic: () => exact, + cancelled: () => false, + signal: controller.signal, + onAbort: () => {}, + }) + expect(exactResult.diagnostic).toBe(exact) + const result = await settleRunResult({ attempt: async () => { throw new Error('provider failed') }, collectOutput: () => [], @@ -104,19 +106,12 @@ describe('outcome mapping helpers', () => { signal: controller.signal, onAbort: () => {}, }) + const limited = result.diagnostic ?? '' + expect(Buffer.byteLength(limited, 'utf8')) + .toBeLessThanOrEqual(MAX_SUBAGENT_DIAGNOSTIC_BYTES) + expect(limited.endsWith('[diagnostic truncated]')).toBe(true) + expect(limited).not.toContain('\uFFFD') expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe(limited) - - await expect(settleRunResult({ - attempt: async () => { throw new Error('provider failed') }, - collectOutput: () => [{ type: 'text', text: 'partial' }], - collectDiagnostic: () => { throw new Error('collector failed') }, - cancelled: () => false, - signal: controller.signal, - onAbort: () => {}, - })).resolves.toEqual({ - output: [{ type: 'text', text: 'partial' }], - stopReason: 'error', - }) }) }) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index 0be724a8bb..c0da403cd4 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -27,6 +27,8 @@ export interface Config { reply?: string /** Terminal result reason. */ stopReason?: SubagentStopReason + /** Safe non-assistant detail for a non-completed result. */ + diagnostic?: string /** Start-time features advertised by the provider. */ capabilities?: Partial /** Whether tool descriptions say the child inherits completed turns. */ @@ -65,11 +67,17 @@ class ScriptedSubagentProvider implements SubagentProvider { throw new Error('scripted subagent start aborted before publication') } - const resultFor = (): SubagentResult => ({ - output, - ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, - stopReason: state.cancelled ? 'aborted' : stopReason, - }) + const resultFor = (): SubagentResult => { + const terminal = state.cancelled ? 'aborted' : stopReason + return { + output, + ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, + ...this.config.diagnostic !== undefined && terminal !== 'completed' + ? { diagnostic: this.config.diagnostic } + : {}, + stopReason: terminal, + } + } const gate = Promise.resolve(this.config.onStart?.(request)) const result = gate.then(() => new Promise((resolve) => { setTimeout(() => { resolve(resultFor()) }, 0) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index d9ff83c080..1ee5e40228 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -186,26 +186,11 @@ describe('dsh-tool-subagent', () => { }) it('renders provider diagnostics before preserved partial assistant output', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - ctx.subagents.registerProvider({ - name: 'diagnostic', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async () => ({ - id: SessionId('diagnostic-child'), - localAgent: undefined, - result: Promise.resolve({ - output: [{ type: 'text', text: 'partial assistant text' }], - diagnostic: 'Claude Code denied a tool request', - stopReason: 'error', - }), - dispose: async () => {}, - }), + const ctx = await setup({ provider: 'mock' }, { + reply: 'partial assistant text', + diagnostic: 'Claude Code denied a tool request', + stopReason: 'error', }) - await ctx.plugin(tool, { provider: 'diagnostic', maxDepth: 'provider-managed' }) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) @@ -884,34 +869,17 @@ describe('dsh-tool-subagent background mode', () => { }) it('preserves provider diagnostics in one-shot background failure detail', async () => { - const ctx = await backgroundSetup({ provider: 'mock' }) + const ctx = await backgroundSetup({ provider: 'mock' }, { + reply: 'not background output', + diagnostic: 'Claude Code cancelled an unattended dialog', + stopReason: 'error', + }) const parent = ownerAgent(ctx, 'sess-parent') - ctx.subagents.registerProvider({ - name: 'diagnostic-background', - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start: async () => ({ - id: SessionId('diagnostic-background-child'), - localAgent: undefined, - result: Promise.resolve({ - output: [{ type: 'text', text: 'not background output' }], - diagnostic: 'Claude Code cancelled an unattended dialog', - stopReason: 'error', - }), - dispose: async () => {}, - }), - }) - tool.apply(ctx, { - provider: 'diagnostic-background', - toolName: 'subagent_diagnostic_background', - backgroundMode: 'one-shot', - maxDepth: 'provider-managed', - }) const started = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('diagnostic-background-start'), - name: 'subagent_diagnostic_background', + name: 'subagent', arguments: { description: 'd', prompt: 'p', run_in_background: true }, agent: parent, }) From 7eb203069c9995ba94b2808bafa64fdcda87274d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 18:07:08 +0800 Subject: [PATCH 083/146] feat(subagent): add Codex non-interactive permission modes --- ...ubagent-providers-in-shared-host.i18n.yaml | 4 +- ...oduct-subagent-providers-in-shared-host.md | 2 +- ...ct-subagent-providers-in-shared-host.zh.md | 2 +- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 16 +- ...ude-code-and-codex-subagent-backends.zh.md | 16 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 2 +- ...t-subagent-one-shot-background-tasks.zh.md | 2 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 40 +- ...-subagent-noninteractive-permissions.zh.md | 40 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 12 +- docs/config-catalog.zh.md | 12 +- .../product-subagent-both.cordis.snapshot.yml | 2 + .../product-subagent-both.cordis.yml | 2 + ...product-subagent-codex.cordis.snapshot.yml | 2 + .../product-subagent-codex.cordis.yml | 2 + .../subagent/subagent-codex/cordis.yml | 2 + .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 28 +- packages/subagent/subagent-codex/README.zh.md | 28 +- packages/subagent/subagent-codex/src/index.ts | 18 +- packages/subagent/subagent-codex/src/run.ts | 55 ++- packages/subagent/subagent-codex/src/wire.ts | 161 +++++++- .../subagent-codex/tests/real-product.spec.ts | 77 +++- .../subagent-codex/tests/responses-fixture.ts | 6 + .../tests/subagent-codex.spec.ts | 382 +++++++++++++++++- 29 files changed, 824 insertions(+), 109 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml index 331a7a8f5d..84752e27ff 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md -2026-08-10-product-subagent-providers-in-shared-host.md: dd5cd2b3b9c424da1f9f126d4ec9cb1fa4ca7083 -2026-08-10-product-subagent-providers-in-shared-host.zh.md: 0d946fa30240a130b27f85709382595cf29f5ead +2026-08-10-product-subagent-providers-in-shared-host.md: 452ff1cca7e4e5f91f8c35092761ebe83f3ff174 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: a62bf6faa3c9bba5326da1de20ecbc2946c02bcc diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md index dd5cd2b3b9..452ff1cca7 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -16,7 +16,7 @@ Product providers remain process-scoped host-plane registrations. The [productio This note continues to own why a mounted product provider belongs on the host plane while its model-facing tool belongs to an Agent Preset. The production-install exclusion decision owns which Profiles install those optional packages. The provider-contract note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. -The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply the mounted Provider's deployment configuration, including the Claude Code `permissionMode` owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving that choice into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, or test authentication. It may supply each mounted Provider's deployment configuration, including the product-specific `permissionMode` values owned by the [non-interactive permissions decision](../feature/2026-08-15-product-subagent-noninteractive-permissions.md), without moving those choices into an Agent Preset or model-facing tool. Missing commands and product failures remain local to the attempted delegation. Only a Profile that selects the Claude Code provider carries the Claude Agent SDK's optional platform CLI payload. Production still resolves the host `claude`; the SDK payload remains provider-package installation cost rather than the production executable. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md index 0d946fa302..a62bf6faa3 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -16,7 +16,7 @@ Status: implemented 本说明继续负责解释为什么已经挂载的产品提供方属于 host plane,而面向模型的工具属于 Agent Preset。生产安装排除决策负责哪些 Profile 安装这些可选包。提供方约定说明继续负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 -这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的 Claude Code `permissionMode`,但不会把该选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本或测试身份验证。它可以提供每个已挂载 Provider 的部署配置,包括由[非交互权限决策](../feature/2026-08-15-product-subagent-noninteractive-permissions.md)负责的产品专属 `permissionMode` 值,但不会把这些选择移入 Agent Preset 或面向模型的工具。命令缺失和产品故障仍局限于发生问题的那次委派。 只有选择 Claude Code 提供方的 Profile 才会携带 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷。生产环境仍解析宿主提供的 `claude`;这份 SDK 载荷是提供方包的安装成本,而不是生产可执行文件。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index c642b870b8..597b078939 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: d0e48bb2c048351f71687a66a31c8ecdda123328 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 3fd604927c447c24e9047424ab255eb9fd628226 +2026-08-04-claude-code-and-codex-subagent-backends.md: 49c3e3fc6a99cae23b606f5a680320307c79d08c +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dc3a0737b9cfe00a850697ca482fad2743105058 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index d0e48bb2c0..49c3e3fc6a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -34,15 +34,15 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product ## Codex provider -`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains only an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Installation, login, `CODEX_HOME`, model selection, base URL, sandbox, approval policy, and product-session settings remain native Codex or deployment responsibilities. +`@deepseek-ai/dsh-subagent-codex` registers the fixed `codex` provider and starts `codex app-server --stdio` from `PATH`. Its public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a three-value native `permissionMode` that defaults to `never`. Installation, login, `CODEX_HOME`, model selection, base URL, and product-session settings remain native Codex or deployment responsibilities; the selected mode owns only the thread approval/reviewer/sandbox fields described by the non-interactive permissions decision. -Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, and creates an `ephemeral: true` thread. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. +Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; this version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted`. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail. -For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. +For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, and waits for whole-tree exit. Result failure and teardown failure stay independently observable. +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -62,7 +62,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. @@ -82,7 +82,7 @@ The project owner's distribution authorization is scoped to the official `@anthr **Product doubles as required evidence.** Doubles cover exhaustive private protocol branches but do not prove package exports, official distributions, authentication, or real process behavior. Required evidence drives each official product against a loopback model fixture. -**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Claude Code exposes only one native non-interactive mode choice in addition to environment and teardown configuration; it does not mirror product rules or add a human interaction channel. +**Plugin-managed login, product home, models, settings, sandbox rules, or fine-grained permission policy.** Those choices would create another authority beside each product's native configuration and enlarge a one-shot provider into account management. Each product exposes only one native non-interactive mode choice in addition to environment and teardown configuration; neither Provider mirrors product rules or adds a human interaction channel. **Continuation, progress, product-native background state, and shared parent context.** The provider payload remains one final answer for one self-contained task. The generic Job layer may add its id, status, notice, collection, and cancellation results, but product sessions, resume, follow-up, intermediate messages, parent transcript transfer, structured output, and provider-specific background state need separate user contracts and are not prebuilt. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed Claude Code run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 3fd604927c..dc3a0737b9 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -34,15 +34,15 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product ## Codex 提供方 -`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置仅包含显式的 `env` 覆盖项和须为正有限值的 `disposeGraceMs`,且后者不得大于仓库共享的 `MAX_TIMER_DELAY_MS`。安装、登录、`CODEX_HOME`、模型选择、基础 URL、沙箱、审批策略和产品会话设置仍由 Codex 原生机制或部署环境负责。 +`@deepseek-ai/dsh-subagent-codex` 注册固定的 `codex` 提供方,并启动 `codex app-server --stdio`,该命令从 `PATH` 解析。其公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `never` 的三值原生 `permissionMode`。安装、登录、`CODEX_HOME`、模型选择、基础 URL 和产品会话设置仍由 Codex 原生机制或部署环境负责;所选模式只拥有非交互权限决策中描述的线程 approval/reviewer/sandbox 字段。 -发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,并创建一个 `ephemeral: true` 线程。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 +发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。 -对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 +对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树并等待其退出,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,并等待整棵进程树退出。结果失败与清理失败仍可彼此独立地观察。 +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -62,7 +62,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 @@ -82,7 +82,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl **以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 -**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。Claude Code 除环境和清理配置外只公开一个原生非交互模式选择;它不会镜像产品规则,也不会增加人工交互通道。 +**由插件管理登录、产品主目录、模型、设置、沙箱规则或细粒度权限策略。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。两个产品除环境和清理配置外都只公开一个原生非交互模式选择;任一提供方都不会镜像产品规则或增加人工交互通道。 **续接、进度、产品原生后台状态和共享父级上下文。** 提供方载荷仍是一项自包含任务的一个最终回答。通用 Job 层可以额外提供 id、状态、通知、收集与取消结果,但产品会话、恢复、后续交互、中间消息、父级 transcript(文本记录)传递、结构化输出和提供方专属后台状态都需要独立的用户约定,当前实现不会预先构建这些功能。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的 Claude Code 运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index b8ef147519..b6cad9b39f 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: e389c0b8b6587cf699ea3fd30e75531bb6069108 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d424fa9d1ccb1f14fa73e342964e95b7181c8274 +2026-08-12-product-subagent-one-shot-background-tasks.md: 9aeccfadbad0d8f44ac2c294c4008b672f855027 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 74a0a614847543aff5f88cc5696246a76f2bb72f diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index e389c0b8b6..9aeccfadba 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -49,7 +49,7 @@ The ACP product compositions use the same fixed product rows and generic job con ## Verification -The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. +The Web composition test explicitly mounts both optional providers from the repository examples dependency anchor, then boots four user-preset variants—neither product, Codex, Claude Code, and both—and checks that each enabled product tool exposes `run_in_background` alongside `job_output`, `job_list`, and `job_kill`. The two package-owned Loader compositions run with an empty `PATH`, inspect the same schemas and controls, and prove that explicit provider loading starts no product process. ACP keyless snapshots pin the assembled explicit product schemas, while the existing `dsh-tool-subagent` and job suites pin foreground defaulting, Job registration, final-output collection, shared diagnostic presentation, cancellation, completion notices, owner disposal, and provider disposal. The two real product-provider suites independently prove that their native permission failures enter that same shared result before either scheduling path consumes it. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index d424fa9d1c..74a0a61484 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -49,7 +49,7 @@ ACP 产品组装使用相同的固定产品行与通用作业控制工具。其 ## 验证 -Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。 +Web 组装测试会从仓库 examples 依赖锚点显式挂载两个可选提供方,再启动四种用户 preset 变体——不启用产品、只启用 Codex、只启用 Claude Code,以及同时启用两者——并检查每个已启用产品工具都会与 `job_output`、`job_list` 和 `job_kill` 一起公开 `run_in_background`。两个由包负责的 Loader 组装会在空 `PATH` 下运行,检查相同 schema 与控制工具,并证明显式加载提供方不会启动产品进程。ACP 无密钥快照会固定显式组装后的产品 schema,而现有 `dsh-tool-subagent` 与作业测试套件会固定前台默认值、Job 登记、最终输出收集、共享诊断呈现、取消、完成通知、owner 资源释放与提供方资源释放。两个真实产品提供方测试套件还会分别证明各自的原生权限失败先进入同一个共享结果,再由任一调度路径消费。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 42e26c2f7e..9f54af3439 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: f382bc7ad058fefd8001da6181824fc9b6f767d4 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 76cf53c7c9af791db6e54a8b779a7284187d0716 +2026-08-15-product-subagent-noninteractive-permissions.md: 3615f2b719522bab0eafe59ed8335fff7c2b3cb1 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: bd8b7fadd48bb67ca17dc7db2568f8107d3993e6 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index f382bc7ad0..3615f2b719 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code subagents use Profile-selected non-interactive permissions +# Agent Note: Product subagents use Profile-selected non-interactive permissions Status: implemented @@ -6,13 +6,17 @@ English | [中文](2026-08-15-product-subagent-noninteractive-permissions.zh.md) ## Problem -The [Claude Code product provider](2026-08-04-claude-code-and-codex-subagent-backends.md) runs without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on the product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority. +The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) run without a human interface. Native permission prompts, user dialogs, or MCP elicitation therefore cannot wait for a person, but relying on either product's ambient default can still select an interactive mode. A deployment also needs to choose broader native modes without giving the parent model or one tool call a way to raise its own authority. A failed product run previously reached the [subagent seam](2026-06-21-subagent-capability-seam.md) only as a stop reason. Logs could retain the product error, but the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not distinguish a permission refusal from another failure. Reusing assistant output for that fact would misattribute infrastructure detail to the child model. ## Decision -The Claude Code Provider owns one Profile-level `permissionMode` value. It defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK: +Each product Provider owns its own Profile-level `permissionMode` value. The two Config fields deliberately use the products' native names rather than a shared restricted/automatic/full abstraction. The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. + +### Claude Code + +Claude Code defaults to `dontAsk` and accepts only the native non-interactive modes supported by the pinned Agent SDK: | Value | Native behavior | | --- | --- | @@ -22,15 +26,27 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau | `plan` | Use Claude Code's planning-only mode without tool execution. | | `bypassPermissions` | Set the SDK's explicit dangerous confirmation and bypass permission checks. | -The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. +The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. +### Codex + +Codex defaults to `never` and accepts the three native non-interactive modes exposed by Codex 0.147.0. The Provider starts the fixed app-server command, then maps the selected mode into official `thread/start` fields because CLI-global permission flags do not configure threads created later by an app-server client: + +| Value | `thread/start` fields | Native behavior | +| --- | --- | --- | +| `never` | `approvalPolicy: never`; sandbox omitted | Never prompt; execution failures return to the model under the native sandbox. | +| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review. | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement. | + +The Provider overrides only those thread fields. `CODEX_HOME`, project configuration, model/provider selection, MCP, hooks, skills, authentication, and sandbox facts not selected by the mode remain native Codex state. The wire still denies any unexpected approval, permission, user-input, or MCP request rather than opening a dynamic allow path. + ### Failure diagnostic `SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. -Claude Code records only the effective mode, request category, unattended decision, and a fixed safe reason. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. +Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. @@ -38,16 +54,16 @@ The foreground consumer presents the stop-reason headline, then the optional dia | Fact or resource | Owner | Observable behavior | | --- | --- | --- | -| Profile permission choice | Claude Code Provider Config | Invalid, interactive, or unknown values fail during configuration. | -| Permission and sandbox semantics | Claude Code and its Agent SDK | The Provider passes one native mode and does not mirror product policy. | -| Interaction decisions and safe diagnostic | One Claude Code run | Concurrent runs keep independent mode, callback, and diagnostic state. | +| Profile permission choice | Each product Provider Config | Invalid, interactive, or unknown values fail during configuration. | +| Permission and sandbox semantics | Claude Code Agent SDK or Codex app-server | Each Provider passes one native mode and does not mirror product policy. | +| Interaction decisions and safe diagnostic | One product run | Concurrent runs keep independent mode, protocol, and diagnostic state. | | Diagnostic type and byte limit | `dsh-subagent` | Consumers receive a bounded optional field separate from assistant output. | | Foreground and Job presentation | `dsh-tool-subagent` and the generic Job runtime | Scheduling choice does not change the underlying failure fact. | | Process cancellation and quiescence | Product Provider and `dsh-subprocess` | Result settlement still precedes idempotent whole-tree disposal. | ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK option mapping, bypass confirmation, callback terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, and disposal behavior. The real Agent SDK/CLI fixture proves that the default overrides an interactive native setting, denies an out-of-workspace write with safe diagnostic detail, executes an explicit bypass write only inside suite-owned temporary storage, and leaves the full process tree quiescent. Loader composition proves a non-default mode can be published without starting either product, and the keyless ACP snapshot records the same diagnostic in a foreground tool error and one-shot `job_output` while the model-facing product tool schema contains no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered @@ -55,7 +71,7 @@ Package tests pin every allowed and rejected Config value, the exact SDK option **Put permission mode in the model-facing tool or each start request.** That would let task content select authority and would duplicate a Profile deployment decision on every call. -**Copy Claude settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of `auto` and bypass modes. +**Copy product settings or map the parent Harness sandbox.** The products do not share one permission vocabulary. Mirroring their state would create a second authority and obscure the native sandbox consequences of automatic and bypass modes. **Forward prompts to a parent, Web client, or CLI.** The one-shot product run has no owned human-interaction lifecycle. Adding one would require durable request identity, routing, cancellation, and timeout semantics beyond this decision. @@ -65,8 +81,8 @@ Package tests pin every allowed and rejected Config value, the exact SDK option ## Consequences -Profiles can select Claude Code's native restricted, automatic, planning, edit-accepting, or bypass behavior before the Provider starts, while the safe default never asks a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. +Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement. -The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Codex and other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. +The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 76cf53c7c9..bd8b7fadd4 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Claude Code subagent 使用 Profile 选择的非交互权限 +# Agent Note: 产品 subagent 使用 Profile 选择的非交互权限 Status: implemented @@ -6,13 +6,17 @@ Status: implemented ## Problem -[Claude Code 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。 +[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)都在没有人工界面的情况下运行。因此,原生权限提示、用户对话或 MCP elicitation 不能等待人员响应,但依赖任一产品环境中的默认值仍可能选择交互模式。部署也需要选择更宽松的原生模式,同时不能让父模型或单次工具调用提升自身权限。 失败的产品运行此前只能把终止原因送入 [subagent seam](2026-06-21-subagent-capability-seam.md)。日志可以保留产品错误,但前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)无法区分权限拒绝和其他失败。若复用 assistant 输出承载该事实,则会把基础设施说明错误归因给子模型。 ## Decision -Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式: +每个产品提供方分别拥有自己的 Profile 级 `permissionMode` 值。两个 Config 字段有意使用各产品的原生名称,而不是共享的受限/自动/完全抽象。提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。 + +### Claude Code + +Claude Code 默认使用 `dontAsk`,而且只接受锁定版本 Agent SDK 支持的原生非交互模式: | 值 | 原生行为 | | --- | --- | @@ -22,15 +26,27 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认 | `plan` | 使用 Claude Code 的仅规划模式,不执行工具。 | | `bypassPermissions` | 设置 SDK 的显式危险确认并跳过权限检查。 | -提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 +提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 +### Codex + +Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交互模式。提供方启动固定的 app-server 命令,再把所选模式映射为官方 `thread/start` 字段,因为 CLI 全局权限 flag 不会配置之后由 app-server 客户端创建的线程: + +| 值 | `thread/start` 字段 | 原生行为 | +| --- | --- | --- | +| `never` | `approvalPolicy: never`;省略 sandbox | 永不弹出提示;执行失败会在原生 sandbox 下返回模型。 | +| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求。 | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox。 | + +提供方只覆盖这些线程字段。`CODEX_HOME`、项目配置、模型/provider 选择、MCP、hook、skill、身份验证,以及模式未选择的 sandbox 事实仍属于 Codex 原生状态。wire 仍会拒绝任何意外到达的审批、权限、用户输入或 MCP 请求,而不会开放动态 allow 通道。 + ### 失败诊断 `SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。 -Claude Code 只记录有效模式、请求类别、无人值守决定与固定的安全原因。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 +每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 @@ -38,16 +54,16 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定 | 事实或资源 | Owner | 可观察行为 | | --- | --- | --- | -| Profile 权限选择 | Claude Code 提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 | -| 权限与沙箱语义 | Claude Code 及其 Agent SDK | 提供方传入一个原生模式,不镜像产品策略。 | -| 交互决定与安全诊断 | 单次 Claude Code 运行 | 并发运行分别拥有独立的模式、回调与诊断状态。 | +| Profile 权限选择 | 各产品提供方 Config | 配置阶段会拒绝无效、交互式或未知值。 | +| 权限与沙箱语义 | Claude Code Agent SDK 或 Codex app-server | 各提供方传入一个原生模式,不镜像产品策略。 | +| 交互决定与安全诊断 | 单次产品运行 | 并发运行分别拥有独立的模式、协议与诊断状态。 | | 诊断类型与字节上限 | `dsh-subagent` | 消费方收到与 assistant 输出分离的有界可选字段。 | | 前台与 Job 呈现 | `dsh-tool-subagent` 和通用 Job 运行时 | 调度选择不会改变底层失败事实。 | | 进程取消与完全停稳 | 产品提供方和 `dsh-subprocess` | 结果结算后仍执行幂等的完整进程树资源释放。 | ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 选项映射、bypass 确认、回调终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail 和资源释放行为。真实 Agent SDK/CLI fixture 证明默认值会覆盖交互式原生设置,越出工作区的写入会被拒绝并返回安全诊断,显式 bypass 写入只会发生在测试拥有的临时存储中,而且完整进程树会完全停稳。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录同一诊断如何出现在前台工具错误与一次性 `job_output` 中,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered @@ -55,7 +71,7 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定 **把权限模式放入面向模型的工具或每次 start 请求。** 这会让任务内容选择权限,并在每次调用中重复一个 Profile 部署决定。 -**复制 Claude 设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖 `auto` 与 bypass 模式的原生沙箱后果。 +**复制产品设置或映射父级 Harness 沙箱。** 各产品并不共享同一套权限词汇。镜像这些状态会创建第二个权威,并掩盖自动模式与 bypass 模式的原生沙箱后果。 **把提示转发给父 agent、Web 客户端或 CLI。** 一次性产品运行没有由其拥有的人工交互生命周期。新增该能力需要持久请求身份、路由、取消与 timeout 语义,超出本决策范围。 @@ -65,8 +81,8 @@ Claude Code 只记录有效模式、请求类别、无人值守决定与固定 ## Consequences -Profile 可以在提供方启动前选择 Claude Code 原生的受限、自动、仅规划、编辑放行或 bypass 行为,而安全默认值绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 +Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。 -本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。Codex 与其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 +本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 921934f9ac..4fab8e561c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 8294c2187f2b80fbf36787c784ad8b73a16206c1 -config-catalog.zh.md: f35392a5b005212067c9b593b7fa2818202466dd +config-catalog.md: 8cdfc06094c75792e7f906e905ae617df8af2848 +config-catalog.zh.md: bb31b54a914ecafd6d29cbf43cfccecd31fdcb39 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8294c2187f..8cdfc06094 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2112,19 +2112,27 @@ Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/s Requires: `subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f35392a5b0..bb31b54a91 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2114,19 +2114,27 @@ export type ClaudeCodePermissionMode = 需要:`subagents` · `subprocess` ```ts config-catalog -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } + +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' ``` -来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts) +来源:[`packages/subagent/subagent-codex/src/index.ts:33`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml index 39f464a6b7..c4af1894e1 100644 --- a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -20,6 +20,8 @@ - id: deepseek-v4-pro - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml index 6c5154fc6b..837fea1f75 100644 --- a/examples/acp-agent/product-subagent-both.cordis.yml +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -9,6 +9,8 @@ - insert: - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' config: diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml index 69171c7dbf..83383814c9 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -20,6 +20,8 @@ - id: deepseek-v4-pro - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml index 2a95679e14..be399023b0 100644 --- a/examples/acp-agent/product-subagent-codex.cordis.yml +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -9,6 +9,8 @@ - insert: - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml index 6afe2b888d..fd015839ff 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/cordis.yml @@ -11,6 +11,8 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + config: + permissionMode: approve-for-me - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index da14b8ff30..22f8e3c291 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-codex/README.md -README.md: 848d170585710b682fa4ce331010fce7080de673 -README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c +README.md: 645479474599eb4cb72c0bf73838a6341c98adb7 +README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 848d170585..6454794745 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers the fixed `codex` subagent provider. Each accepted run starts the official `codex app-server --stdio` command in the delegating Session's workspace, creates one ephemeral Codex thread, submits one self-contained text task, and returns either the selected final answer or safe failure detail through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. -For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. +For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. -Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate. ## Capabilities and context @@ -23,9 +23,16 @@ The provider advertises no optional start-time capabilities and reports `inherit | Key | Default | Meaning | |---|---|---| | `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. | +| `permissionMode` | `never` | Native non-interactive approval and sandbox mode fixed for every thread from this Provider instance. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. +| `permissionMode` value | `thread/start` fields | Native behavior | +|---|---|---| +| `never` | `approvalPolicy: never`; sandbox omitted | Never ask for approval; execution failures return to the model under the native sandbox. | +| `approve-for-me` | `approvalPolicy: on-request`, `approvalsReviewer: auto_review`, `sandbox: workspace-write` | Route permission requests through Codex automatic review without a human. | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`, `sandbox: danger-full-access` | Skip approval and sandbox enforcement; this value must be selected explicitly. | + +Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The Provider overrides only the selected thread approval/reviewer/sandbox fields; all other `CODEX_HOME`, project, model, provider, MCP, hook, skill, and account settings remain native. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls. @@ -35,6 +42,7 @@ The standalone composition below shows the complete explicit capability. A Profi - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' config: + permissionMode: approve-for-me env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY @@ -55,7 +63,7 @@ The standalone composition below shows the complete explicit capability. A Profi ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. ## Model Experience @@ -63,7 +71,7 @@ The production wire intentionally implements only the app-server methods require #### What the model sees -The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd, and its model, system instructions, tools, sandbox, and authentication come from the native Codex installation and configuration. +The Codex child receives the standalone text blocks as one turn in a fresh ephemeral thread. Its workspace is the parent Session cwd; its model, system instructions, tools, and authentication come from the native Codex installation and configuration, while the Provider's Profile configuration fixes the thread's non-interactive approval and sandbox mode. #### Token effect @@ -77,7 +85,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. #### Token effect @@ -92,7 +100,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. -- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; deployments cannot configure an allow policy through this package. -- **Product payload is final text only** — reasoning, commentary, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime. +- **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. +- **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. - **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 34e9105e6a..1e9d21882b 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包注册固定的 `codex` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中启动官方 `codex app-server --stdio` 命令,创建一个临时 Codex 线程,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定返回选定的最终答案或安全失败说明。 ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized` → `thread/start { cwd, ephemeral: true }`,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 -对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。 +对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。 -本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`;成功和本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离。 ## 能力与上下文 @@ -23,9 +23,16 @@ | 配置键 | 默认值 | 含义 | |---|---|---| | `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 | +| `permissionMode` | `never` | 为该提供方实例的每个线程固定原生非交互审批与沙箱模式。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 +| `permissionMode` 值 | `thread/start` 字段 | 原生行为 | +|---|---|---| +| `never` | `approvalPolicy: never`;省略 sandbox | 永不请求审批;执行失败会在原生 sandbox 下返回模型。 | +| `approve-for-me` | `approvalPolicy: on-request`、`approvalsReviewer: auto_review`、`sandbox: workspace-write` | 由 Codex 自动评审权限请求,不等待人工。 | +| `dangerously-bypass-approvals-and-sandbox` | `approvalPolicy: never`、`sandbox: danger-full-access` | 跳过审批与 sandbox;必须显式选择该值。 | + +生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。提供方只覆盖选定线程的 approval/reviewer/sandbox 字段;其他 `CODEX_HOME`、项目、模型、provider、MCP、hook、skill 与账户设置仍由原生机制负责。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。 @@ -35,6 +42,7 @@ - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' config: + permissionMode: approve-for-me env: OPENAI_API_KEY: !!js process.env.OPENAI_API_KEY @@ -55,7 +63,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 ## 模型体验 @@ -63,7 +71,7 @@ #### 模型看到的内容 -Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具、沙箱和身份验证来自原生 Codex 安装与配置。 +Codex 子级会在一个全新的临时线程中,以单个轮次接收这些独立文本块。它的工作区是父会话 cwd;其模型、系统指令、工具和身份验证来自原生 Codex 安装与配置,而提供方的 Profile 配置会固定该线程的非交互审批与沙箱模式。 #### 对 token 的影响 @@ -77,7 +85,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案,或者在结果未完成时看到消费方给出的原样错误。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案与状态,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、stderr、工作区差异、用量信息和产品标识符均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -92,7 +100,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 -- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;部署方无法通过本包配置允许策略。 -- **产品载荷仅包含最终文本**:推理、过程说明、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部;通用 Job id、通知与状态来自共享作业运行时。 +- **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 +- **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 - **没有按实际经过时间触发的超时或副作用回滚**:长时间运行的工作由调用方取消,且取消前已更改的文件或外部系统不会恢复原状。 diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 3b1bbec799..9624824791 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -18,27 +18,34 @@ import { type SubagentProvider, } from '@deepseek-ai/dsh-subagent' import { + CODEX_PERMISSION_MODES, + DEFAULT_CODEX_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, startCodexRun, + type CodexPermissionMode, type CodexRunSpec, } from './run.ts' export const name = 'subagent-codex' export const inject = ['subagents', 'subprocess'] -/** Deployment-owned environment and process-release bound. */ +/** Deployment-owned permission, environment, and process-release settings. */ export interface Config { /** * Explicit environment entries layered over the subprocess seam's * credential-scrubbed parent environment. */ env?: Record + /** Native non-interactive permission mode fixed for this Provider instance. */ + permissionMode?: CodexPermissionMode /** Grace in milliseconds for app-server process-tree termination. */ disposeGraceMs?: number } export const Config: z = z.object({ env: z.dict(z.string()).default({}), + permissionMode: z.union([...CODEX_PERMISSION_MODES]) + .default(DEFAULT_CODEX_PERMISSION_MODE), disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) @@ -67,6 +74,7 @@ class CodexProvider implements SubagentProvider { undefined, parentCwd, ), + permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), @@ -83,10 +91,14 @@ class CodexProvider implements SubagentProvider { /** * Register the fixed `codex` provider. * @param ctx - context carrying shared subagent and subprocess services. - * @param config - explicit child environment and disposal grace. + * @param config - permission mode, child environment, and disposal grace. */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const resolved: ResolvedConfig = { + env: config.env as Record, + permissionMode: config.permissionMode ?? DEFAULT_CODEX_PERMISSION_MODE, + disposeGraceMs: config.disposeGraceMs as number, + } assertPositiveFinite( 'subagent-codex', 'disposeGraceMs', diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index ebce244f3b..1c596b806a 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -24,6 +24,22 @@ import { CodexAppServerWire } from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 +/** Profile-selectable non-interactive Codex permission mode. */ +export type CodexPermissionMode = + | 'never' + | 'approve-for-me' + | 'dangerously-bypass-approvals-and-sandbox' + +/** Codex CLI permission modes that cannot wait for a human response. */ +export const CODEX_PERMISSION_MODES = [ + 'never', + 'approve-for-me', + 'dangerously-bypass-approvals-and-sandbox', +] as const satisfies readonly CodexPermissionMode[] + +/** Safe default for unattended Codex runs. */ +export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never' + /** * Resolve the fixed app-server command for a platform. * @@ -45,6 +61,8 @@ export function codexAppServerArgv( export interface CodexRunSpec { /** Parent Session workspace, also supplied to `thread/start`. */ readonly cwd: string + /** Profile-selected native non-interactive permission mode. */ + readonly permissionMode: CodexPermissionMode /** Explicit deployment/test environment layered after the shared scrub. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -125,7 +143,7 @@ export async function startCodexRun( const child = spec.spawn({ argv: codexAppServerArgv(), cwd: spec.cwd, - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: spec.disposeGraceMs, env: spec.env, }) @@ -133,8 +151,27 @@ export async function startCodexRun( const wire = new CodexAppServerWire( child.stdout as NonNullable, child.stdin as NonNullable, + spec.permissionMode, ) - const disposeProcess = (): Promise => disposeCodexChild(wire, child) + const onStderr = (chunk: Buffer | string): void => { + process.stderr.write(chunk) + wire.observeStderr(chunk.toString()) + } + const stderrFailure = Promise.withResolvers() + const onStderrError = (error: Error): void => { + stderrFailure.reject(error) + } + void stderrFailure.promise.catch(() => {}) + child.stderr?.on('data', onStderr) + child.stderr?.on('error', onStderrError) + const disposeProcess = async (): Promise => { + try { + await disposeCodexChild(wire, child) + } finally { + child.stderr?.off('data', onStderr) + child.stderr?.off('error', onStderrError) + } + } const processFailure: Promise = child.done.then( outcome => Promise.reject(new Error( @@ -158,8 +195,16 @@ export async function startCodexRun( try { wire.start() - await Promise.race([wire.initialize(request.signal), processFailure]) - await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) + await Promise.race([ + wire.initialize(request.signal), + processFailure, + stderrFailure.promise, + ]) + await Promise.race([ + wire.startThread(spec.cwd, request.signal), + processFailure, + stderrFailure.promise, + ]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) try { @@ -181,8 +226,10 @@ export async function startCodexRun( attempt: () => Promise.race([ wire.runTurn(texts, runAbort.signal), processFailure, + stderrFailure.promise, ]), collectOutput, + collectDiagnostic: () => wire.collectDiagnostic(), cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index bc00ff0acf..c4274c8b2c 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -11,9 +11,42 @@ import type { Readable, Writable } from 'node:stream' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult } from '@deepseek-ai/dsh-subagent' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' +import type { CodexPermissionMode } from './run.ts' type JsonObject = Record +const THREAD_PERMISSION_PARAMS: Readonly> = { + never: { approvalPolicy: 'never' }, + 'approve-for-me': { + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }, + 'dangerously-bypass-approvals-and-sandbox': { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + }, +} + +const STDERR_PERMISSION_SIGNATURES = [ + { + text: 'approval policy is Never; reject command', + request: 'command execution', + decision: 'denied', + reason: 'Codex rejected an escalation because the selected policy never asks for approval', + }, + { + text: 'recorded sandbox violation:', + request: 'sandbox execution', + decision: 'failed', + reason: 'Codex reported a sandbox violation', + }, +] as const + +const STDERR_SIGNATURE_TAIL_CHARS = Math.max( + ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length), +) - 1 + function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -47,6 +80,24 @@ function isContextWindowExceeded(turn: JsonObject): boolean { && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' } +function isSandboxFailure(turn: JsonObject): boolean { + if (turn.status !== 'failed') return false + const error = turn.error + return error !== null + && typeof error === 'object' + && !Array.isArray(error) + && (error as JsonObject).codexErrorInfo === 'sandboxError' +} + +function unattendedDiagnostic( + mode: CodexPermissionMode, + request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution', + decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed', + reason: string, +): string { + return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` +} + function thrown(value: unknown): Error { /* v8 ignore next -- typed protocol and stream failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -93,11 +144,14 @@ export class CodexAppServerWire { }> = [] private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined + private diagnostic: string | undefined + private stderrTail = '' private closed = false constructor( private readonly input: Readable, output: Writable, + private readonly permissionMode: CodexPermissionMode = 'never', ) { this.transport = new JsonRpcLineTransport(input, output) // Fatal protocol state can arrive after the current guarded operation has @@ -154,6 +208,7 @@ export class CodexAppServerWire { const response = object(await this.guarded(this.transport.request('thread/start', { cwd, ephemeral: true, + ...THREAD_PERMISSION_PARAMS[this.permissionMode], }, signal), signal), 'thread/start response') const thread = object(response.thread, 'thread/start thread') const id = string(thread.id, 'thread/start thread id') @@ -191,8 +246,18 @@ export class CodexAppServerWire { return { output: this.collectOutput(), stopReason: 'max-tokens' } } if (status !== 'completed') { + const sandboxFailure = isSandboxFailure(terminal) + if (sandboxFailure) { + this.recordDiagnostic( + 'sandbox execution', + 'failed', + 'Codex reported a sandbox failure', + ) + } const detail = status === 'failed' - ? `: ${JSON.stringify(terminal.error)}` + ? sandboxFailure + ? ': sandboxError' + : ': error' : '' throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) } @@ -226,6 +291,36 @@ export class CodexAppServerWire { : [] } + /** + * The latest safe unattended permission fact observed for this run. + * @returns provider-authored diagnostic text, when one was observed. + */ + collectDiagnostic(): string | undefined { + return this.diagnostic + } + + /** + * Observe product stderr while retaining only enough tail to recognize fixed + * permission signatures. The raw text is never copied into the diagnostic. + * @param chunk - one decoded stderr chunk already forwarded to the host. + */ + observeStderr(chunk: string): void { + const observed = `${this.stderrTail}${chunk}` + let latestIndex = -1 + let latest: (typeof STDERR_PERMISSION_SIGNATURES)[number] | undefined + for (const signature of STDERR_PERMISSION_SIGNATURES) { + const index = observed.lastIndexOf(signature.text) + if (index > latestIndex) { + latestIndex = index + latest = signature + } + } + if (latest !== undefined) { + this.recordDiagnostic(latest.request, latest.decision, latest.reason) + } + this.stderrTail = observed.slice(-STDERR_SIGNATURE_TAIL_CHARS) + } + /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ close(): void { if (this.closed) return @@ -291,21 +386,67 @@ export class CodexAppServerWire { } } + private recordDiagnostic( + request: Parameters[1], + decision: Parameters[2], + reason: string, + ): void { + this.diagnostic = unattendedDiagnostic( + this.permissionMode, + request, + decision, + reason, + ) + } + private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { case 'item/commandExecution/requestApproval': + this.validateRunIds(params) + { + const decision = unattendedDecision(params) + this.recordDiagnostic( + 'command approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/fileChange/requestApproval': this.validateRunIds(params) - return Promise.resolve({ decision: unattendedDecision(params) }) + { + const decision = unattendedDecision(params) + this.recordDiagnostic( + 'file approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/permissions/requestApproval': this.validateRunIds(params) + this.recordDiagnostic( + 'permission grant', + 'denied', + 'the provider grants no additional turn permissions', + ) return Promise.resolve({ permissions: {}, scope: 'turn' }) case 'item/tool/requestUserInput': this.validateRunIds(params) + this.recordDiagnostic( + 'user input', + 'empty response', + 'the provider does not collect interactive answers', + ) return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': this.validateRunIds(params, true) + this.recordDiagnostic( + 'MCP elicitation', + 'declined', + 'the provider does not collect interactive MCP input', + ) return Promise.resolve({ action: 'decline', content: null, _meta: null }) default: throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) @@ -340,6 +481,22 @@ export class CodexAppServerWire { } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') + if (item.type === 'commandExecution' && item.status === 'declined') { + this.recordDiagnostic( + 'command execution', + 'declined', + 'Codex declined the command under the selected permission mode', + ) + return + } + if (item.type === 'fileChange' && item.status === 'declined') { + this.recordDiagnostic( + 'file change', + 'declined', + 'Codex declined the file change under the selected permission mode', + ) + return + } if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 551d6db765..a060d1555d 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -18,6 +18,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' +import type { CodexPermissionMode } from '../src/run.ts' import { startResponsesFixture, type ResponsesBehavior, @@ -53,7 +54,10 @@ interface RealHarness { readonly workspace: string } -async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ +async function realHarness( + script: readonly ResponsesBehavior[], + permissionMode?: CodexPermissionMode, +): Promise<{ readonly harness: RealHarness readonly fixture: ResponsesFixture }> { @@ -106,7 +110,11 @@ async function realHarness(script: readonly ResponsesBehavior[]): Promise<{ handles.push(handle) return handle }) - await ctx.plugin(codex, { env, disposeGraceMs: 2_000 }) + await ctx.plugin(codex, { + env, + ...permissionMode === undefined ? {} : { permissionMode }, + disposeGraceMs: 2_000, + }) const parent = { id: 'real-parent', session: { header: { cwd: workspace } }, @@ -141,12 +149,12 @@ function responseInputTexts(body: Record): string[] { } describe('real @openai/codex 0.147.0 product', () => { - it('passes the exact task and fake authentication to local Responses and returns exact text', async () => { + it('starts approve-for-me through the real app-server and returns exact text', async () => { const sentinel = 'REAL_CODEX_SENTINEL_0_147_0' const task = 'Return the fixture sentinel exactly.' const { harness, fixture } = await realHarness([ { kind: 'complete', text: sentinel }, - ]) + ], 'approve-for-me') expect(codexPackage.version).toBe('0.147.0') const version = await execFileAsync(process.execPath, [codexEntry, '--version'], { env: { ...process.env, ...harness.env }, @@ -173,7 +181,7 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) - it('cancels a real app-server command approval without executing the command', async () => { + it('overrides on-request with never and reports a denied command safely', async () => { const command = process.platform === 'win32' ? 'cmd /c type nul > approval-side-effect' : 'touch approval-side-effect' @@ -200,6 +208,11 @@ describe('real @openai/codex 0.147.0 product', () => { kind: 'advertisedFunctionCall', choices: commandCalls, }, + { + kind: 'error', + status: 400, + message: 'fixture terminal failure after permission denial', + }, ]) const sideEffect = join(harness.workspace, 'approval-side-effect') const run = await harness.ctx.subagents.start('codex', { @@ -207,14 +220,20 @@ describe('real @openai/codex 0.147.0 product', () => { parent: harness.parent, signal: new AbortController().signal, }) - await expect(run.result).resolves.toEqual({ - output: [], - stopReason: 'error', - }) + const result = await run.result + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + expect([ + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ]).toContain(result.diagnostic) + expect(result.diagnostic).not.toContain(command) + expect(result.diagnostic).not.toContain(harness.workspace) await run.dispose() expect(existsSync(sideEffect)).toBe(false) - expect(fixture.requests).toHaveLength(1) + expect(fixture.requests).toHaveLength(2) const tools = fixture.requests[0]!.body.tools as Array> expect(commandCalls.some(call => tools.some(tool => ( tool.type === 'function' && tool.name === call.name @@ -225,6 +244,44 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { + const sideEffect = 'bypass-side-effect' + const command = process.platform === 'win32' + ? `cmd /c echo bypass>${sideEffect}` + : `printf bypass > ${sideEffect}` + const commandCalls = [ + { + name: 'exec_command', + arguments: { + cmd: command, + }, + }, + { + name: 'shell_command', + arguments: { + command, + }, + }, + ] as const + const { harness } = await realHarness([ + { kind: 'advertisedFunctionCall', choices: commandCalls }, + { kind: 'complete', text: 'bypass complete' }, + ], 'dangerously-bypass-approvals-and-sandbox') + const target = join(harness.workspace, sideEffect) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Create the fixture side effect.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'bypass complete' }], + stopReason: 'completed', + }) + expect(readFileSync(target, 'utf8').trim()).toBe('bypass') + await run.dispose() + await expectQuiescent(harness.handles) + }, 60_000) + it('settles cancellation locally and leaves the real app-server tree quiescent', async () => { const { harness, fixture } = await realHarness([{ kind: 'hold' }]) const controller = new AbortController() diff --git a/packages/subagent/subagent-codex/tests/responses-fixture.ts b/packages/subagent/subagent-codex/tests/responses-fixture.ts index 2b6e5868ae..c2ef18d803 100644 --- a/packages/subagent/subagent-codex/tests/responses-fixture.ts +++ b/packages/subagent/subagent-codex/tests/responses-fixture.ts @@ -17,6 +17,7 @@ interface RecordedResponsesRequest { /** Behavior consumed by one Responses request. */ export type ResponsesBehavior = | { readonly kind: 'complete'; readonly text: string } + | { readonly kind: 'error'; readonly status: number; readonly message: string } | { readonly kind: 'functionCall' readonly name: string @@ -275,6 +276,11 @@ export async function startResponsesFixture( response.end(JSON.stringify({ error: { message: 'none of the fixture function calls was advertised' } })) return } + if (behavior.kind === 'error') { + response.writeHead(behavior.status, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: behavior.message } })) + return + } response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 37b2e9ff0b..b09e2ce46c 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -15,6 +15,8 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import * as invariant from '../src/invariant.ts' import { + CODEX_PERMISSION_MODES, + DEFAULT_CODEX_PERMISSION_MODE, codexAppServerArgv, DEFAULT_DISPOSE_GRACE_MS, disposeCodexChild, @@ -101,6 +103,7 @@ interface FakeChild { readonly peer: ProtocolPeer readonly fromChild: PassThrough readonly toChild: PassThrough + readonly stderr: PassThrough readonly settle: (outcome?: SubprocessOutcome) => void readonly fail: (error: Error) => void readonly terminate: () => void @@ -110,6 +113,7 @@ interface FakeChild { function fakeChild(options: FakeChildOptions = {}): FakeChild { const fromChild = new PassThrough() const toChild = new PassThrough() + const stderr = new PassThrough() const peer = new ProtocolPeer(toChild, fromChild) let exited = false let resolveDone!: (outcome: SubprocessOutcome) => void @@ -159,7 +163,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { pid: options.pid ?? 1234, stdin: toChild, stdout: fromChild, - stderr: undefined, + stderr, collected: {}, done, terminate, @@ -170,6 +174,7 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { peer, fromChild, toChild, + stderr, settle, fail, terminate, @@ -183,6 +188,7 @@ function runSpec( ): CodexRunSpec { return { cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, env: {}, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, spawn: () => child.handle, @@ -260,7 +266,10 @@ function turnCompleted( } describe('task admission and package contracts', () => { - it('resolves the fixed app-server command through the Windows npm shim boundary', () => { + it('keeps the app-server command fixed on POSIX and Windows', () => { + expect(codexAppServerArgv('linux')).toEqual([ + 'codex', 'app-server', '--stdio', + ]) expect(codexAppServerArgv('win32')).toEqual([ 'cmd.exe', '/d', @@ -270,7 +279,6 @@ describe('task admission and package contracts', () => { 'app-server', '--stdio', ]) - expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio']) }) it('accepts one or more text blocks and rejects empty or non-text tasks', () => { @@ -314,6 +322,61 @@ describe('task admission and package contracts', () => { await ctx.fiber.dispose() }) + it('accepts only the three fixed non-interactive permission modes', () => { + expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE) + for (const permissionMode of CODEX_PERMISSION_MODES) { + expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode) + } + for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) { + expect(() => codex.Config({ permissionMode } as never)).toThrow() + } + }) + + it('resolves the safe permission default when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(SubagentRuntime) + await ctx.plugin(LocalSubprocessRuntime) + codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 }) + expect(ctx.subagents.getProvider('codex')).toBeDefined() + await ctx.fiber.dispose() + }) + + it.each([ + ['never', { approvalPolicy: 'never' }], + ['approve-for-me', { + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }], + ['dangerously-bypass-approvals-and-sandbox', { + approvalPolicy: 'never', + sandbox: 'danger-full-access', + }], + ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => { + const child = fakeChild() + const wire = new CodexAppServerWire( + child.handle.stdout!, + child.handle.stdin!, + permissionMode, + ) + wire.start() + const initializing = wire.initialize(new AbortController().signal) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await initializing + await child.peer.nextMethod('initialized') + const starting = wire.startThread('/workspace', new AbortController().signal) + const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: '/workspace', + ephemeral: true, + ...expected, + }) + child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) + await starting + wire.close() + }) + it('requires a parent session cwd without suggesting unsupported config', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -386,7 +449,11 @@ describe('CodexAppServerWire', () => { const starting = wire.startThread('/workspace', new AbortController().signal) const threadStart = await child.peer.nextMethod('thread/start') - expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true }) + expect(threadStart.params).toEqual({ + cwd: '/workspace', + ephemeral: true, + approvalPolicy: 'never', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) await starting @@ -586,11 +653,15 @@ describe('CodexAppServerWire', () => { threadId: 'thread-1', turnId: 'turn-1', availableDecisions: ['decline', 'cancel'], + command: 'cat /private/secret.txt', }, }) expect(await child.peer.nextResponse('command')).toMatchObject({ result: { decision: 'cancel' }, }) + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + ) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() @@ -604,30 +675,35 @@ describe('CodexAppServerWire', () => { availableDecisions: ['decline'], }, result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, { id: 'file-default', method: 'item/fileChange/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, { id: 'permissions', method: 'item/permissions/requestApproval', params: { threadId: 'thread-1', turnId: 'turn-1' }, result: { permissions: {}, scope: 'turn' }, + diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions', }, { id: 'user-input', method: 'item/tool/requestUserInput', params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] }, result: { answers: {} }, + diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers', }, { id: 'mcp', method: 'mcpServer/elicitation/request', params: { threadId: 'thread-1', turnId: null }, result: { action: 'decline', content: null, _meta: null }, + diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', }, ] as const for (const serverRequest of requests) { @@ -635,8 +711,133 @@ describe('CodexAppServerWire', () => { expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({ result: serverRequest.result, }) + expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic) } + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + wire.close() + }) + + it('records only a safe diagnostic for an explicit sandbox failure', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'failed at /private/secret.txt with SECRET_TOKEN', + additionalDetails: 'raw command payload', + codexErrorInfo: 'sandboxError', + })) + await expect(result).rejects.toThrow('status failed') + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', + ) + expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + wire.close() + }) + + it('records a declined command item without retaining its payload', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'commandExecution', + status: 'declined', + command: 'cat /private/secret.txt', + }, + }, + }, + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + }), + ) + await expect(result).rejects.toThrow('status failed') + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + ) + expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + wire.close() + }) + + it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => { + const first = fakeChild() + const largeWire = new CodexAppServerWire( + first.handle.stdout!, + first.handle.stdin!, + 'never', + ) + largeWire.observeStderr( + `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`, + ) + expect(largeWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ) + expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + + const second = fakeChild() + const splitWire = new CodexAppServerWire( + second.handle.stdout!, + second.handle.stdin!, + 'never', + ) + splitWire.observeStderr('SECRET_TOKEN approval policy is Ne') + splitWire.observeStderr('ver; reject command — /private/secret.txt') + expect(splitWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + ) + expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN') + expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt') + + const third = fakeChild() + const orderedWire = new CodexAppServerWire( + third.handle.stdout!, + third.handle.stdin!, + 'dangerously-bypass-approvals-and-sandbox', + ) + orderedWire.observeStderr( + 'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt', + ) + expect(orderedWire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation', + ) + expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt') + }) + + it('does not reapply an old stderr signature after a newer request diagnostic', async () => { + const { child, wire } = await initializeWire() + wire.observeStderr('approval policy is Never; reject command') + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'file-approval', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + }) + await child.peer.nextResponse('file-approval') + expect(wire.collectDiagnostic()).toContain('request: file approval') + wire.observeStderr('later benign stderr') + expect(wire.collectDiagnostic()).toContain('request: file approval') child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) wire.close() @@ -864,7 +1065,7 @@ describe('run lifecycle and quiescence', () => { expect(spawn).toHaveBeenCalledWith({ argv: codexAppServerArgv(), cwd: process.cwd(), - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, graceMs: DEFAULT_DISPOSE_GRACE_MS, env: { OPENAI_API_KEY: 'fake' }, }) @@ -929,6 +1130,73 @@ describe('run lifecycle and quiescence', () => { await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) await run.dispose() } + { + const child = fakeChild() + const { run, turnStart } = await publishRun(child, undefined, { + onError: (error) => { errors.push(error.message) }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.emit('error', new Error('stderr broke')) + await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + expect(errors.at(-1)).toContain('stderr broke') + await run.dispose() + expect(child.stderr.listenerCount('error')).toBe(0) + } + }) + + it('attaches a safe permission diagnostic when a published run fails', async () => { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'approval-diagnostic', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + command: 'cat /private/secret.txt', + }, + }) + expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({ + result: { decision: 'cancel' }, + }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) + await run.dispose() + }) + + it('forwards stderr while extracting only a fixed safe permission signature', async () => { + const child = fakeChild() + const forwarded: string[] = [] + const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + forwarded.push(String(chunk)) + return true + }) + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.write('SECRET_TOKEN approval policy is Ne') + child.stderr.write('ver; reject command — /private/secret.txt') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + expect(forwarded.join('')).toContain('SECRET_TOKEN') + await run.dispose() + expect(child.stderr.listenerCount('data')).toBe(0) + write.mockRestore() }) it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { @@ -939,6 +1207,7 @@ describe('run lifecycle and quiescence', () => { request(undefined, controller.signal), { cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, env: {}, disposeGraceMs: 10, spawn, @@ -952,6 +1221,14 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, null) await expect(starting).rejects.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + + const stderrChild = fakeChild() + const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) + await stderrChild.peer.nextMethod('initialize') + stderrChild.stderr.emit('error', new Error('startup stderr broke')) + await expect(stderrStarting).rejects.toThrow('startup stderr broke') + expect(stderrChild.terminate).toHaveBeenCalledTimes(1) + expect(stderrChild.stderr.listenerCount('error')).toBe(0) }) it('rolls back an abort that wins immediately after thread creation', async () => { @@ -965,6 +1242,11 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: process.cwd(), + ephemeral: true, + approvalPolicy: 'never', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) controller.abort('startup race') await expect(starting).rejects.toThrow('aborted before run publication') @@ -1012,6 +1294,55 @@ describe('run lifecycle and quiescence', () => { await Promise.all(runs.map(entry => entry.run.dispose())) }) + it('isolates permission modes and diagnostics across overlapping runs', async () => { + const first = await publishRun(fakeChild(), undefined, { + permissionMode: 'never', + }) + const second = await publishRun(fakeChild(), undefined, { + permissionMode: 'dangerously-bypass-approvals-and-sandbox', + }) + first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } }) + second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } }) + await nextTask() + first.child.peer.send({ + id: 'never-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-never', + availableDecisions: ['cancel'], + }, + }) + second.child.peer.send({ + id: 'bypass-elicitation', + method: 'mcpServer/elicitation/request', + params: { threadId: 'thread-1', turnId: null }, + }) + await Promise.all([ + first.child.peer.nextResponse('never-approval'), + second.child.peer.nextResponse('bypass-elicitation'), + ]) + first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', { + message: 'first failure', + codexErrorInfo: 'other', + })) + second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', { + message: 'second failure', + codexErrorInfo: 'other', + })) + await expect(first.run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) + await expect(second.run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + stopReason: 'error', + }) + await Promise.all([first.run.dispose(), second.run.dispose()]) + }) + it('uses the registered provider config and logs flattened errors', async () => { const ctx = new Context() await ctx.plugin(SubagentRuntime) @@ -1024,6 +1355,7 @@ describe('run lifecycle and quiescence', () => { }) as typeof ctx.logger.warn await ctx.plugin(codex, { env: { OPENAI_API_KEY: 'fake' }, + permissionMode: 'approve-for-me', disposeGraceMs: 25, }) const starting = ctx.subagents.start('codex', { @@ -1035,20 +1367,50 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) await child.peer.nextMethod('initialized') const threadStart = await child.peer.nextMethod('thread/start') + expect(threadStart.params).toEqual({ + cwd: process.cwd(), + ephemeral: true, + approvalPolicy: 'on-request', + approvalsReviewer: 'auto_review', + sandbox: 'workspace-write', + }) child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } }) const run = await starting - await child.peer.nextMethod('turn/start') - child.settle({ exitCode: 1, signal: null }) - await expect(run.result).resolves.toMatchObject({ stopReason: 'error' }) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() + child.peer.send({ + id: 'provider-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + command: 'cat /private/secret.txt', + }, + }) + await child.peer.nextResponse('provider-approval') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: 'other', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval', + stopReason: 'error', + }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ + argv: ['codex', 'app-server', '--stdio'], env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), })) expect(warnings).toEqual([ - expect.stringContaining('subagent-codex: child run failed (error):'), + expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'), ]) - await run.dispose().catch(() => {}) + expect(warnings.join('\n')).not.toContain('SECRET_TOKEN') + expect(warnings.join('\n')).not.toContain('/private/secret.txt') + await run.dispose() await ctx.fiber.dispose() }) }) From a3deb9aa5ed874a638d728a3cc7b8bf86a66281b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 18:15:53 +0800 Subject: [PATCH 084/146] fix(subagent): keep Claude plan mode non-executing --- ...agent-noninteractive-permissions.i18n.yaml | 4 ++-- ...uct-subagent-noninteractive-permissions.md | 2 +- ...-subagent-noninteractive-permissions.zh.md | 2 +- .../subagent-claude-code/README.i18n.yaml | 4 ++-- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent/subagent-claude-code/src/run.ts | 20 ++++------------ .../tests/real-product.spec.ts | 10 ++++++-- .../tests/subagent-claude-code.spec.ts | 23 ++++++------------- 9 files changed, 28 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 477c20bdc4..75cd0ef2da 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: d4d29d982e5eb2a06f7cb710860ce72c506c4ade -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 3431465e6240e169dd8d240628d651348ac029b7 +2026-08-15-product-subagent-noninteractive-permissions.md: 9ab4887dda61895161392e7ff3aee164e765ee26 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: f6d7b438fb0e9b501640be96c298f8690d1313d3 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index d4d29d982e..9ab4887dda 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -24,7 +24,7 @@ The Claude Code Provider owns one Profile-level `permissionMode` value. It defau The Provider fixes the resolved value for every run from that plugin instance. The subagent tool schema and `SubagentStartRequest` contain no permission field, so a model or individual delegation cannot change it. The Provider continues to omit `settingSources`: Claude Code remains the owner of user, project, and local settings, authentication, tools, and sandbox behavior outside the selected mode. -Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; in plan mode, `ExitPlanMode` receives a fixed denial that tells the model to return the completed plan without executing it. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. +Every query disables `AskUserQuestion`. Non-bypass permission callbacks deny instead of returning the SDK's indefinitely blocking `null`; plan mode also places `ExitPlanMode` in `disallowedTools`, so native allow rules cannot switch the unattended query back to execution. MCP elicitation is declined; the supported refusal dialog is cancelled; undeclared dialog kinds use the SDK's no-dialog failure behavior. A native `permission_denied` message records the same operation-local fact. These paths do not create an approval session, queue, cache, or retry loop. ### Failure diagnostic diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 3431465e62..f6d7b438fb 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -24,7 +24,7 @@ Claude Code 提供方拥有一个 Profile 级 `permissionMode` 值。它默认 提供方会为该插件实例的每次运行固定已解析值。subagent 工具 schema 与 `SubagentStartRequest` 都不包含权限字段,因此模型或单次委派无法改变它。提供方继续省略 `settingSources`:除所选模式以外,用户、项目和本地设置、身份验证、工具与沙箱行为仍由 Claude Code 拥有。 -每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;在 plan 模式下,`ExitPlanMode` 会收到一项固定拒绝,要求模型返回完整计划且不得执行。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 +每次 query 都禁用 `AskUserQuestion`。非 bypass 模式的权限回调会拒绝请求,而不会返回 SDK 中会无限阻塞的 `null`;plan 模式还会把 `ExitPlanMode` 放入 `disallowedTools`,因此原生 allow 规则无法把无人值守 query 切回执行模式。MCP elicitation 会被拒绝;已支持的拒绝对话会被取消;未声明的对话类型使用 SDK 的无对话失败行为。原生 `permission_denied` 消息会记录同一份当前运行事实。这些路径不会创建审批会话、队列、缓存或重试循环。 ### 失败诊断 diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 28fc01c965..0185afd2de 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/subagent/subagent-claude-code/README.md -README.md: e7c5debddfdc740802d7bc25c2a863c7de287d07 -README.zh.md: 9e68b5f3f3824ffc3913fdba159c95b5c94353c9 +README.md: be3b2262addc487e545fed1f792600a9a5ca24c0 +README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index e7c5debddf..be3b2262ad 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -16,7 +16,7 @@ Local cancellation wins the result race and maps to `aborted`. `dispose()` is id The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. In plan mode, the `ExitPlanMode` approval is denied with a fixed instruction to return the completed plan as the final answer without executing it. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. ## Capabilities and context diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 9e68b5f3f3..7ea1b8ca72 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -16,7 +16,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。在 plan 模式下,`ExitPlanMode` 审批会被拒绝,同时用固定指令要求模型把完整计划作为最终答案返回且不得执行。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 ## 能力与上下文 diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 0134c09086..82dcfb4eb4 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -59,7 +59,7 @@ const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ function unattendedDiagnostic( mode: ClaudeCodePermissionMode, - request: 'tool permission' | 'plan approval' | 'MCP elicitation' | 'user dialog', + request: 'tool permission' | 'MCP elicitation' | 'user dialog', decision: 'denied' | 'declined' | 'cancelled', reason: string, ): string { @@ -223,24 +223,14 @@ export function claudeQueryOptions( pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, - disallowedTools: ['AskUserQuestion'], + disallowedTools: spec.permissionMode === 'plan' + ? ['AskUserQuestion', 'ExitPlanMode'] + : ['AskUserQuestion'], permissionMode: spec.permissionMode, ...spec.permissionMode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : { - canUseTool: (toolName) => { - if (spec.permissionMode === 'plan' && toolName === 'ExitPlanMode') { - captureDiagnostic(unattendedDiagnostic( - spec.permissionMode, - 'plan approval', - 'denied', - 'the provider returns the plan without approving execution', - )) - return Promise.resolve({ - behavior: 'deny' as const, - message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', - }) - } + canUseTool: () => { captureDiagnostic(unattendedDiagnostic( spec.permissionMode, 'tool permission', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index c97f18481f..a2e7111ece 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -127,6 +127,7 @@ interface RealHarness { async function realHarness( behavior: MessagesBehavior, permissionMode?: ClaudeCodePermissionMode, + nativeAllow: readonly string[] = [], ): Promise<{ readonly harness: RealHarness readonly fixture: MessagesFixture @@ -151,7 +152,10 @@ async function realHarness( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel, - permissions: { defaultMode: 'default' }, + permissions: { + defaultMode: 'default', + ...nativeAllow.length === 0 ? {} : { allow: nativeAllow }, + }, }, null, 2)}\n`, ) const fixture = await startMessagesFixture(behavior) @@ -367,13 +371,15 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 toolName: 'ExitPlanMode', input: {}, finalText: 'PLAN_ONLY_RESULT', - }, 'plan') + }, 'plan', ['ExitPlanMode']) const run = await startRequest(harness, 'Design the fixture change without implementing it.') await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'PLAN_ONLY_RESULT' }], stopReason: 'completed', }) expect(fixture.requests).toHaveLength(2) + expect(JSON.stringify(fixture.requests[1]?.body.messages)) + .toContain('ExitPlanMode exists but is not enabled in this context') await run.dispose() await expectQuiescent(harness.handles) }) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index f0e1f4a1e1..b5be0987ca 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -679,6 +679,9 @@ describe('query options and result mapping', () => { spawn: () => child.handle, }, new AbortController(), () => {}, () => {}) expect(options.permissionMode).toBe(permissionMode) + expect(options.disallowedTools).toEqual(permissionMode === 'plan' + ? ['AskUserQuestion', 'ExitPlanMode'] + : ['AskUserQuestion']) if (permissionMode === 'bypassPermissions') { expect(options.allowDangerouslySkipPermissions).toBe(true) expect(options).not.toHaveProperty('canUseTool') @@ -689,9 +692,8 @@ describe('query options and result mapping', () => { }, ) - it('returns a plan without approving ExitPlanMode execution', async () => { + it('disallows ExitPlanMode before native plan-mode allow rules', () => { const child = fakeChild() - const diagnostics: string[] = [] const options = claudeQueryOptions({ cwd: '/workspace', executable: '/native/claude', @@ -699,21 +701,10 @@ describe('query options and result mapping', () => { env: {}, disposeGraceMs: 17, spawn: () => child.handle, - }, new AbortController(), () => {}, value => diagnostics.push(value)) - await expect(options.canUseTool!( + }, new AbortController(), () => {}, () => {}) + expect(options.disallowedTools).toEqual([ + 'AskUserQuestion', 'ExitPlanMode', - {}, - { - signal: new AbortController().signal, - toolUseID: 'exit-plan', - requestId: 'exit-plan-request', - }, - )).resolves.toEqual({ - behavior: 'deny', - message: 'Plan approval is unavailable in this unattended run. Return the completed plan in your final response without executing it.', - }) - expect(diagnostics).toEqual([ - 'Claude Code unattended decision (mode: plan; request: plan approval; decision: denied): the provider returns the plan without approving execution', ]) }) From a016e17393d43ca57a0c41884805698a45a232f6 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 18:39:30 +0800 Subject: [PATCH 085/146] test(subagent): cover Codex permission branches --- .../tests/subagent-codex.spec.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index b09e2ce46c..be7e0eb8f6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -666,6 +666,17 @@ describe('CodexAppServerWire', () => { child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() const requests = [ + { + id: 'command-decline', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['decline'], + }, + result: { decision: 'decline' }, + diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval', + }, { id: 'file', method: 'item/fileChange/requestApproval', @@ -677,6 +688,17 @@ describe('CodexAppServerWire', () => { result: { decision: 'decline' }, diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval', }, + { + id: 'file-cancel', + method: 'item/fileChange/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + result: { decision: 'cancel' }, + diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval', + }, { id: 'file-default', method: 'item/fileChange/requestApproval', @@ -742,11 +764,29 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('records a declined command item without retaining its payload', async () => { + it('records declined command and file items without retaining their payloads', async () => { const { child, wire } = await initializeWire() const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'commandExecution', + status: 'declined', + command: 'cat /private/secret.txt', + }, + }, + }) + await nextTask() + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + ) + expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') + child.peer.send( { method: 'item/completed', @@ -754,9 +794,9 @@ describe('CodexAppServerWire', () => { threadId: 'thread-1', turnId: 'turn-1', item: { - type: 'commandExecution', + type: 'fileChange', status: 'declined', - command: 'cat /private/secret.txt', + patch: 'SECRET_TOKEN in /private/secret.txt', }, }, }, @@ -767,7 +807,7 @@ describe('CodexAppServerWire', () => { ) await expect(result).rejects.toThrow('status failed') expect(wire.collectDiagnostic()).toBe( - 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode', + 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode', ) expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN') expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt') From 34db64d90d77ea8c5646c5c93ea5a6451fcd851a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:02:04 +0800 Subject: [PATCH 086/146] refactor(subagent): simplify Codex permission runtime --- packages/subagent/subagent-codex/src/run.ts | 20 ++---- packages/subagent/subagent-codex/src/wire.ts | 2 +- .../tests/subagent-codex.spec.ts | 68 ++++++++++++------- 3 files changed, 51 insertions(+), 39 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 1c596b806a..086b6a588b 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -157,11 +157,10 @@ export async function startCodexRun( process.stderr.write(chunk) wire.observeStderr(chunk.toString()) } - const stderrFailure = Promise.withResolvers() - const onStderrError = (error: Error): void => { - stderrFailure.reject(error) + const onStderrError = (): void => { + // Stderr observation is auxiliary. JSON-RPC and child.done remain the + // only terminal authorities if the diagnostic stream itself fails. } - void stderrFailure.promise.catch(() => {}) child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) const disposeProcess = async (): Promise => { @@ -195,16 +194,8 @@ export async function startCodexRun( try { wire.start() - await Promise.race([ - wire.initialize(request.signal), - processFailure, - stderrFailure.promise, - ]) - await Promise.race([ - wire.startThread(spec.cwd, request.signal), - processFailure, - stderrFailure.promise, - ]) + await Promise.race([wire.initialize(request.signal), processFailure]) + await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) try { @@ -226,7 +217,6 @@ export async function startCodexRun( attempt: () => Promise.race([ wire.runTurn(texts, runAbort.signal), processFailure, - stderrFailure.promise, ]), collectOutput, collectDiagnostic: () => wire.collectDiagnostic(), diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index c4274c8b2c..9fe5036d53 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -151,7 +151,7 @@ export class CodexAppServerWire { constructor( private readonly input: Readable, output: Writable, - private readonly permissionMode: CodexPermissionMode = 'never', + private readonly permissionMode: CodexPermissionMode, ) { this.transport = new JsonRpcLineTransport(input, output) // Fatal protocol state can arrive after the current guarded operation has diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index be7e0eb8f6..a50b9b488a 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -182,6 +182,14 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { } } +function defaultWire(child: FakeChild): CodexAppServerWire { + return new CodexAppServerWire( + child.handle.stdout!, + child.handle.stdin!, + DEFAULT_CODEX_PERMISSION_MODE, + ) +} + function runSpec( child: FakeChild, overrides: Partial = {}, @@ -201,7 +209,7 @@ async function initializeWire(): Promise<{ readonly wire: CodexAppServerWire }> { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const initializing = wire.initialize(new AbortController().signal) const initialize = await child.peer.nextMethod('initialize') @@ -426,7 +434,7 @@ describe('task admission and package contracts', () => { describe('CodexAppServerWire', () => { it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) expect(wire.collectOutput()).toEqual([]) wire.start() @@ -540,7 +548,7 @@ describe('CodexAppServerWire', () => { it('rejects invalid handshake, thread, and turn response shapes', async () => { { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) const frame = await child.peer.nextMethod('initialize') @@ -550,7 +558,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.startThread('/workspace', new AbortController().signal) const frame = await child.peer.nextMethod('thread/start') @@ -1031,7 +1039,7 @@ describe('CodexAppServerWire', () => { it('rejects pending work on abort, EOF, and stream error', async () => { { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const controller = new AbortController() controller.abort('pre-aborted') @@ -1041,7 +1049,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const controller = new AbortController() const pending = wire.initialize(controller.signal) @@ -1052,7 +1060,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -1062,7 +1070,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -1072,7 +1080,7 @@ describe('CodexAppServerWire', () => { } { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) wire.start() const pending = wire.initialize(new AbortController().signal) await child.peer.nextMethod('initialize') @@ -1172,13 +1180,14 @@ describe('run lifecycle and quiescence', () => { } { const child = fakeChild() - const { run, turnStart } = await publishRun(child, undefined, { - onError: (error) => { errors.push(error.message) }, - }) + const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.stderr.emit('error', new Error('stderr broke')) - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) - expect(errors.at(-1)).toContain('stderr broke') + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) await run.dispose() expect(child.stderr.listenerCount('error')).toBe(0) } @@ -1264,10 +1273,23 @@ describe('run lifecycle and quiescence', () => { const stderrChild = fakeChild() const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) - await stderrChild.peer.nextMethod('initialize') + const stderrInitialize = await stderrChild.peer.nextMethod('initialize') stderrChild.stderr.emit('error', new Error('startup stderr broke')) - await expect(stderrStarting).rejects.toThrow('startup stderr broke') - expect(stderrChild.terminate).toHaveBeenCalledTimes(1) + stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' }) + await stderrChild.peer.nextMethod('initialized') + const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start') + stderrChild.peer.respond(stderrThreadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + const stderrRun = await stderrStarting + const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start') + stderrChild.peer.send( + { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } }, + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' }) + await stderrRun.dispose() expect(stderrChild.stderr.listenerCount('error')).toBe(0) }) @@ -1458,7 +1480,7 @@ describe('run lifecycle and quiescence', () => { describe('disposeCodexChild', () => { it('closes stdin, terminates, and waits for the managed tree', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) const end = vi.spyOn(child.toChild, 'end') await disposeCodexChild(wire, child.handle) expect(end).toHaveBeenCalled() @@ -1469,7 +1491,7 @@ describe('disposeCodexChild', () => { it('does not finish disposal before the managed tree exits', async () => { const child = fakeChild({ exitOnTerminate: false }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) let disposed = false const disposal = disposeCodexChild(wire, child.handle).then(() => { disposed = true @@ -1483,7 +1505,7 @@ describe('disposeCodexChild', () => { it('contains a concurrently closed stdin error', async () => { const child = fakeChild() - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) vi.spyOn(child.toChild, 'end').mockImplementation(() => { throw new Error('already closed') }) @@ -1496,7 +1518,7 @@ describe('disposeCodexChild', () => { pid: -1, doneError: new Error('spawn failed'), }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .resolves.toBeUndefined() expect(child.terminate).not.toHaveBeenCalled() @@ -1508,14 +1530,14 @@ describe('disposeCodexChild', () => { const child = fakeChild({ doneError: new Error('close observer failed'), }) - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) .rejects.toThrow('close observer failed') } { const child = fakeChild() const handle = { ...child.handle, stdin: undefined } - const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!) + const wire = defaultWire(child) await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) From cfcecbf0c7fa1f8a33de1f280cced62cda43b2bb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:11:24 +0800 Subject: [PATCH 087/146] fix(subagent): stabilize Codex permission diagnostics --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 2 +- ...t-subagent-one-shot-background-tasks.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 23 ++++-- packages/subagent/subagent-codex/src/wire.ts | 56 +++++++++----- .../tests/subagent-codex.spec.ts | 75 ++++++++++++++++++- 9 files changed, 139 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 597b078939..777fff4e2a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 49c3e3fc6a99cae23b606f5a680320307c79d08c -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dc3a0737b9cfe00a850697ca482fad2743105058 +2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 49c3e3fc6a..f65c0626ad 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile-selected mode and the shared failure diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index dc3a0737b9..97ac527b8e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责 Claude Code 的 Profile 模式选择与共享失败诊断。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index b6cad9b39f..cec2cc269a 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 9aeccfadbad0d8f44ac2c294c4008b672f855027 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 74a0a614847543aff5f88cc5696246a76f2bb72f +2026-08-12-product-subagent-one-shot-background-tasks.md: 248bb943f8ee46a7050c373b6b7c3f7dec65d566 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d6867a97561e7efbe2b152b6c45991553393b4e7 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 9aeccfadba..248bb943f8 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,7 +14,7 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile configuration and diagnostic production. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 74a0a61484..d6867a9756 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,7 +14,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责 Claude Code 的 Profile 配置与诊断生产。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 086b6a588b..94183802f1 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -154,21 +154,27 @@ export async function startCodexRun( spec.permissionMode, ) const onStderr = (chunk: Buffer | string): void => { - process.stderr.write(chunk) wire.observeStderr(chunk.toString()) } const onStderrError = (): void => { // Stderr observation is auxiliary. JSON-RPC and child.done remain the // only terminal authorities if the diagnostic stream itself fails. } + const onHostStderrError = (): void => { + // Host stderr is an observation sink, not a child-run failure authority. + } child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) + process.stderr.on('error', onHostStderrError) + child.stderr?.pipe(process.stderr, { end: false }) const disposeProcess = async (): Promise => { try { await disposeCodexChild(wire, child) } finally { + child.stderr?.unpipe(process.stderr) child.stderr?.off('data', onStderr) child.stderr?.off('error', onStderrError) + process.stderr.off('error', onHostStderrError) } } @@ -214,10 +220,17 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ - attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal), - processFailure, - ]), + attempt: async () => { + try { + return await Promise.race([ + wire.runTurn(texts, runAbort.signal), + processFailure, + ]) + } catch (error: unknown) { + await new Promise((resolve) => { setImmediate(resolve) }) + throw error + } + }, collectOutput, collectDiagnostic: () => wire.collectDiagnostic(), cancelled: () => runAbort.signal.aborted, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9fe5036d53..6e617891b1 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -47,6 +47,21 @@ const STDERR_SIGNATURE_TAIL_CHARS = Math.max( ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length), ) - 1 +function stderrSignatureTail(value: string): string { + for ( + let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length) + ; length > 0 + ; length -= 1 + ) { + const tail = value.slice(-length) + if (STDERR_PERMISSION_SIGNATURES.some(signature => + tail.length < signature.text.length && signature.text.startsWith(tail))) { + return tail + } + } + return '' +} + function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -318,7 +333,7 @@ export class CodexAppServerWire { if (latest !== undefined) { this.recordDiagnostic(latest.request, latest.decision, latest.reason) } - this.stderrTail = observed.slice(-STDERR_SIGNATURE_TAIL_CHARS) + this.stderrTail = stderrSignatureTail(observed) } /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ @@ -399,6 +414,26 @@ export class CodexAppServerWire { ) } + private recordDeclinedItem(item: JsonObject): boolean { + if (item.type === 'commandExecution' && item.status === 'declined') { + this.recordDiagnostic( + 'command execution', + 'declined', + 'Codex declined the command under the selected permission mode', + ) + return true + } + if (item.type === 'fileChange' && item.status === 'declined') { + this.recordDiagnostic( + 'file change', + 'declined', + 'Codex declined the file change under the selected permission mode', + ) + return true + } + return false + } + private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { @@ -475,28 +510,15 @@ export class CodexAppServerWire { if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) + const item = object(params.item, 'item/completed item') + if (this.recordDeclinedItem(item)) return this.earlyTurnNotifications.push({ method, params }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') - if (item.type === 'commandExecution' && item.status === 'declined') { - this.recordDiagnostic( - 'command execution', - 'declined', - 'Codex declined the command under the selected permission mode', - ) - return - } - if (item.type === 'fileChange' && item.status === 'declined') { - this.recordDiagnostic( - 'file change', - 'declined', - 'Codex declined the file change under the selected permission mode', - ) - return - } + if (this.recordDeclinedItem(item)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index a50b9b488a..0fd2642d52 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -868,7 +868,7 @@ describe('CodexAppServerWire', () => { it('does not reapply an old stderr signature after a newer request diagnostic', async () => { const { child, wire } = await initializeWire() - wire.observeStderr('approval policy is Never; reject command') + wire.observeStderr('recorded sandbox violation:') const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -891,6 +891,36 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('keeps a newer request diagnostic after replaying an older early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + await nextTask() + child.peer.send({ + id: 'newer-command-request', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('newer-command-request') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(wire.collectDiagnostic()).toContain('request: command approval') + wire.close() + }) + it('fails the run on unknown requests or wrong request association', async () => { for (const serverRequest of [ { @@ -1222,11 +1252,37 @@ describe('run lifecycle and quiescence', () => { await run.dispose() }) + it('drains queued stderr before settling a failed published run', async () => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + setImmediate(() => { + child.stderr.write('approval policy is Never; reject command') + }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + write.mockRestore() + }) + it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() const forwarded: string[] = [] + let writes = 0 const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { forwarded.push(String(chunk)) + writes += 1 + if (writes === 1) { + setImmediate(() => { process.stderr.emit('drain') }) + return false + } return true }) const { run, turnStart } = await publishRun(child) @@ -1243,11 +1299,28 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(forwarded.join('')).toContain('SECRET_TOKEN') + expect(writes).toBe(2) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) write.mockRestore() }) + it('contains host stderr errors without changing run settlement', async () => { + const child = fakeChild() + const initialErrorListeners = process.stderr.listenerCount('error') + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + expect(process.stderr.listenerCount('error')).toBeGreaterThan(initialErrorListeners) + process.stderr.emit('error', new Error('host stderr broke')) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose() + expect(process.stderr.listenerCount('error')).toBe(initialErrorListeners) + }) + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { const controller = new AbortController() controller.abort() From d1e9dcae7a0e86c9322638ee055661881b77e5d3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:33:03 +0800 Subject: [PATCH 088/146] refactor(subagent): redesign Codex stderr diagnostics --- packages/subagent/subagent-codex/src/run.ts | 16 +-- packages/subagent/subagent-codex/src/wire.ts | 43 +++++-- .../tests/subagent-codex.spec.ts | 116 +++++++++++++----- 3 files changed, 127 insertions(+), 48 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 94183802f1..c5eb7c1e61 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -8,6 +8,7 @@ */ import { randomUUID } from 'node:crypto' +import { writeSync } from 'node:fs' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -154,27 +155,26 @@ export async function startCodexRun( spec.permissionMode, ) const onStderr = (chunk: Buffer | string): void => { - wire.observeStderr(chunk.toString()) + const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk + wire.observeStderr(bytes.toString()) + try { + writeSync(process.stderr.fd, bytes) + } catch { + // Host stderr is an observation sink, not a child-run failure authority. + } } const onStderrError = (): void => { // Stderr observation is auxiliary. JSON-RPC and child.done remain the // only terminal authorities if the diagnostic stream itself fails. } - const onHostStderrError = (): void => { - // Host stderr is an observation sink, not a child-run failure authority. - } child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) - process.stderr.on('error', onHostStderrError) - child.stderr?.pipe(process.stderr, { end: false }) const disposeProcess = async (): Promise => { try { await disposeCodexChild(wire, child) } finally { - child.stderr?.unpipe(process.stderr) child.stderr?.off('data', onStderr) child.stderr?.off('error', onStderrError) - process.stderr.off('error', onHostStderrError) } } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 6e617891b1..28777b7d7a 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -156,10 +156,13 @@ export class CodexAppServerWire { private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject + readonly order: number }> = [] private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined private diagnostic: string | undefined + private diagnosticOrder = 0 + private observationOrder = 0 private stderrTail = '' private closed = false @@ -382,7 +385,11 @@ export class CodexAppServerWire { this.turnId = id const notifications = this.earlyTurnNotifications.splice(0) for (const notification of notifications) { - this.handleNotification(notification.method, notification.params) + this.handleNotification( + notification.method, + notification.params, + notification.order, + ) } } @@ -405,7 +412,10 @@ export class CodexAppServerWire { request: Parameters[1], decision: Parameters[2], reason: string, + order = this.nextObservationOrder(), ): void { + if (order < this.diagnosticOrder) return + this.diagnosticOrder = order this.diagnostic = unattendedDiagnostic( this.permissionMode, request, @@ -414,12 +424,18 @@ export class CodexAppServerWire { ) } - private recordDeclinedItem(item: JsonObject): boolean { + private nextObservationOrder(): number { + this.observationOrder += 1 + return this.observationOrder + } + + private recordDeclinedItem(item: JsonObject, order?: number): boolean { if (item.type === 'commandExecution' && item.status === 'declined') { this.recordDiagnostic( 'command execution', 'declined', 'Codex declined the command under the selected permission mode', + order, ) return true } @@ -428,6 +444,7 @@ export class CodexAppServerWire { 'file change', 'declined', 'Codex declined the file change under the selected permission mode', + order, ) return true } @@ -493,7 +510,11 @@ export class CodexAppServerWire { } } - private handleNotification(method: string, params: JsonObject): void { + private handleNotification( + method: string, + params: JsonObject, + order?: number, + ): void { if (method === 'turn/started') { const threadId = string(params.threadId, 'turn/started thread id') if (threadId !== this.threadId) return @@ -510,15 +531,17 @@ export class CodexAppServerWire { if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) - const item = object(params.item, 'item/completed item') - if (this.recordDeclinedItem(item)) return - this.earlyTurnNotifications.push({ method, params }) + this.earlyTurnNotifications.push({ + method, + params, + order: this.nextObservationOrder(), + }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') - if (this.recordDeclinedItem(item)) return + if (this.recordDeclinedItem(item, order)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text @@ -541,7 +564,11 @@ export class CodexAppServerWire { if (turnCompleted === undefined) return if (this.turnId === undefined) { this.observePendingTurnId(id) - this.earlyTurnNotifications.push({ method, params }) + this.earlyTurnNotifications.push({ + method, + params, + order: this.nextObservationOrder(), + }) return } if (id !== this.turnId) return diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 0fd2642d52..36c7185952 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -26,6 +26,37 @@ import { } from '../src/run.ts' import { CodexAppServerWire } from '../src/wire.ts' +const { hostStderrWrite } = vi.hoisted(() => ({ + hostStderrWrite: { + capture: false, + failNext: false, + chunks: [] as Buffer[], + }, +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeSync(fd: number, value: string | Uint8Array): number { + if (fd === 2 && hostStderrWrite.capture) { + if (hostStderrWrite.failNext) { + hostStderrWrite.failNext = false + throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) + } + const bytes = typeof value === 'string' + ? Buffer.from(value) + : Buffer.from(value.buffer, value.byteOffset, value.byteLength) + hostStderrWrite.chunks.push(bytes) + return bytes.byteLength + } + return typeof value === 'string' + ? actual.writeSync(fd, value, null, 'utf8') + : actual.writeSync(fd, value, 0, value.byteLength, null) + }, + } +}) + type JsonObject = Record const fakeParent = { @@ -983,6 +1014,24 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('does not retain a diagnostic from a mismatched early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-early', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + expect(wire.collectDiagnostic()).toBeUndefined() + wire.close() + }) + it('rejects conflicting early notifications and requests before turn/start', async () => { { const { child, wire } = await initializeWire() @@ -1253,7 +1302,8 @@ describe('run lifecycle and quiescence', () => { }) it('drains queued stderr before settling a failed published run', async () => { - const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 const { child, run, turnStart } = await publishRun() child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { @@ -1269,26 +1319,18 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) await run.dispose() - write.mockRestore() + hostStderrWrite.capture = false }) it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() - const forwarded: string[] = [] - let writes = 0 - const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { - forwarded.push(String(chunk)) - writes += 1 - if (writes === 1) { - setImmediate(() => { process.stderr.emit('drain') }) - return false - } - return true - }) + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.stderr.write('SECRET_TOKEN approval policy is Ne') child.stderr.write('ver; reject command — /private/secret.txt') + child.stderr.emit('data', 'string stderr suffix') child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { message: 'fixture terminal failure', codexErrorInfo: 'badRequest', @@ -1298,27 +1340,27 @@ describe('run lifecycle and quiescence', () => { diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', stopReason: 'error', }) - expect(forwarded.join('')).toContain('SECRET_TOKEN') - expect(writes).toBe(2) + expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') + expect(hostStderrWrite.chunks).toHaveLength(3) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) - write.mockRestore() + hostStderrWrite.capture = false }) - it('contains host stderr errors without changing run settlement', async () => { + it('contains host stderr write failures without changing run settlement', async () => { const child = fakeChild() - const initialErrorListeners = process.stderr.listenerCount('error') + hostStderrWrite.capture = true + hostStderrWrite.failNext = true const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - expect(process.stderr.listenerCount('error')).toBeGreaterThan(initialErrorListeners) - process.stderr.emit('error', new Error('host stderr broke')) + child.stderr.write('forwarding failure') child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed', }) await run.dispose() - expect(process.stderr.listenerCount('error')).toBe(initialErrorListeners) + hostStderrWrite.capture = false }) it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { @@ -1406,12 +1448,21 @@ describe('run lifecycle and quiescence', () => { }) it('keeps overlapping runs isolated', async () => { - const first = fakeChild() - const second = fakeChild() - const runs = await Promise.all([ - publishRun(first), - publishRun(second), - ]) + const initialStderrListeners = { + error: process.stderr.listenerCount('error'), + unpipe: process.stderr.listenerCount('unpipe'), + close: process.stderr.listenerCount('close'), + finish: process.stderr.listenerCount('finish'), + } + const runs = await Promise.all( + Array.from({ length: 6 }, () => publishRun(fakeChild())), + ) + expect({ + error: process.stderr.listenerCount('error'), + unpipe: process.stderr.listenerCount('unpipe'), + close: process.stderr.listenerCount('close'), + finish: process.stderr.listenerCount('finish'), + }).toEqual(initialStderrListeners) for (const [index, entry] of runs.entries()) { const id = `turn-${index + 1}` entry.child.peer.send( @@ -1421,11 +1472,12 @@ describe('run lifecycle and quiescence', () => { ) } const results = await Promise.all(runs.map(entry => entry.run.result)) - expect(results.map(result => result.output)).toEqual([ - [{ type: 'text', text: 'answer-1' }], - [{ type: 'text', text: 'answer-2' }], - ]) - expect(runs[0].run.id).not.toBe(runs[1].run.id) + expect(results.map(result => result.output)).toEqual( + Array.from({ length: 6 }, (_, index) => [ + { type: 'text', text: `answer-${index + 1}` }, + ]), + ) + expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id) await Promise.all(runs.map(entry => entry.run.dispose())) }) From 6ed3cab9b58fceaf0d15608c0f8a5ddd0e7b3419 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:58:08 +0800 Subject: [PATCH 089/146] fix(subagent): preserve Codex diagnostic ordering --- packages/subagent/subagent-codex/src/run.ts | 12 +- packages/subagent/subagent-codex/src/wire.ts | 119 +++++++++++++----- .../tests/subagent-codex.spec.ts | 115 +++++++++++++++-- 3 files changed, 200 insertions(+), 46 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index c5eb7c1e61..78f9a6fd9c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -158,7 +158,17 @@ export async function startCodexRun( const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk wire.observeStderr(bytes.toString()) try { - writeSync(process.stderr.fd, bytes) + let offset = 0 + while (offset < bytes.byteLength) { + const written = writeSync( + process.stderr.fd, + bytes, + offset, + bytes.byteLength - offset, + ) + if (written <= 0) throw new Error('subagent-codex: host stderr made no write progress') + offset += written + } } catch { // Host stderr is an observation sink, not a child-run failure authority. } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 28777b7d7a..a777b05331 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -152,7 +152,10 @@ export class CodexAppServerWire { private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined - private turnCompleted: PromiseWithResolvers | undefined + private turnCompleted: PromiseWithResolvers<{ + readonly params: JsonObject + readonly order: number + }> | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject @@ -163,6 +166,13 @@ export class CodexAppServerWire { private diagnostic: string | undefined private diagnosticOrder = 0 private observationOrder = 0 + private pendingDiagnostic: { + readonly turnId: string + readonly order: number + readonly request: Parameters[1] + readonly decision: Parameters[2] + readonly reason: string + } | undefined private stderrTail = '' private closed = false @@ -247,7 +257,10 @@ export class CodexAppServerWire { texts: readonly string[], signal: AbortSignal, ): Promise { - const completion = Promise.withResolvers() + const completion = Promise.withResolvers<{ + readonly params: JsonObject + readonly order: number + }>() this.turnCompleted = completion const threadId = this.threadId as string const response = object(await this.guarded(this.transport.request('turn/start', { @@ -258,7 +271,7 @@ export class CodexAppServerWire { this.commitTurnId(string(turn.id, 'turn/start turn id')) const completed = await this.guarded(completion.promise, signal) - const terminal = object(completed.turn, 'turn/completed turn') + const terminal = object(completed.params.turn, 'turn/completed turn') const status = terminal.status if (isContextWindowExceeded(terminal)) { return { output: this.collectOutput(), stopReason: 'max-tokens' } @@ -270,6 +283,7 @@ export class CodexAppServerWire { 'sandbox execution', 'failed', 'Codex reported a sandbox failure', + completed.order, ) } const detail = status === 'failed' @@ -383,6 +397,16 @@ export class CodexAppServerWire { throw new Error('subagent-codex: turn/start response did not match the active turn') } this.turnId = id + const pendingDiagnostic = this.pendingDiagnostic + this.pendingDiagnostic = undefined + if (pendingDiagnostic?.turnId === id) { + this.recordDiagnostic( + pendingDiagnostic.request, + pendingDiagnostic.decision, + pendingDiagnostic.reason, + pendingDiagnostic.order, + ) + } const notifications = this.earlyTurnNotifications.splice(0) for (const notification of notifications) { this.handleNotification( @@ -393,19 +417,43 @@ export class CodexAppServerWire { } } - private validateRunIds(params: JsonObject, nullableTurn = false): void { + private validateRunIds( + params: JsonObject, + nullableTurn = false, + ): string | undefined { if (params.threadId !== this.threadId) { throw new Error('subagent-codex: app-server request referenced another thread') } - if (nullableTurn && params.turnId === null) return + if (nullableTurn && params.turnId === null) return undefined const id = string(params.turnId, 'server request turn id') if (this.turnId === undefined) { this.observePendingTurnId(id) - return + return id } if (id !== this.turnId) { throw new Error('subagent-codex: app-server request referenced another turn') } + return undefined + } + + private recordRequestDiagnostic( + provisionalTurnId: string | undefined, + request: Parameters[1], + decision: Parameters[2], + reason: string, + ): void { + const order = this.nextObservationOrder() + if (provisionalTurnId !== undefined) { + this.pendingDiagnostic = { + turnId: provisionalTurnId, + order, + request, + decision, + reason, + } + return + } + this.recordDiagnostic(request, decision, reason, order) } private recordDiagnostic( @@ -455,46 +503,48 @@ export class CodexAppServerWire { try { switch (method) { case 'item/commandExecution/requestApproval': - this.validateRunIds(params) - { - const decision = unattendedDecision(params) - this.recordDiagnostic( - 'command approval', - decision === 'cancel' ? 'cancelled' : 'declined', - 'the provider does not grant interactive approval', - ) - return Promise.resolve({ decision }) - } + { + const provisionalTurnId = this.validateRunIds(params) + const decision = unattendedDecision(params) + this.recordRequestDiagnostic( + provisionalTurnId, + 'command approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/fileChange/requestApproval': - this.validateRunIds(params) - { - const decision = unattendedDecision(params) - this.recordDiagnostic( - 'file approval', - decision === 'cancel' ? 'cancelled' : 'declined', - 'the provider does not grant interactive approval', - ) - return Promise.resolve({ decision }) - } + { + const provisionalTurnId = this.validateRunIds(params) + const decision = unattendedDecision(params) + this.recordRequestDiagnostic( + provisionalTurnId, + 'file approval', + decision === 'cancel' ? 'cancelled' : 'declined', + 'the provider does not grant interactive approval', + ) + return Promise.resolve({ decision }) + } case 'item/permissions/requestApproval': - this.validateRunIds(params) - this.recordDiagnostic( + this.recordRequestDiagnostic( + this.validateRunIds(params), 'permission grant', 'denied', 'the provider grants no additional turn permissions', ) return Promise.resolve({ permissions: {}, scope: 'turn' }) case 'item/tool/requestUserInput': - this.validateRunIds(params) - this.recordDiagnostic( + this.recordRequestDiagnostic( + this.validateRunIds(params), 'user input', 'empty response', 'the provider does not collect interactive answers', ) return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': - this.validateRunIds(params, true) - this.recordDiagnostic( + this.recordRequestDiagnostic( + this.validateRunIds(params, true), 'MCP elicitation', 'declined', 'the provider does not collect interactive MCP input', @@ -575,6 +625,9 @@ export class CodexAppServerWire { if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) } - turnCompleted.resolve(params) + turnCompleted.resolve({ + params, + order: order ?? this.nextObservationOrder(), + }) } } diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 36c7185952..1c63e11592 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -30,6 +30,8 @@ const { hostStderrWrite } = vi.hoisted(() => ({ hostStderrWrite: { capture: false, failNext: false, + zeroNext: false, + maxBytesPerWrite: undefined as number | undefined, chunks: [] as Buffer[], }, })) @@ -38,8 +40,17 @@ vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - writeSync(fd: number, value: string | Uint8Array): number { + writeSync( + fd: number, + value: string | Uint8Array, + offset?: number | null, + length?: number | null, + ): number { if (fd === 2 && hostStderrWrite.capture) { + if (hostStderrWrite.zeroNext) { + hostStderrWrite.zeroNext = false + return 0 + } if (hostStderrWrite.failNext) { hostStderrWrite.failNext = false throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) @@ -47,12 +58,26 @@ vi.mock('node:fs', async (importOriginal) => { const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value.buffer, value.byteOffset, value.byteLength) - hostStderrWrite.chunks.push(bytes) - return bytes.byteLength + const start = typeof value === 'string' ? 0 : offset ?? 0 + const requested = typeof value === 'string' + ? bytes.byteLength + : length ?? bytes.byteLength - start + const written = Math.min( + requested, + hostStderrWrite.maxBytesPerWrite ?? requested, + ) + hostStderrWrite.chunks.push(Buffer.from(bytes.subarray(start, start + written))) + return written } return typeof value === 'string' ? actual.writeSync(fd, value, null, 'utf8') - : actual.writeSync(fd, value, 0, value.byteLength, null) + : actual.writeSync( + fd, + value, + offset ?? 0, + length ?? value.byteLength - (offset ?? 0), + null, + ) }, } }) @@ -698,12 +723,13 @@ describe('CodexAppServerWire', () => { expect(await child.peer.nextResponse('command')).toMatchObject({ result: { decision: 'cancel' }, }) - expect(wire.collectDiagnostic()).toBe( - 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', - ) + expect(wire.collectDiagnostic()).toBeUndefined() child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) await nextTask() + expect(wire.collectDiagnostic()).toBe( + 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + ) const requests = [ { id: 'command-decline', @@ -952,6 +978,25 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('keeps a newer stderr fact after replaying an older early terminal', async () => { + hostStderrWrite.capture = true + hostStderrWrite.chunks.length = 0 + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'sandbox failure', + codexErrorInfo: 'sandboxError', + })) + await nextTask() + wire.observeStderr('approval policy is Never; reject command') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await expect(result).rejects.toThrow('sandboxError') + expect(wire.collectDiagnostic()).toContain('request: command execution') + wire.close() + hostStderrWrite.capture = false + }) + it('fails the run on unknown requests or wrong request association', async () => { for (const serverRequest of [ { @@ -1032,6 +1077,26 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('does not retain a diagnostic from a mismatched provisional request', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + id: 'provisional-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-early', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('provisional-approval') + child.peer.respond(turnStart, { turn: { id: 'turn-response' } }) + await expect(result).rejects.toThrow('did not match the active turn') + expect(wire.collectDiagnostic()).toBeUndefined() + wire.close() + }) + it('rejects conflicting early notifications and requests before turn/start', async () => { { const { child, wire } = await initializeWire() @@ -1325,6 +1390,7 @@ describe('run lifecycle and quiescence', () => { it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() hostStderrWrite.capture = true + hostStderrWrite.maxBytesPerWrite = 3 hostStderrWrite.chunks.length = 0 const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -1341,9 +1407,10 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') - expect(hostStderrWrite.chunks).toHaveLength(3) + expect(hostStderrWrite.chunks.length).toBeGreaterThan(3) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) + hostStderrWrite.maxBytesPerWrite = undefined hostStderrWrite.capture = false }) @@ -1353,11 +1420,35 @@ describe('run lifecycle and quiescence', () => { hostStderrWrite.failNext = true const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.stderr.write('forwarding failure') - child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + child.stderr.write('approval policy is Never; reject command') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: 'answer' }], - stopReason: 'completed', + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + hostStderrWrite.capture = false + }) + + it('contains a zero-progress host stderr write without losing the diagnostic', async () => { + const child = fakeChild() + hostStderrWrite.capture = true + hostStderrWrite.zeroNext = true + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.stderr.write('approval policy is Never; reject command') + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', }) await run.dispose() hostStderrWrite.capture = false From 0ff3c236ecb6384302c20f729452af4d0cc9da5d Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 20:09:04 +0800 Subject: [PATCH 090/146] refactor(subagent): simplify Codex diagnostic handoff --- packages/subagent/subagent-codex/src/run.ts | 14 +---- packages/subagent/subagent-codex/src/wire.ts | 24 ++++---- .../tests/subagent-codex.spec.ts | 58 ++----------------- 3 files changed, 19 insertions(+), 77 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 78f9a6fd9c..fdf467c876 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -8,7 +8,7 @@ */ import { randomUUID } from 'node:crypto' -import { writeSync } from 'node:fs' +import { writeFileSync } from 'node:fs' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -158,17 +158,7 @@ export async function startCodexRun( const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk wire.observeStderr(bytes.toString()) try { - let offset = 0 - while (offset < bytes.byteLength) { - const written = writeSync( - process.stderr.fd, - bytes, - offset, - bytes.byteLength - offset, - ) - if (written <= 0) throw new Error('subagent-codex: host stderr made no write progress') - offset += written - } + writeFileSync(process.stderr.fd, bytes) } catch { // Host stderr is an observation sink, not a child-run failure authority. } diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index a777b05331..cfb24a7481 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -167,7 +167,6 @@ export class CodexAppServerWire { private diagnosticOrder = 0 private observationOrder = 0 private pendingDiagnostic: { - readonly turnId: string readonly order: number readonly request: Parameters[1] readonly decision: Parameters[2] @@ -399,7 +398,7 @@ export class CodexAppServerWire { this.turnId = id const pendingDiagnostic = this.pendingDiagnostic this.pendingDiagnostic = undefined - if (pendingDiagnostic?.turnId === id) { + if (pendingDiagnostic !== undefined) { this.recordDiagnostic( pendingDiagnostic.request, pendingDiagnostic.decision, @@ -420,32 +419,31 @@ export class CodexAppServerWire { private validateRunIds( params: JsonObject, nullableTurn = false, - ): string | undefined { + ): boolean { if (params.threadId !== this.threadId) { throw new Error('subagent-codex: app-server request referenced another thread') } - if (nullableTurn && params.turnId === null) return undefined + if (nullableTurn && params.turnId === null) return false const id = string(params.turnId, 'server request turn id') if (this.turnId === undefined) { this.observePendingTurnId(id) - return id + return true } if (id !== this.turnId) { throw new Error('subagent-codex: app-server request referenced another turn') } - return undefined + return false } private recordRequestDiagnostic( - provisionalTurnId: string | undefined, + provisional: boolean, request: Parameters[1], decision: Parameters[2], reason: string, ): void { const order = this.nextObservationOrder() - if (provisionalTurnId !== undefined) { + if (provisional) { this.pendingDiagnostic = { - turnId: provisionalTurnId, order, request, decision, @@ -504,10 +502,10 @@ export class CodexAppServerWire { switch (method) { case 'item/commandExecution/requestApproval': { - const provisionalTurnId = this.validateRunIds(params) + const provisional = this.validateRunIds(params) const decision = unattendedDecision(params) this.recordRequestDiagnostic( - provisionalTurnId, + provisional, 'command approval', decision === 'cancel' ? 'cancelled' : 'declined', 'the provider does not grant interactive approval', @@ -516,10 +514,10 @@ export class CodexAppServerWire { } case 'item/fileChange/requestApproval': { - const provisionalTurnId = this.validateRunIds(params) + const provisional = this.validateRunIds(params) const decision = unattendedDecision(params) this.recordRequestDiagnostic( - provisionalTurnId, + provisional, 'file approval', decision === 'cancel' ? 'cancelled' : 'declined', 'the provider does not grant interactive approval', diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 1c63e11592..497303237a 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -30,8 +30,6 @@ const { hostStderrWrite } = vi.hoisted(() => ({ hostStderrWrite: { capture: false, failNext: false, - zeroNext: false, - maxBytesPerWrite: undefined as number | undefined, chunks: [] as Buffer[], }, })) @@ -40,17 +38,11 @@ vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - writeSync( + writeFileSync( fd: number, value: string | Uint8Array, - offset?: number | null, - length?: number | null, - ): number { + ): void { if (fd === 2 && hostStderrWrite.capture) { - if (hostStderrWrite.zeroNext) { - hostStderrWrite.zeroNext = false - return 0 - } if (hostStderrWrite.failNext) { hostStderrWrite.failNext = false throw Object.assign(new Error('host stderr broke'), { code: 'EIO' }) @@ -58,26 +50,10 @@ vi.mock('node:fs', async (importOriginal) => { const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value.buffer, value.byteOffset, value.byteLength) - const start = typeof value === 'string' ? 0 : offset ?? 0 - const requested = typeof value === 'string' - ? bytes.byteLength - : length ?? bytes.byteLength - start - const written = Math.min( - requested, - hostStderrWrite.maxBytesPerWrite ?? requested, - ) - hostStderrWrite.chunks.push(Buffer.from(bytes.subarray(start, start + written))) - return written + hostStderrWrite.chunks.push(bytes) + return } - return typeof value === 'string' - ? actual.writeSync(fd, value, null, 'utf8') - : actual.writeSync( - fd, - value, - offset ?? 0, - length ?? value.byteLength - (offset ?? 0), - null, - ) + actual.writeFileSync(fd, value) }, } }) @@ -1390,7 +1366,6 @@ describe('run lifecycle and quiescence', () => { it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() hostStderrWrite.capture = true - hostStderrWrite.maxBytesPerWrite = 3 hostStderrWrite.chunks.length = 0 const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -1407,10 +1382,9 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') - expect(hostStderrWrite.chunks.length).toBeGreaterThan(3) + expect(hostStderrWrite.chunks).toHaveLength(3) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) - hostStderrWrite.maxBytesPerWrite = undefined hostStderrWrite.capture = false }) @@ -1434,26 +1408,6 @@ describe('run lifecycle and quiescence', () => { hostStderrWrite.capture = false }) - it('contains a zero-progress host stderr write without losing the diagnostic', async () => { - const child = fakeChild() - hostStderrWrite.capture = true - hostStderrWrite.zeroNext = true - const { run, turnStart } = await publishRun(child) - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.stderr.write('approval policy is Never; reject command') - child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { - message: 'fixture terminal failure', - codexErrorInfo: 'badRequest', - })) - await expect(run.result).resolves.toEqual({ - output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', - stopReason: 'error', - }) - await run.dispose() - hostStderrWrite.capture = false - }) - it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { const controller = new AbortController() controller.abort() From 4b25b0e76d45695aa299e460f58b25f59e9c86c5 Mon Sep 17 00:00:00 2001 From: GeeeekExplorer <2651904866@qq.com> Date: Wed, 12 Aug 2026 12:17:35 +0800 Subject: [PATCH 091/146] feat(web): make the ask-user question card collapsible The ask-user takeover rendered the pending question set as a bottom card capped at min(60vh, 520px) with no way to reduce it, which buried the conversation above while the user decided. Add a minimize toggle next to the dismiss action: collapsed, the card becomes a header strip (title plus the two icon buttons) and the option body and footer unmount; expanding restores the full card. Drafts and the current question index live in QuestionFlow local state, so collapse/expand never loses them. The free-form textarea autofocuses only on first presentation, so re-expanding does not steal focus from the toggle. Agent Note: .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.{md,zh.md,i18n.yaml} --- ...llapsible-ask-user-question-card.i18n.yaml | 6 + ...8-11-collapsible-ask-user-question-card.md | 32 ++ ...1-collapsible-ask-user-question-card.zh.md | 32 ++ .../src/client/QuestionComposer.module.css | 22 ++ .../src/client/QuestionComposer.tsx | 292 ++++++++++-------- .../ui-user-questions/src/client/locales.ts | 4 + 6 files changed, 256 insertions(+), 132 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml new file mode 100644 index 0000000000..e403530fec --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.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 .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md +2026-08-11-collapsible-ask-user-question-card.md: 5c7e62749e63a6042285b79751c400ba09038b49 +2026-08-11-collapsible-ask-user-question-card.zh.md: 5f4b5851e4b595e634841bf87827db70fe928afb diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md new file mode 100644 index 0000000000..5c7e62749e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md @@ -0,0 +1,32 @@ +# Agent Note: Collapsible Ask-User Question Card + +Status: implemented + +English | [中文](2026-08-11-collapsible-ask-user-question-card.zh.md) + +## Problem + +`dsh`'s ask-user takeover renders the pending question set as a bottom card capped at `min(60vh, 520px)`, so a long batch or a user who wants to re-read the conversation above before deciding has no way to reduce the card — the conversation above becomes hard to read because only a few lines peek out at the top. + +## Decision + +Add a minimize/maximize toggle to the question card header, next to the existing dismiss action. Collapsing hides the option body and the footer actions, leaving a header strip (eyebrow, title, both icon buttons) so the user still sees that a question is pending; expanding restores the full card. + +- State lives in `QuestionFlow` local state (`minimized`), so drafts and the current question index survive collapse/expand — nothing is re-derived or reset, and the answers already picked remain submit-ready. +- The toggle is a plain `IconChevronDownOutline14` / `IconChevronUpOutline14` pair on the existing 24px icon-button grid; `aria-expanded` reflects the card state and the label flips between `nav.minimize` / `nav.maximize` (the collapsed button reads "expand" for screen readers). +- While minimized the option body and footer are unmounted (`{!minimized && ...}`), so no hidden interactive surface remains in the a11y tree. +- The collapse button is disabled while a submit/cancel is in flight (`busy !== null`), matching the dismiss button's existing guard. +- CSS: `.cardMinimized` drops the `max-height` cap and hides `.body` / `.footer`; `.header` gains bottom padding so the strip is not cramped. +- Scope: only the generic question flow (`QuestionFlow`) gets the toggle. The plan-review card (`PlanReviewPanel`) is a different shape (one decision over one plan) and keeps its current layout. + +## Consequences + +- Users can shrink the question card to read the conversation, then expand to answer — drafts and position are preserved because the state lives in the flow component, not in the DOM. +- The minimize action is visually adjacent to dismiss; both share the icon button style, so the header stays balanced. +- Product copy additions are confined to the `question` locale namespace (`nav.minimize` / `nav.maximize`), paired zh/en per the dictionary contract. + +## Alternatives considered + +- **Auto-collapse on scroll**: collapsing the card when the user scrolls the conversation would reclaim space without a button, but it fights the user mid-interaction and hides the pending-question signal unexpectedly; an explicit toggle keeps the decision with the user. +- **Resizable card**: a drag handle would let users size the card freely, but it is more machinery than the ask needs and does not address "I want the card out of the way entirely". +- **Persisting the collapsed state per session**: nice-to-have, but the ask is per-interaction; persisting adds storage and sync complexity without a clear win for this surface. diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md new file mode 100644 index 0000000000..5f4b5851e4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 可收起的提问卡片 + +Status: implemented + +[English](2026-08-11-collapsible-ask-user-question-card.md) | 中文 + +## Problem + +`dsh` 的 ask-user 接管界面把待回答的问题组渲染为底部卡片,高度上限为 `min(60vh, 520px)`;当问题批次较长、或用户想先阅读上方的会话记录再决定时,卡片会占满大部分视口且无法缩小——上方会话几乎被遮住,只能看到顶部几行。 + +## Decision + +在提问卡片头部(现有的"放弃整组问题"按钮旁)增加收起/展开切换按钮。收起时隐藏选项主体和底部操作区,只保留一条头部(eyebrow、标题、两个图标按钮),用户仍能看到"有未答问题"的信号;展开后恢复完整卡片。 + +- 状态存放在 `QuestionFlow` 的本地 state(`minimized`),因此收起/展开不会丢失草稿和当前题目索引——已选答案仍可直接提交。 +- 切换按钮使用 `IconChevronDownOutline14` / `IconChevronUpOutline14`,复用现有 24px 图标按钮网格;`aria-expanded` 反映卡片状态,文案在 `nav.minimize` / `nav.maximize` 之间切换(收起后按钮对读屏器显示为"展开")。 +- 收起时选项主体和底部通过 `{!minimized && ...}` 卸载,a11y 树中不残留隐藏的可交互面。 +- 提交/取消进行中(`busy !== null`)时收起按钮禁用,与现有放弃按钮的守卫一致。 +- CSS:`.cardMinimized` 去掉 `max-height` 上限并隐藏 `.body` / `.footer`;`.header` 增加底部 padding,避免折叠后过于局促。 +- 范围:只有通用提问流(`QuestionFlow`)获得该切换。计划评审卡片(`PlanReviewPanel`)是另一种形态(对一个计划做一次决策),保持现有布局。 + +## Consequences + +- 用户可以缩小提问卡片以阅读会话,再展开作答——草稿和位置因状态存放在流程组件中而得以保留。 +- 收起动作紧邻放弃按钮,二者共用图标按钮样式,头部保持平衡。 +- 新增产品文案仅落在 `question` locale 命名空间(`nav.minimize` / `nav.maximize`),按字典契约中英成对。 + +## Alternatives considered + +- **滚动时自动收起**:用户滚动会话时自动折叠卡片可以省空间,但会在交互中途与用户对抗,并意外隐藏"待答问题"信号;显式切换把决定权交给用户。 +- **可拖拽调整大小**:拖拽手柄让用户自由调整卡片大小,但比需求所需的机制更复杂,也没有解决"让卡片完全让开"的诉求。 +- **按会话持久化折叠状态**:锦上添花,但提问是单次交互;持久化引入存储与同步复杂度,对这个界面没有明确收益。 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index c0b83182d2..8d01f8e3df 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -39,6 +39,28 @@ box-sizing: border-box; } +/* Collapsed to the header strip: drop the height cap and the inner scroll + seat so the card hugs the title row, freeing the viewport for the + conversation above while the question stays pending. */ +.cardMinimized { + max-height: none; +} + +/* The header strip is the whole card when collapsed: the title row needs + bottom padding once the body that normally carries it is hidden. */ +.cardMinimized .header { + padding-bottom: 14px; +} + +/* Header button group: minimize sits next to the close action, both on the + same 24px icon-button grid. */ +.headerActions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + /* Figma 1019:36938 header, user-tuned: heading block left, close right; the pager sits in the footer to balance the card. */ .header { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 596e1ec727..8aa7f0daef 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react' +import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, - IconCloseOutline16, IconEditOutline16, MarkdownText, + Button, IconCheckOutline14, IconChevronDownOutline14, IconChevronLeftOutline14, + IconChevronRightOutline14, IconChevronUpOutline14, IconCloseOutline16, + IconEditOutline16, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { PendingQuestion, planReviewOf, @@ -75,6 +76,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick(null) const [error, setError] = useState(null) + // Collapsed to the header strip so the conversation above stays readable + // while the user decides; the drafts survive because the state lives here. + const [minimized, setMinimized] = useState(false) + // The free-form textarea autofocuses on first presentation; re-expanding a + // collapsed question must not steal focus from the expand toggle back into + // the input, so focus is granted once per question index. + const focusedQuestions = useRef(new Set()) // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. // oxlint-disable-next-line typescript/no-non-null-assertion const question = questions[index]! @@ -191,7 +199,10 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick -
    +
    {question.header !== undefined &&
    {question.header}
    } @@ -199,138 +210,155 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick
    - +
    + + +
    -
    - {question.detail !== undefined && ( -
    - )} -
    - {(question.options ?? []).map((option, optionIndex) => { - const selected = draft.selected.includes(option.label) - const display = parseRecommendedLabel(option.label) - return ( - - ) - })} - - {hasOptions - ? ( -
    - {question.multiSelect === true - ? ( - - ) - : ( - - )} - -
    - ) - : ( -