From e0f22aeaad680f0f96c49821a39c851e4e969ed7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:51:03 +0800 Subject: [PATCH 01/80] 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 02/80] 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 03/80] 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 04/80] 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 05/80] 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 06/80] 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 07/80] 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 08/80] 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 09/80] 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 10/80] 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 11/80] 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 12/80] 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 13/80] 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 14/80] 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 15/80] 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 16/80] 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 17/80] 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 18/80] 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 19/80] 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 20/80] 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 21/80] 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 22/80] 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 23/80] 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 24/80] 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 25/80] 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 26/80] 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 27/80] 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 28/80] 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 29/80] 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 30/80] 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 31/80] 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 32/80] 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 33/80] 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 34/80] 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 35/80] 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 36/80] 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 37/80] 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 38/80] 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 39/80] 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 40/80] 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 41/80] 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 42/80] 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 078dd2b6dffd67b41de0b93e48a680048e3b5892 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 13 Aug 2026 17:57:58 +0800 Subject: [PATCH 43/80] 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 44/80] 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 45/80] 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 46/80] 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 47/80] 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 8692a1b76bd0672e27d3d5588bcb849dcb14dd32 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 13 Aug 2026 15:21:45 +0800 Subject: [PATCH 48/80] test(python): pin the minimal composition's model-visible surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python lane never compared what the minimal composition shows the model: the mock model only asserted system-role messages, and the advanced snapshot tokenizes the assembled system prompt and tool schemas. The sdk-minimal scenario now records model-visible.json — every model request's advertised tool schemas verbatim and its message list, with system and user text kept and assistant/tool payloads reduced to call identity so the expected output replays on macOS and Linux. It excludes the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux (#2488). AGENTS.md and the testing policy name both SDKs as independent projections of the agent loop, session lifecycle, and SessionEventMap. --- ...d-python-runtime-pull-request-ci.i18n.yaml | 4 +- ...required-python-runtime-pull-request-ci.md | 4 +- ...uired-python-runtime-pull-request-ci.zh.md | 4 +- ...n-minimal-model-visible-snapshot.i18n.yaml | 6 + ...3-python-minimal-model-visible-snapshot.md | 33 ++ ...ython-minimal-model-visible-snapshot.zh.md | 33 ++ AGENTS.md | 1 + docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- python/development.i18n.yaml | 4 +- python/development.md | 9 + python/development.zh.md | 9 + scripts/doc-budgets.manifest.json | 2 +- scripts/smoke-python-runtime.py | 149 ++++-- .../minimal/model-visible.json | 430 ++++++++++++++++++ 16 files changed, 652 insertions(+), 44 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md create mode 100644 .agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md create mode 100644 scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml index 10cd239b9a..30d2548222 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-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/testing/2026-08-12-required-python-runtime-pull-request-ci.md -2026-08-12-required-python-runtime-pull-request-ci.md: 2f5dcac17262bd885049221620a4d9708b083faa -2026-08-12-required-python-runtime-pull-request-ci.zh.md: 66c81f70d1bf25355425030883ccc4307efbb1ce +2026-08-12-required-python-runtime-pull-request-ci.md: 61b1e832be6d29eafe5cb304d2bca3f0a59e3d84 +2026-08-12-required-python-runtime-pull-request-ci.zh.md: 92bf80688d8d152b26fdd893fe0f0b96553868e9 diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md b/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md index 2f5dcac172..61b1e832be 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md +++ b/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md @@ -10,11 +10,11 @@ Ordinary pull-request CI runs the complete Python SDK pytest suite against fake ## Decision -Every pull request has a required `python-runtime` job in [CI](../../../../.github/workflows/ci.yml). It calls the shared [single-executable builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) for `node24-linux-x64` without a path filter and participates in `all checks passed`. The called workflow builds the real executable, runs all keyless Python full-turn and direct-binary scenarios including the committed executable snapshot, builds the SDK and runtime wheels, installs them into a clean virtual environment, checks the executable and native addon's GLIBC requirements, and runs the installed wheels in a manylinux 2.28 container. +Every pull request has a required `python-runtime` job in [CI](../../../../.github/workflows/ci.yml). It calls the shared [single-executable builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) for `node24-linux-x64` without a path filter and participates in `all checks passed`. The called workflow builds the real executable, runs all keyless Python full-turn and direct-binary scenarios including both committed snapshots, builds the SDK and runtime wheels, installs them into a clean virtual environment, checks the executable and native addon's GLIBC requirements, and runs the installed wheels in a manylinux 2.28 container. The required job and the [Python publication workflow](../process/2026-08-11-python-publication-workflow.md) use the same builder. Its concurrency key includes the caller workflow, so required CI and an explicit full release validation for the same ref do not cancel each other. The complete linux-x64, linux-arm64, and macos-arm64 matrix remains a release validation because platform-independent runtime, SDK, and snapshot behavior needs one merge-blocking native carrier, while architecture-specific executable, addon, wheel-tag, and deployment-target behavior still needs all release targets before publication. -The executable snapshot normalizes opaque session, message, subagent, and workflow-run identifiers before comparison. A newly persisted workflow event therefore changes the reviewed expected output without making a random run identifier part of that output. +The advanced executable snapshot normalizes opaque session, message, subagent, and workflow-run identifiers before comparison. A newly persisted workflow event therefore changes the reviewed expected output without making a random run identifier part of that output. The minimal scenario's [model-visible snapshot](2026-08-13-python-minimal-model-visible-snapshot.md) covers the assembled system prompt, tool schemas, and message list that this one tokenizes. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md b/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md index 66c81f70d1..92bf80688d 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -每个拉取请求都在 [CI](../../../../.github/workflows/ci.yml) 中运行必需的 `python-runtime` 作业。该作业不使用路径过滤,调用共享的[单文件可执行程序构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)构建 `node24-linux-x64`,并参与 `all checks passed`。被调用的工作流会构建真实可执行文件,运行全部无密钥 Python 完整轮次和直接二进制场景(包括检入的 exe 快照),构建 SDK 与运行时 wheel 包,将二者安装进干净的虚拟环境,检查可执行文件与原生 addon 的 GLIBC 依赖,并在 manylinux 2.28 容器中运行已安装的 wheel 包。 +每个拉取请求都在 [CI](../../../../.github/workflows/ci.yml) 中运行必需的 `python-runtime` 作业。该作业不使用路径过滤,调用共享的[单文件可执行程序构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)构建 `node24-linux-x64`,并参与 `all checks passed`。被调用的工作流会构建真实可执行文件,运行全部无密钥 Python 完整轮次和直接二进制场景(包括两份检入的快照),构建 SDK 与运行时 wheel 包,将二者安装进干净的虚拟环境,检查可执行文件与原生 addon 的 GLIBC 依赖,并在 manylinux 2.28 容器中运行已安装的 wheel 包。 必需作业与 [Python 发布工作流](../process/2026-08-11-python-publication-workflow.md)共用同一构建器。其并发键包含调用方工作流,因此同一 ref 上的必需 CI 与显式完整发布验证不会互相取消。完整的 linux-x64、linux-arm64 和 macos-arm64 矩阵仍属于发布验证:平台无关的运行时、SDK 与快照行为只需要一个阻断合并的原生载体,而架构相关的可执行文件、addon、wheel 包标签与部署目标行为在发布前仍需要全部发布目标验证。 -exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。 +进阶 exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。极简场景的[模型可见快照](2026-08-13-python-minimal-model-visible-snapshot.md)覆盖了这份快照所占位化的已组装系统提示词、工具 schema 与消息列表。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.i18n.yaml new file mode 100644 index 0000000000..b5a7f0dfca --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.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/testing/2026-08-13-python-minimal-model-visible-snapshot.md +2026-08-13-python-minimal-model-visible-snapshot.md: 66cfa1ed667d9a60579b0d27ddca2667614d7e1c +2026-08-13-python-minimal-model-visible-snapshot.zh.md: 40f596208f07532e68e382013b36e0d7ec46de3d diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md new file mode 100644 index 0000000000..66cfa1ed66 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.md @@ -0,0 +1,33 @@ +# Agent Note: Python minimal-composition model-visible snapshot + +Status: implemented + +English | [中文](2026-08-13-python-minimal-model-visible-snapshot.zh.md) + +## Problem + +The Python lane never compared what the minimal composition actually shows the model. Dynamic runtime context reaches history as a user message, so the mock model's assertion that system-role messages equal the deployment persona could not see it, and the advanced executable snapshot replaces each request header's assembled system prompt with a token and each tool schema with its name. The sandbox-policy runtime-context message therefore rode along in the checked-in [minimal composition](../../../../examples/jsonrpc-agent/minimal.cordis.yml) while `python-runtime` stayed green, and any plugin that adds a system section, a tool, or another context message could do the same. + +## Decision + +The `sdk-minimal` scenario in [the packaged-runtime smoke](../../../../scripts/smoke-python-runtime.py) records `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`: for every model request of the turn, the advertised tool schemas verbatim and the message list. System and user messages keep their full text with the scenario's temporary directory tokenized; assistant and tool messages keep only call identity, because their PTY and filesystem text differs across the platforms the expected output replays on. + +One model-visible message is excluded: the agent loop's dynamic runtime-context snapshot. The same composition emits it on macOS and not on Linux, which the required lane runs, so no single expected output can carry it. That difference is a defect in its own right ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)) — this expected output covers every other model-visible message rather than waiting for it. + +The mock model no longer asserts the minimal scenario's tools and system prompts — the snapshot owns that surface and reports a complete diff instead of the first mismatch. Snapshot comparison takes its directory and file set as arguments, so the `minimal` and `advanced` expected outputs use one implementation, and `--update-snapshots` accepts `sdk-minimal`. + +## Alternatives considered + +**Snapshot the minimal session log, like the advanced scenario.** The minimal turn drives a real PTY and editor, so persisted tool results carry platform-dependent text. The expected output would go red for reasons unrelated to model-visible assembly, and normalizing that text away leaves the log carrying little the model-visible file does not. + +**Extend the mock model's inline assertions.** Every new model-visible contribution would need another hand-written expectation, and a failure names one mismatch rather than the whole surface. Tool descriptions would also be duplicated from the composition into the script. + +**Rely on the TypeScript SDK snapshot.** Its `persistent-tools` scenario pins the same composition's system prompt, tool schemas, and runtime context, but through replayed model responses and a source or `lib` runtime, in a different required job. It cannot show what the deployed executable's closure assembles for a Python caller. + +## Consequences + +A change to the minimal composition's model-visible surface — a system section, a tool, a tool description, or an added user message — now fails `python-runtime` with the exact diff, and landing it means rerunning `--scenario sdk-minimal --update-snapshots` and reviewing that diff. The minimal composition's tool descriptions become reviewed expected output. + +Assistant and tool message text is no longer compared, and the runtime-context snapshot is not compared at all. The scenario's own assertions continue to own persistent-shell state, editor output, and the final response; [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) owns the excluded message until its platform difference is resolved. + +[AGENTS.md](../../../../AGENTS.md) and [the testing policy](../../../../docs/testing.md) now name both SDKs as independent projections of the agent loop, session lifecycle, and `SessionEventMap`, so a change to any of those carries updating both expected outputs rather than only the one a contributor happens to run. diff --git a/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md new file mode 100644 index 0000000000..40f596208f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-08-13-python-minimal-model-visible-snapshot.zh.md @@ -0,0 +1,33 @@ +# Agent Note:Python 极简组合的模型可见快照 + +Status: implemented + +[English](2026-08-13-python-minimal-model-visible-snapshot.md) | 中文 + +## 问题 + +Python 通道从未比对极简组合实际展示给模型的内容。动态运行时上下文以 user 消息进入历史,因此 mock 模型"system 角色消息等于部署 persona"的断言看不见它;而进阶可执行文件快照会把每个请求头中已组装的系统提示词换成占位符、把每个工具 schema 换成其名称。于是 sandbox-policy 的运行时上下文消息一直搭车留在签入的[极简组合](../../../../examples/jsonrpc-agent/minimal.cordis.yml)里,而 `python-runtime` 始终是绿的;任何新增系统分段、工具或其他上下文消息的插件都能照此蒙混过关。 + +## 决策 + +[打包运行时冒烟测试](../../../../scripts/smoke-python-runtime.py)的 `sdk-minimal` 场景会录制 `scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json`:对该回合的每个模型请求,逐字记录对外公布的工具 schema 与消息列表。system 与 user 消息保留全文,仅将场景的临时目录替换为占位符;assistant 与 tool 消息只保留调用标识,因为它们的 PTY 与文件系统文本在期望输出需要重放的各平台上并不相同。 + +有一条模型可见消息被排除在外:agent loop 的动态运行时上下文快照。同一组合在 macOS 上会发出它,在必需车道所用的 Linux 上不会,因此任何单一期望输出都无法承载它。该差异本身就是缺陷([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))——这份期望输出覆盖其余全部模型可见消息,而不是等它先被修复。 + +mock 模型不再断言极简场景的工具与系统提示词——该面由快照拥有,并给出完整差异而非首个不匹配项。快照比对以目录与文件集合为参数,因此 `minimal` 与 `advanced` 两份期望输出共用一套实现,且 `--update-snapshots` 接受 `sdk-minimal`。 + +## 曾考虑的替代方案 + +**像进阶场景那样对极简会话日志做快照。** 极简回合驱动真实 PTY 与编辑器,持久化的工具结果带有平台相关文本。期望输出会因与模型可见组装无关的原因变红;而把这些文本归一化掉之后,日志所承载的内容也就所剩无几。 + +**扩展 mock 模型中的内联断言。** 每新增一项模型可见贡献都要再手写一条期望,且失败只会指出一处不匹配而非整个面。工具描述还会从组合复制进脚本,形成重复。 + +**依赖 TypeScript SDK 快照。** 其 `persistent-tools` 场景固定了同一组合的系统提示词、工具 schema 与运行时上下文,但走的是重放的模型响应与 source 或 `lib` 运行时,且位于另一个必需任务中。它无法体现已部署可执行文件的闭包为 Python 调用方组装出什么。 + +## 后果 + +极简组合模型可见面的改动——系统分段、工具、工具描述或新增的 user 消息——现在会让 `python-runtime` 带着精确差异失败;要让它落地,就必须重新运行 `--scenario sdk-minimal --update-snapshots` 并审阅该差异。极简组合的工具描述由此成为经过审阅的期望输出。 + +assistant 与 tool 消息文本不再参与比对,运行时上下文快照则完全不参与比对。持久 shell 状态、编辑器输出与最终响应仍由该场景自身的断言拥有;被排除的那条消息由 [#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488) 负责,直到其平台差异得到解决。 + +[AGENTS.md](../../../../AGENTS.md) 与[测试政策](../../../../docs/testing.md)现已点明两个 SDK 都是 agent loop、会话生命周期与 `SessionEventMap` 的独立投影,因此改动其中任何一项都要连带更新两侧的期望输出,而不只是贡献者恰好会运行的那一侧。 diff --git a/AGENTS.md b/AGENTS.md index 3fe315e212..d6e3b6cfc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. +- **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)). - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). - **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index a8b43ae725..8f5b7505cb 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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/testing.md -testing.md: 8ed81412a954bcd1d4b5d84eb4133ed081092764 -testing.zh.md: 5662db51847ba2f4e297d0bd3d4af0d024fa29fb +testing.md: bef73983e48365f6655e4d4242f4f73223d971ed +testing.zh.md: 7a5935165a335b4f3885092ef7b7b29f08f53c51 diff --git a/docs/testing.md b/docs/testing.md index 8ed81412a9..bef73983e4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the internal canonical-event JSONL snapshots and replay fixtures. The `pwsh-tool-turn` ACP scenario boots real `pwsh` and skips where it is absent. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. The two SDKs project the agent loop, session lifecycle, and `SessionEventMap` independently, so changing any of those updates both: `examples/jsonrpc-agent/tests/snapshots/` owns the TypeScript client; `scripts/snapshots/python-sdk-single-exe/` owns the Python client, which only the required `python-runtime` CI job runs. New capability seams, lifecycle variants, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 5662db5184..7a5935165a 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。浏览器渲染的 Web GUI 旅程使用上述 Web 应用快照套件。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/test-support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有内部规范事件 JSONL 快照与回放 fixture。`pwsh-tool-turn` ACP 场景启动真实 `pwsh`,在无 `pwsh` 的主机上跳过。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。浏览器渲染的 Web GUI 旅程使用上述 Web 应用快照套件。两个 SDK 各自独立地投影 agent loop、会话生命周期与 `SessionEventMap`,因此改动其中任何一项都要同时更新两者:`examples/jsonrpc-agent/tests/snapshots/` 拥有 TypeScript 客户端;`scripts/snapshots/python-sdk-single-exe/` 拥有 Python 客户端,且只有必需的 `python-runtime` CI 作业会运行它。新的能力 seam、生命周期变体或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index d5570fba11..72df1143fc 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 31dc254b58c05c2a19c7c4cd5dc1e53207517902 -development.zh.md: dbb85a0cffc5e06c7ca781b2b01f6993395204e3 +development.md: fe62a109f2643afe0b9be1ed51b86be0b9fa731f +development.zh.md: d4ab9850d6c83dc17a740240f97cef89d61faaa0 diff --git a/python/development.md b/python/development.md index 31dc254b58..fe62a109f2 100644 --- a/python/development.md +++ b/python/development.md @@ -27,6 +27,15 @@ uv run --project python/sdk pytest `python/sdk/tests/test_bundled_runtime.py` exercises available bundled carriers and skips a carrier when its artifact has not been built. For repository-wide test policy, see [Testing](../docs/testing.md). +That suite drives fake runtime peers. `scripts/smoke-python-runtime.py` drives the real packaged runtime instead, and the required `python-runtime` CI job runs every scenario against a freshly built executable: + +```sh +uv run --project python/sdk python scripts/smoke-python-runtime.py \ + --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 +``` + +Two scenarios compare committed expected output under `scripts/snapshots/python-sdk-single-exe/`. `minimal/model-visible.json` pins the checked-in minimal composition's assembled system prompts, advertised tool schemas, and model-visible messages, so a plugin that contributes an unintended system section or user message fails the job; it drops the dynamic runtime-context snapshot, which the same composition emits on macOS and not on Linux ([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488)). `advanced/` pins the SDK result and the persisted session logs. Rerun the owning scenario with `--update-snapshots` and review that diff before committing it. + An interactive smoke test needs `DEEPSEEK_API_KEY` in the environment or repository-root `.env`: ```python diff --git a/python/development.zh.md b/python/development.zh.md index dbb85a0cff..d4ab9850d6 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -27,6 +27,15 @@ uv run --project python/sdk pytest `python/sdk/tests/test_bundled_runtime.py` 会运行可用的内置载体;某个载体的产物尚未构建时,会跳过该载体。仓库级测试政策见 [测试](../docs/testing.md)。 +该套件面向的是伪造的运行时对端。`scripts/smoke-python-runtime.py` 面向真实的打包运行时;必需的 `python-runtime` CI 任务会用新构建的可执行文件运行全部场景: + +```sh +uv run --project python/sdk python scripts/smoke-python-runtime.py \ + --scenario sdk-minimal --exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 +``` + +其中两个场景会比对 `scripts/snapshots/python-sdk-single-exe/` 下已提交的期望输出。`minimal/model-visible.json` 固定了签入的极简组合所组装的系统提示词、对外公布的工具 schema 以及模型可见消息,因此插件一旦贡献出计划外的系统分段或 user 消息,该任务即失败;它会丢弃动态运行时上下文快照——同一组合在 macOS 上会发出它,在 Linux 上不会([#2488](https://github.com/deepseek-harness/deepseek-harness/issues/2488))。`advanced/` 固定 SDK 结果与持久化的会话日志。重新运行对应场景时加上 `--update-snapshots`,并在提交前审阅该差异。 + 交互式冒烟测试需要环境变量或仓库根目录 `.env` 中存在 `DEEPSEEK_API_KEY`: ```python diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 6358eae682..050cab0ffd 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1900, + "AGENTS.md": 1950, "docs/AGENTS.md": 1320, "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 700cfb7c79..8cac1519b5 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -28,7 +28,6 @@ WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " -MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." MINIMAL_CORDIS = ( Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" ) @@ -65,10 +64,18 @@ SNAPSHOT_WORKFLOW_SCRIPT = ( f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n" "return { reply }" ) -SNAPSHOT_DIRECTORY = ( +ADVANCED_SNAPSHOT_DIRECTORY = ( Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced" ) -SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl") +ADVANCED_SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl") +MINIMAL_SNAPSHOT_DIRECTORY = ( + Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "minimal" +) +MINIMAL_SNAPSHOT_FILENAMES = ("model-visible.json",) +# The agent loop's dynamic runtime-context snapshot is the one model-visible message this +# expected output cannot carry: the same composition emits it on macOS and not on Linux +# (deepseek-harness#2488), and the file must replay on both. Everything else is compared. +RUNTIME_CONTEXT_PREFIX = "Current runtime context" CUSTOM_CORDIS = """\ - id: sdk-jsonrpc-server name: '@deepseek-ai/dsh-sdk-jsonrpc-server' @@ -170,17 +177,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: ), None, ) + # The minimal composition's assembled system prompt, advertised tool schemas, and + # model-visible messages are pinned by its snapshot, not asserted here. if minimal_prompt is not None: - names = advertised_tool_names(body) - if names != {"bash", "str_replace_editor"}: - raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}") - system_prompts = [ - message_text(message.get("content")) - for message in messages - if isinstance(message, dict) and message.get("role") == "system" - ] - if system_prompts != [MINIMAL_SYSTEM_PROMPT]: - raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}") return tool_call_chunks( "minimal-bash-1", "bash", @@ -485,8 +484,8 @@ def main() -> None: args = parser.parse_args() if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") - if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: - parser.error("--update-snapshots requires --scenario sdk-snapshot or all") + if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot"}: + parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, or all") if args.exe is not None and not args.exe.is_file(): parser.error(f"runtime executable does not exist: {args.exe}") @@ -498,7 +497,7 @@ def main() -> None: smoke_sdk_custom(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None - smoke_sdk_minimal(model.url, args.exe.resolve()) + smoke_sdk_minimal(model.url, args.exe.resolve(), args.update_snapshots) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -558,10 +557,12 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) -def smoke_sdk_minimal(base_url: str, executable: Path) -> None: +def smoke_sdk_minimal(base_url: str, executable: Path, update_snapshots: bool) -> None: """Exercise the checked-in minimal composition through the packaged executable.""" from deepseek_harness import DeepSeekHarness + # One mock model serves every scenario of a run, so the snapshot takes this turn's slice. + first_request = len(MockModelHandler.requests) with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary: root = Path(temporary).resolve() editor_path = root / "created.txt" @@ -587,6 +588,11 @@ def smoke_sdk_minimal(base_url: str, executable: Path) -> None: raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + files = build_minimal_snapshot_files(MockModelHandler.requests[first_request:], root) + compare_snapshot_files( + files, update_snapshots, MINIMAL_SNAPSHOT_DIRECTORY, MINIMAL_SNAPSHOT_FILENAMES, + ) + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" @@ -628,7 +634,9 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) raise AssertionError("second advanced child log has no workflow-subagent result") files = build_snapshot_files(result, logs, child_ids, root) - compare_snapshot_files(files, update_snapshots) + compare_snapshot_files( + files, update_snapshots, ADVANCED_SNAPSHOT_DIRECTORY, ADVANCED_SNAPSHOT_FILENAMES, + ) def smoke_direct(base_url: str, executable: Path) -> None: @@ -802,6 +810,79 @@ def snapshot_child_ids(result: "RunResult") -> list[str]: return child_ids +def build_minimal_snapshot_files( + requests: list[dict[str, object]], + cwd: Path, +) -> dict[str, str]: + """Render the minimal composition's model-visible surface as expected output. + + Every assembled system prompt, advertised tool schema, and system or user message is + kept verbatim: they carry what the deployment actually shows the model, so a plugin + that contributes an unintended system section or user message cannot pass unnoticed. + Assistant and tool payloads keep only their call identity, and the dynamic + runtime-context snapshot is dropped, because their text differs across the platforms + this expected output must replay on. + """ + snapshot = [] + for body in requests: + messages = body.get("messages") + if not isinstance(messages, list): + raise AssertionError(f"minimal model request has no messages: {body}") + snapshot.append({ + "tools": minimal_snapshot_text(body.get("tools"), cwd), + "messages": [ + minimal_snapshot_message(message, cwd) + for message in messages + if not is_runtime_context_message(message) + ], + }) + return {"model-visible.json": json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n"} + + +def is_runtime_context_message(message: object) -> bool: + """Identify the agent loop's dynamic runtime-context snapshot, current or cleared.""" + return ( + isinstance(message, dict) + and message.get("role") == "user" + and message_text(message.get("content")).startswith(RUNTIME_CONTEXT_PREFIX) + ) + + +def minimal_snapshot_message(message: object, cwd: Path) -> dict[str, object]: + """Reduce one model-visible message to its stable, behavior-carrying parts.""" + if not isinstance(message, dict): + raise AssertionError(f"minimal model request has an invalid message: {message}") + role = message.get("role") + if role in ("system", "user"): + return {"role": role, "text": minimal_snapshot_text(message_text(message.get("content")), cwd)} + if role == "assistant": + calls = message.get("tool_calls") + if not isinstance(calls, list): + raise AssertionError(f"minimal assistant message has no tool calls: {message}") + return { + "role": role, + "toolCalls": [ + {"id": call.get("id"), "name": (call.get("function") or {}).get("name")} + for call in calls + if isinstance(call, dict) + ], + } + if role == "tool": + return {"role": role, "toolCallId": message.get("tool_call_id"), "text": "{{tool-result}}"} + raise AssertionError(f"minimal model request has an unexpected message role: {message}") + + +def minimal_snapshot_text(value: object, cwd: Path) -> object: + """Replace the scenario's temporary working directory everywhere it appears.""" + if isinstance(value, str): + return value.replace(str(cwd), "{{cwd}}") + if isinstance(value, list): + return [minimal_snapshot_text(item, cwd) for item in value] + if isinstance(value, dict): + return {key: minimal_snapshot_text(item, cwd) for key, item in value.items()} + return value + + def build_snapshot_files( result: "RunResult", logs: dict[str, list[dict[str, object]]], @@ -838,8 +919,6 @@ def build_snapshot_files( files[f"session.{index}.jsonl"] = render_jsonl( [normalize_snapshot_value(record, replacements) for record in logs[child_id]] ) - if tuple(files) != SNAPSHOT_FILENAMES: - raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}") return files @@ -930,27 +1009,35 @@ def render_jsonl(records: list[object]) -> str: ) -def compare_snapshot_files(files: dict[str, str], update: bool) -> None: - """Write or exactly compare the advanced executable snapshot files.""" +def compare_snapshot_files( + files: dict[str, str], + update: bool, + directory: Path, + filenames: tuple[str, ...], +) -> None: + """Write or exactly compare one scenario's expected snapshot files.""" + scenario = directory.name + if tuple(files) != filenames: + raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}") if update: - SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True) + directory.mkdir(parents=True, exist_ok=True) for name, content in files.items(): - (SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8") - print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}") + (directory / name).write_text(content, encoding="utf-8") + print(f"smoke-python-runtime: updated snapshots in {directory}") existing = { path.name - for path in SNAPSHOT_DIRECTORY.iterdir() + for path in directory.iterdir() if path.is_file() - } if SNAPSHOT_DIRECTORY.is_dir() else set() - expected = set(SNAPSHOT_FILENAMES) + } if directory.is_dir() else set() + expected = set(filenames) if existing != expected: raise AssertionError( - "advanced snapshot files differ: " + f"{scenario} snapshot files differ: " f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}" ) for name, actual in files.items(): - expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8") + expected_text = (directory / name).read_text(encoding="utf-8") if actual == expected_text: continue diff = "".join(difflib.unified_diff( @@ -960,7 +1047,7 @@ def compare_snapshot_files(files: dict[str, str], update: bool) -> None: tofile=f"actual/{name}", )) raise AssertionError( - f"advanced executable snapshot mismatch in {name}; " + f"{scenario} executable snapshot mismatch in {name}; " "rerun with --update-snapshots after reviewing the behavior\n" f"{diff}" ) diff --git a/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json b/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json new file mode 100644 index 0000000000..a3223c8d76 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/minimal/model-visible.json @@ -0,0 +1,430 @@ +[ + { + "tools": [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "bash" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "bash" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-2", + "name": "bash" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-2", + "text": "{{tool-result}}" + } + ] + }, + { + "tools": [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Run commands in a bash shell\n* When invoking this tool, the contents of the \"command\" parameter does NOT need to be XML-escaped.\n* You don't have access to the internet via this tool.\n* You do have access to a mirror of common linux and python packages via apt and pip.\n* State is persistent across command calls and discussions with the user.\n* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'.\n* Please avoid commands that may produce a very large amount of output.\n* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "str_replace_editor", + "description": "Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with ``\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`.", + "enum": [ + "view", + "create", + "str_replace", + "insert" + ] + }, + "path": { + "type": "string", + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`." + }, + "file_text": { + "type": "string", + "description": "Required parameter of `create` command, with the content of the file to be created." + }, + "insert_line": { + "type": "integer", + "description": "Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`." + }, + "new_str": { + "type": "string", + "description": "Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert." + }, + "old_str": { + "type": "string", + "description": "Required parameter of `str_replace` command containing the string in `path` to replace." + }, + "view_range": { + "type": "array", + "description": "Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.", + "items": { + "type": "integer" + } + } + }, + "required": [ + "command", + "path" + ] + } + } + } + ], + "messages": [ + { + "role": "system", + "text": "You are a helpful software engineer assistant." + }, + { + "role": "user", + "text": "Exercise the packaged minimal agent's persistent Bash and string-replacement editor.\nEditor path: {{cwd}}/created.txt" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-1", + "name": "bash" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-1", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-bash-2", + "name": "bash" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-bash-2", + "text": "{{tool-result}}" + }, + { + "role": "assistant", + "toolCalls": [ + { + "id": "minimal-editor", + "name": "str_replace_editor" + } + ] + }, + { + "role": "tool", + "toolCallId": "minimal-editor", + "text": "{{tool-result}}" + } + ] + } +] From a95171b0842e2b4088cb1017eb466a730b026b78 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 17:33:55 +0800 Subject: [PATCH 49/80] fix(code-runtime-python): declare the MIT license the package gate requires master added verify-dsh-package-licenses while this branch was open: every repository-owned DSH package must declare "license": "MIT". This package carried BSD-3-Clause from its creation, so the gate failed and took the required "node 24 / static" lane 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 f79e0584ef..2b7734dc94 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -30,7 +30,7 @@ "py/**/*.py", "lib/types/**/*.d.ts" ], - "license": "BSD-3-Clause", + "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" From 88336074693c330ee8905da0e3b001908dfd7632 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 17:46:40 +0800 Subject: [PATCH 50/80] docs(code-runtime-python): state the package's current surface, not its stack position docs/AGENTS.md:38 keeps PRs, commits, and stack positions out of durable prose. Both README sides described where this layer sits in a PR stack and what a later PR would add, which goes stale the moment the backend lands. Describe what the package owns instead: the wire protocol, with an exported surface that carries no subprocess execution path. Re-record README.i18n.yaml. --- packages/code-runtime/code-runtime-python/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime-python/README.md | 4 ++-- packages/code-runtime/code-runtime-python/README.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index d511b98ed7..7071bd66a6 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: a87b52ea6352392952a5833c2e88dfe44779efce -README.zh.md: 62f7e654bc431a7e9b0975541590e8b2780dac0c +README.md: 1fc19b89b751ce5063d6937d588b96f2f36ae69e +README.zh.md: 0351001308541b41ba519b70a61d25c48de73a4b diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index a87b52ea63..1fc19b89b7 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -4,7 +4,7 @@ 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-thread`](../code-runtime-worker-thread/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. +The package owns the wire protocol for that seam: the host-side frame codec and the Python-side mirror of the same message vocabulary. ## Wire protocol @@ -26,4 +26,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **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. +- **`src/index.ts` exports the protocol vocabulary only** — the package carries no subprocess execution path and no Python-side JSON codec, so nothing here spawns `python3` outside the mirror test. diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 62f7e654bc..0351001308 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -4,7 +4,7 @@ [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam 的 CPython 子进程实现。与 [`@deepseek-ai/dsh-code-runtime-worker-thread`](../code-runtime-worker-thread/README.md) 配套;以全新的 `python3` 子进程取代 Node worker 线程,让模型代码从 TypeScript 换成 Python。 -本包分多个 code-runtime-python PR 逐层搭建。本层交付 wire protocol;在其之上驱动 `python3 -I` 进程的 `PythonCodeRuntime` 实现随后落地。 +本包持有该 seam 的 wire protocol:host 侧的帧编解码,以及 Python 侧对同一套消息词汇的镜像。 ## Wire protocol @@ -26,4 +26,4 @@ host 与 CPython 子进程在子进程的 fd 3 上交换一个无版本号的 JS ## Known Limitations and Deferred Work - **跨语言 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 协议词汇。 +- **`src/index.ts` 只导出协议词汇** —— 本包不含子进程执行路径,也不含 Python 侧的 JSON codec,因此除 mirror 测试之外没有任何地方会启动 `python3`。 From d1700c8a011b497d839ace9aadae7810993699db Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 18:07:24 +0800 Subject: [PATCH 51/80] docs(code-runtime-python): scope the module JSDoc to the current contract The barrel's module comment described where a later implementation would sit relative to this seam, which docs/AGENTS.md:38 keeps out of durable prose. State what the module exports instead. --- packages/code-runtime/code-runtime-python/src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/index.ts b/packages/code-runtime/code-runtime-python/src/index.ts index 625576f220..8a3e99f2d1 100644 --- a/packages/code-runtime/code-runtime-python/src/index.ts +++ b/packages/code-runtime/code-runtime-python/src/index.ts @@ -1,11 +1,10 @@ /** * 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. + * The package owns the versionless fd-3 wire protocol between the Node host and + * the CPython subprocess. The protocol's host-side codec and hostile-frame + * validators are re-exported so every consumer of the wire shares one + * vocabulary. * @module @deepseek-ai/dsh-code-runtime-python */ From 3057f3bb1b5b92cf5b84f815bcd0fc05a0d4cba0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 18:32:18 +0800 Subject: [PATCH 52/80] docs(code-runtime-python): state the empty invariant's real reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/AGENTS.md:18 requires a package-specific "No runtime invariant:" reason on an empty installer. This one described a process-boundary implementation and real-subprocess integration tests that the package does not carry — it ships the wire-protocol codec and its Python mirror, covered by protocol.spec.ts and protocol-mirror.e2e.ts. --- packages/code-runtime/code-runtime-python/src/invariant.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/code-runtime/code-runtime-python/src/invariant.ts b/packages/code-runtime/code-runtime-python/src/invariant.ts index 99a25ac594..6f616bc5c0 100644 --- a/packages/code-runtime/code-runtime-python/src/invariant.ts +++ b/packages/code-runtime/code-runtime-python/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'code-runtime-python-invariant' 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. + * No runtime invariant: this package ships only the fd-3 wire-protocol codec and its Python mirror, + * exposing no runtime event sequence or mutable data relation; `protocol.spec.ts` and + * `protocol-mirror.e2e.ts` cover the protocol's behavior. */ const install: InvariantInstaller = () => {} From bb4ca698d63714e753f5621b07400e6ebb0b5d97 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 17 Aug 2026 18:26:07 +0800 Subject: [PATCH 53/80] release(dsh): 0.1.0-rc.7 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/runtime/package.json | 2 +- packages/client/schema-form/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web-react/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/examples/acp-demo/package.json | 2 +- packages/examples/agent-spine-demo/package.json | 2 +- packages/examples/jsonrpc-demo/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/apiproxy/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence-sqlite/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent-report/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/acp-snapshot/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 222 files changed, 222 insertions(+), 222 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index a5f7913c2f..1323329b2b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/apps/web/package.json b/apps/web/package.json index fc990f684c..8f0f0b634d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index 517d0c56d1..4229920f59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 6603a794fd..b099fd90f3 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 9d128595d8..99a489fc3c 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index edc6d65704..0bc596bf71 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly and Host Agent/Session lookup policy", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 844479fc40..176a728da9 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 3f11676e71..f1ee4f97ed 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index c602dc9399..a31983a599 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index a91d6b8cd2..6ec5f68a74 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 9351c491a9..62350bbc11 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 886fdc593e..133c79bb22 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 13ca525731..ad9ec48fae 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 00d4e6da21..6590050f8a 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index f1ab0c4044..fb5aadce7f 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 0184f72d18..37dcbea428 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index dd45276a7a..15123d4dc2 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 1da3ac6428..a619be73f2 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-runtime", "description": "Client core services: SlotRegistry, SessionRuntime (scope tree + object layer)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index e8e3a0228a..4951dee5c5 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-schema-form", "description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index f705158d1e..329be04af2 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index c257166e2a..5a1b81e978 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index cf80e20f3f..5aa199e0ac 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 2fc12605c1..12e5c7f6c2 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ac7f264f62..d76357300e 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 0cc14700fc..095c9ae54b 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 74b8e7845b..7ad263127d 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 6076aab741..e8408e9b04 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index a4ca2afe84..18da5f5d9d 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index a59d064dda..e10a78a7cd 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index d3ab329297..8846fba95b 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 481d02aa03..f722f84e5c 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 298fd8b133..7f07a83930 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection: the /model popupSelect over session.models / session.selectModel", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 62f03cbd07..3da9b90cfb 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 233a8a72fe..eaf1e6142f 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index b6e01b4f5a..4e4b7c0109 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index c5207dfc9d..24ca471a99 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 423755475c..dd312defcf 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 95af8a15fa..1a52441d2b 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index a9fd7b8e9d..4fb29559c6 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 485092d2b9..d86f5f86f2 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index cfda6d55fc..5b23543567 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index b2026095f2..5f9294908b 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 351daf442b..7352b8544d 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 0e0b7aadae..8b44d890d6 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 6f320cd92f..e335e9067e 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 80bc4f2586..991e0d844e 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 375053ba7c..d0a82b86c7 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 95fb02b0f3..df389798dc 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index e5b71cf942..c3f8cfffac 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index b6a711b873..e76b9e138c 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 75bf6877ad..a6be3170e5 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web-react", "description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 9e4e9481e0..62d4bfb4c1 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index d5223dba99..b78590e942 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index fafd5ae387..84b690614b 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 8255fdf8f7..74c8838cd0 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 8c77c3ae0f..9c9416905f 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 95cf293f17..8a3d5bea87 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index af29cf7cf9..b362e2e5ae 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 8d5368da29..d7b3cdf81d 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 8d3e0ec487..5fc478314f 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index d603c28c23..bc944e6cfb 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index a52753965a..2209d53122 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 713fdda02d..2a923e2107 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 64e9f4c5ef..b922d98f59 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index db3aea959d..21a6e5688e 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9940ba4eb9..6144f8679a 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index c08d5feb57..06e0aa7e28 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index cb6d44d59b..0c8bc6b850 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 5bf096b478..529cf5149f 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index ba3b3a8a7c..fd27a8822d 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 8a9f9248e1..87a3b6f9ba 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 02011bf876..2b1bfca785 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 7af9fc15f0..e333cf21bb 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index bcfad85d39..9bfc0fcdb5 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index c63a05ccf9..4bb66e53c0 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index ba1c541973..de22377405 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-demo", "description": "ACP automation server app: agent spine + JSONL persistence + ACP transport, with a JSON-RPC stdio bin", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 193258b7a6..9f872e6dd6 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 761e22159a..7b76192766 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-demo", "description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 74f7970bbc..afb0a3c1a3 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 8c558bee1d..957c44e8fd 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index f98404a113..cb4e1a14f3 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index cacd075a58..d6d0cc8034 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 0d587392a7..fd9fdadcdf 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index d7f2ea911f..d8ee03363f 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 647a74a123..cfcc4683bf 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index f7a5db5af1..bf20c4f65c 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 3b301a50b6..674fc4919f 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 9a8008262a..ad4144e255 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index ba306fa020..ab6b48c771 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 2f7c26b6da..65f326ddcb 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 3de08fa940..07af01aa95 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 8cb103c4c2..54d4c16a5e 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index ccbc49b6ed..c499037847 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index e2cc73214b..77418cfaa8 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index eaefde65b4..2b20854351 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 697a0e0078..447bc2e08f 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 46557d200c..3d983d3a88 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 82f46da1ce..21fa4c11be 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 1966226c8b..4b393fc8d9 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 503a1afdbc..9b40e43fb6 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index e822fd5fe4..946e27ded3 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-apiproxy", "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 3724d97578..8950514029 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 252c010126..f7beaa9c1f 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 664e1466ce..fec5fe87e0 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index b54b0cab66..f8cbbcf680 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 2c62c310d0..ca67bb7761 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 5887a6f70c..f27aad67a3 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index ad624f8c2a..5a4f748bb9 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index d3621f4deb..f967e48d2b 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 59322d8de9..0fb8ab6650 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 42d2f08aff..59e7f4abeb 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 673ddc9625..0e11850943 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 9f9d13f051..06f72c1e2f 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index f2dd1ac8c6..30709a00f3 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 6b4c6fb474..a615cded8b 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 237b3cbfa9..6734d1ea37 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 808185e12f..57f585ff50 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 218f744d61..36ccad7994 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 59d38779c0..3575d6672f 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index c439843e18..44386ce78b 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 7fd91eb819..8f491173d2 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index cd51129cab..c5236fbbf8 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index b968692931..23d6113ea2 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 3fd076ae18..04d107cedb 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 008014844f..d6efa136a8 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index a581c8781b..e3cc3e04c8 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 60369141fb..72295f7328 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 60c818de78..a8f374d765 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index dc06a859d3..0453ca0897 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 67f00d14fc..537381af9c 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 17c5f3ba4c..1866c8ddd7 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index ef1d57be97..3038e99718 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 447d43e511..817d52e48f 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index dc09171354..7581e4a7ff 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 9d47982958..70910080e5 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index f53672fe8b..523223f102 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 855c44d9c6..beda9e4982 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index b2f95436a8..ad55c29dd8 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index b84dede798..75a74a3ef5 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, "repository": { "type": "git", diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4fcfe04863..4bab825e49 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 6e0e97b532..8a2f49c053 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index e5338ac52c..8c069a5907 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index a41739103f..1273bf3ce3 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index e4e9b575a6..0b27dfa1d3 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index dd8c71d3eb..0335901faf 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-sqlite", "description": "SQLite durable session persistence backend for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 3916e0b5e4..73bba56675 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index bf86685372..61694c5fb9 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index bb83ee2b28..dbcc686ef8 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 43b2a55c2f..929940aeaa 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 2734cea997..b0d7be922b 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 7136878fc7..67b9379e08 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 6d95d95687..6287d55029 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 4e7ee22703..a11c796906 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 59f410535b..52c8e55bad 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index cc5d4f135d..3395816244 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 3d19880d0a..7e5cc60efa 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index b4c25a9290..5966a19a93 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index e0a1828d6b..68fb8a5cb1 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 80b193d9f9..e0614889ac 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 59ac15ac16..bd4d4d9569 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 9a8e012614..65a3863bec 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index dd115709ea..3fdf0741d3 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index a52b7da707..0da37192d9 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 7bbccc58e9..2232d909e4 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 66bedc106a..0de6feef49 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 5cf0a2aa1f..e87a546b62 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 94e45d7baf..520d3eb81c 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 165a62c647..c105ba58c7 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 24620a4563..ad9b3221f5 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index ad93077ed4..cdd1bbfa04 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 01ed5f1a6a..f74a1ba404 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index db4964b478..7ebe6e30c9 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 4a5d840afc..d1c436a2af 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index ee8e16006f..7f6a1de899 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index bdef47fa1a..617f8bcfe0 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index a40a487984..da1f7089d9 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 547505a9e1..e5e75353ef 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 3d4c4802a9..8a6fba06e7 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index a6d8ef2fb4..0c0e54cf11 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 0256ee8e21..29493a5612 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 658baa1e6e..8db10499cd 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index c0b5be501a..f5f0a8b1e6 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 4fdd7950e9..613eea85ca 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 0517fbbfda..2f317dde8a 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 2c4e8cff09..7a42bdb7a7 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 5817e4570b..05278fc62f 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 5a521442aa..dbadc60e88 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 47f336e438..8de6a04739 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index dad259817a..ab2c01973c 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 7dae5402ad..d03d7f136f 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 7034244946..b1b8ae6a59 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 02022966c1..388309075f 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 9af627fefb..9fe3475e74 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/acp-snapshot/package.json b/packages/test-support/acp-snapshot/package.json index 2ddbb313b7..d8ca0bcfe4 100644 --- a/packages/test-support/acp-snapshot/package.json +++ b/packages/test-support/acp-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output normalizers, and suite factory", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index eaddaeeb8c..e677c8befd 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 596ec1ed73..c3d08e3fc0 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + web-react renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 7862460b09..ff21dc51a3 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 3ef733bca1..9a6f24bc1b 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 68c67dd08b..bf888ba13f 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index b95b969da2..9e30ebb1d0 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 0f7d210f16..cb544e3e9a 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 2642890fdb..649edf3275 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index d7d5294a60..ea21d951c9 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 84d3987011..973b16d7d5 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 572d4abad8..8eb4b96f12 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 74277b61e7..1070229b37 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 1844f8801e..5834c4a490 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 53be293855..e51b86bab9 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 973335b0fd..d5ea6fc943 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 53bf55133c..9be1db591f 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 8bb6893518..7ba37eda73 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 84ca7bd0d7..d7597c5e7b 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 82d44a1f65..0198f40581 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 7b6145ee97..dd924f259d 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 28583b7a23..b52482cd0c 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 503b588fa3..f7170f22f2 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 05826f6ad4..4338393818 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index a8e83c3394..a795d22ca8 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index b400c22fdb..2bd0cd13a9 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index fad0c9ec6a..8e2c094009 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index ec20881488..bf71730d89 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 2e0ed5a0b4..594815d82b 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From 9705290fe49cd63dc08ced0d66e0fcde97c4fb99 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:42:26 +0800 Subject: [PATCH 54/80] fix: dep version --- 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 2b7734dc94..7cea7a25b5 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.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From bace78045872a3483df83beb4b911b4afe927c93 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:03:57 +0800 Subject: [PATCH 55/80] fix: windows ci --- .../agent-instructions/tests/agent-instructions.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 2bdee49988..171f7322f0 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -1,5 +1,5 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' -import { dirname, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -80,7 +80,8 @@ class RecordingFileSystem extends FileSystem { override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` } override contains(parent: FsTarget, child: FsTarget): boolean { - return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`) + const descendant = relative(String(parent.targetKey), String(child.targetKey)) + return descendant === '' || (!descendant.startsWith('..') && !isAbsolute(descendant)) } override async stat(target: FsTarget, signal?: AbortSignal): Promise { From 56dff07c4e0bc769eba9e02954c9958459f20332 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:47:32 +0800 Subject: [PATCH 56/80] refactor(client): move schema handling into ui-settings --- .../client/locale/tests/apply.client.spec.ts | 4 +- packages/client/schema-form/README.i18n.yaml | 6 - packages/client/schema-form/README.md | 23 --- packages/client/schema-form/README.zh.md | 23 --- packages/client/schema-form/package.json | 45 ------ packages/client/schema-form/src/index.ts | 12 -- packages/client/schema-form/src/invariant.ts | 32 ---- packages/client/schema-form/src/model.ts | 151 ------------------ .../tests/invariant.client.spec.ts | 12 -- .../schema-form/tests/model.client.spec.ts | 100 ------------ packages/client/schema-form/tsconfig.json | 18 --- packages/client/schema-form/tsdown.config.ts | 6 - .../client/ui-permission-presets/package.json | 7 +- .../ui-permission-presets/src/client/index.ts | 4 +- .../src/client/settings-store.ts | 15 +- .../permission-presets-row.client.spec.tsx | 18 ++- .../tests/settings-store.client.spec.ts | 48 +++--- .../ui-permission-presets/tsconfig.json | 3 - .../client/ui-settings-models/package.json | 8 +- .../src/client/DeepSeekOnboardingDialog.tsx | 1 + .../src/client/ModelsSection.tsx | 7 +- .../src/client/ProviderEditor.tsx | 75 +++++---- .../ui-settings-models/src/client/index.ts | 4 +- .../ui-settings-models/src/client/store.ts | 30 ++-- .../tests/components.client.spec.tsx | 18 ++- .../tests/onboarding-dialog.client.spec.tsx | 3 +- .../tests/provider-form.client.spec.tsx | 11 +- .../tests/settings-schema.client.ts | 5 + .../tests/store.client.spec.ts | 25 +-- .../client/ui-settings-models/tsconfig.json | 3 - .../tests/apply.client.spec.ts | 4 +- packages/client/ui-settings/package.json | 12 +- .../client/ui-settings/src/client/index.ts | 6 +- .../client/ui-settings/src/client/schema.ts | 121 ++++++++++++++ .../ui-settings/src/client/settings-scope.ts | 9 +- .../tests/settings-scope.client.spec.ts | 5 +- packages/client/ui-settings/tsconfig.json | 2 +- .../ui-theme/tests/apply.client.spec.ts | 4 +- 38 files changed, 312 insertions(+), 568 deletions(-) delete mode 100644 packages/client/schema-form/README.i18n.yaml delete mode 100644 packages/client/schema-form/README.md delete mode 100644 packages/client/schema-form/README.zh.md delete mode 100644 packages/client/schema-form/package.json delete mode 100644 packages/client/schema-form/src/index.ts delete mode 100644 packages/client/schema-form/src/invariant.ts delete mode 100644 packages/client/schema-form/src/model.ts delete mode 100644 packages/client/schema-form/tests/invariant.client.spec.ts delete mode 100644 packages/client/schema-form/tests/model.client.spec.ts delete mode 100644 packages/client/schema-form/tsconfig.json delete mode 100644 packages/client/schema-form/tsdown.config.ts create mode 100644 packages/client/ui-settings-models/tests/settings-schema.client.ts create mode 100644 packages/client/ui-settings/src/client/schema.ts diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..c54644275c 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS, @@ -47,7 +47,7 @@ async function bench() { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describe, mutate, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, diff --git a/packages/client/schema-form/README.i18n.yaml b/packages/client/schema-form/README.i18n.yaml deleted file mode 100644 index e0d2db8a38..0000000000 --- a/packages/client/schema-form/README.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 packages/client/schema-form/README.md -README.md: ef1d2f9d8ce936fe60d38849f975dc8c0a08ded4 -README.zh.md: 315e508ab1a2837acf4d159795cdcc93dedd72fa diff --git a/packages/client/schema-form/README.md b/packages/client/schema-form/README.md deleted file mode 100644 index ef1d2f9d8c..0000000000 --- a/packages/client/schema-form/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# @deepseek-ai/dsh-client-schema-form - -English | [中文](README.zh.md) - -Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the Service Definition's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering. - -## Contract - -The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing. - -## Model Experience - -None, as this package backs browser configuration editors; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Rehydration executes the served envelope** — `rehydrateSchema` reconstructs a live schemastery validator, and schemastery revives serialized callbacks through `new Function`, so the schema envelope is executable content rather than inert data. This is safe only for an envelope from the same trusted host that serves the page; the protocol provides no inert cross-trust representation. -- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message, including its `$.path`; it does not map errors onto individual controls. -- **No generic renderer** — consumers build feature-specific forms over these helpers. The [Web config-plane Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) records that trade-off. diff --git a/packages/client/schema-form/README.zh.md b/packages/client/schema-form/README.zh.md deleted file mode 100644 index 315e508ab1..0000000000 --- a/packages/client/schema-form/README.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# @deepseek-ai/dsh-client-schema-form - -[English](README.md) | 中文 - -面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 封装);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 Service Definition 的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React,也不做任何渲染。 - -## 约定 - -编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**(`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点(object 属性按名称解析,dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会明确进入降级路径,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。 - -## 模型体验 - -无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与暂缓事项 - -- **重建 schema 会执行所收到的封装**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的回调函数,因此 schema 信封是可执行内容,而不是不可执行数据。只有该封装来自提供该页面的同一受信任宿主时才安全;该协议没有跨信任边界使用的不可执行表示。 -- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。 -- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md) 记录该权衡。 diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json deleted file mode 100644 index 4951dee5c5..0000000000 --- a/packages/client/schema-form/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-schema-form", - "description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path", - "version": "0.1.0-rc.7", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/schema-form" - }, - "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" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "license": "MIT", - "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts" - ] -} diff --git a/packages/client/schema-form/src/index.ts b/packages/client/schema-form/src/index.ts deleted file mode 100644 index 3a8c35edcb..0000000000 --- a/packages/client/schema-form/src/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Schema/draft model layer for settings editors: rehydrate the wire's - * serialized schemastery envelope, resolve nodes by settings path, validate - * drafts, and edit them immutably by path. Editors render their own controls - * (the Models page hand-writes its layout) on top of these helpers. - * @module @deepseek-ai/dsh-client-schema-form - */ - -export { - deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, -} from './model.ts' -export type { SchemaNode } from './model.ts' diff --git a/packages/client/schema-form/src/invariant.ts b/packages/client/schema-form/src/invariant.ts deleted file mode 100644 index 90636e5d67..0000000000 --- a/packages/client/schema-form/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-schema-form`. - * @module @deepseek-ai/dsh-client-schema-form/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form' - -/** Cordis companion plugin name. */ -export const name = 'client-schema-form-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a pure schema/draft helper library — it emits no - * cordis events and owns no cross-plugin mutable relation; draft - * immutability, schema rehydration, and path-edit round trips are asserted - * directly by this package's model specs. - */ -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/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts deleted file mode 100644 index 5cfb624eb4..0000000000 --- a/packages/client/schema-form/src/model.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Schema introspection and draft-editing helpers behind settings editors. - * The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a - * live validator whose node relations (`dict`/`inner`) editors probe for - * field presence and roles; drafts are edited immutably by path. - * @module @deepseek-ai/dsh-client-schema-form/model - */ - -import Schema from '@deepseek-ai/schemastery' - -/** Live schemastery node; the renderer reads only its structural relations. */ -export type SchemaNode = Schema - -/** - * Rehydrate a serialized schema envelope into a live validator/node tree. - * @param serialized - `schema.toJSON()` output received over the wire. - * @returns the root schema node. - */ -export function rehydrateSchema(serialized: unknown): SchemaNode { - return new Schema(serialized as Schema) -} - -/** - * Validate a draft against a rehydrated schema. - * @param schema - rehydrated root node. - * @param draft - candidate value. - * @returns the validation failure message, or `undefined` when the draft passes. - */ -export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined { - try { - ;(schema as unknown as (value: unknown) => unknown)(draft) - return undefined - } catch (error) { - return error instanceof Error ? error.message : String(error) - } -} - -/** - * Resolve the schema node at a settings path (the configurable-provider - * directory's `settingsPath` vocabulary): object properties by name, dict - * entries through `inner`. An unresolvable segment returns `undefined` so - * the caller falls back instead of rendering a wrong subtree. - * @param root - rehydrated section root node. - * @param path - key path from the section root. - * @returns the node describing that position, or `undefined`. - */ -export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined { - let node: SchemaNode | undefined = root - for (const key of path) { - if (node === undefined) return undefined - if (node.type === 'object') node = (node.dict as Record | undefined)?.[key] - else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined - else return undefined - } - return node -} - -/** - * Read a nested value by path. - * @param value - root value (draft or fallback layer). - * @param path - key path from the root; array indexes as strings. - * @returns the value at the path, or `undefined` along a missing branch. - */ -export function getPath(value: unknown, path: readonly string[]): unknown { - let current: unknown = value - for (const key of path) { - if (Array.isArray(current)) { - current = current[Number(key)] - continue - } - if (typeof current !== 'object' || current === null) return undefined - current = (current as Record)[key] - } - return current -} - -/** - * Whether a draft explicitly carries the path (its presence marks a user - * override, independent of the value stored there). - * @param value - root value (draft or fallback layer). - * @param path - key path from the root; array indexes as strings. - * @returns whether the path's final key exists on its parent. - */ -export function hasPath(value: unknown, path: readonly string[]): boolean { - if (path.length === 0) return value !== undefined - const parent = getPath(value, path.slice(0, -1)) - const key = path[path.length - 1] as string - if (Array.isArray(parent)) return Number(key) < parent.length - if (typeof parent !== 'object' || parent === null) return false - return key in parent -} - -function cloneContainer(container: unknown, key: string): Record | unknown[] { - if (Array.isArray(container)) return [...container as unknown[]] - if (typeof container === 'object' && container !== null) return { ...container as Record } - // A missing intermediate materializes as the container the next key needs. - return /^\d+$/.test(key) ? [] : {} -} - -/** Clone the container spine down to the leaf's parent, materializing missing intermediates. */ -function cloneSpine(root: Record, path: readonly string[]): { - result: Record - parent: Record | unknown[] - leaf: string -} { - const result = { ...root } - let target: Record | unknown[] = result - for (let i = 0; i < path.length - 1; i++) { - const key = path[i] as string - const child = cloneContainer( - Array.isArray(target) ? target[Number(key)] : (target)[key], - path[i + 1] as string, - ) - if (Array.isArray(target)) target[Number(key)] = child - else (target)[key] = child - target = child - } - return { result, parent: target, leaf: path[path.length - 1] as string } -} - -/** - * Immutably set a nested value, materializing missing intermediate containers. - * @param root - draft root (never mutated). - * @param path - non-empty key path. - * @param value - value to store at the path. - * @returns the new draft root. - */ -export function setPath(root: Record, path: readonly string[], value: unknown): Record { - if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path') - const { result, parent, leaf } = cloneSpine(root, path) - if (Array.isArray(parent)) parent[Number(leaf)] = value - else parent[leaf] = value - return result -} - -/** - * Immutably remove a nested key (the per-field reset: the resolved value - * falls back to the composition base and schema defaults). Removing along a - * missing branch returns the root unchanged. - * @param root - draft root (never mutated). - * @param path - non-empty key path. - * @returns the new draft root. - */ -export function deletePath(root: Record, path: readonly string[]): Record { - if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path') - if (!hasPath(root, path)) return root - const { result, parent, leaf } = cloneSpine(root, path) - if (Array.isArray(parent)) parent.splice(Number(leaf), 1) - else Reflect.deleteProperty(parent, leaf) - return result -} diff --git a/packages/client/schema-form/tests/invariant.client.spec.ts b/packages/client/schema-form/tests/invariant.client.spec.ts deleted file mode 100644 index 6e63f8f995..0000000000 --- a/packages/client/schema-form/tests/invariant.client.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant' -import InvariantRegistry from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantRegistry, { enabled: true }) - await expect(ctx.plugin(SchemaFormInvariant).await()).resolves.toBeDefined() - }) -}) diff --git a/packages/client/schema-form/tests/model.client.spec.ts b/packages/client/schema-form/tests/model.client.spec.ts deleted file mode 100644 index 1a95e88903..0000000000 --- a/packages/client/schema-form/tests/model.client.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest' -import Schema from '@deepseek-ai/schemastery' -import { - deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, -} from '../src/model.ts' - -const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON())) - -describe('rehydration and validation', () => { - it('rehydrates a serialized envelope into a working validator', () => { - const root = rehydrateSchema(Wire(Schema.object({ name: Schema.string().required() }))) - expect(validateDraft(root, { name: 'ok' })).toBeUndefined() - expect(validateDraft(root, { name: 42 })).toContain('name') - }) - - it('stringifies non-Error validation throws', () => { - const hostile = (() => { - throw 'plain-string failure' - }) as unknown as Parameters[0] - expect(validateDraft(hostile, {})).toBe('plain-string failure') - }) -}) - -describe('path helpers', () => { - const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] } - - it('reads nested object and array paths', () => { - expect(getPath(root, [])).toBe(root) - expect(getPath(root, ['providers', 'openai', 'baseURL'])).toBe('https://x') - expect(getPath(root, ['models', '0', 'id'])).toBe('a') - expect(getPath(root, ['providers', 'missing', 'x'])).toBeUndefined() - expect(getPath(root, ['providers', 'openai', 'baseURL', 'deep'])).toBeUndefined() - }) - - it('reports draft presence by key existence, not value truthiness', () => { - expect(hasPath({ flag: false }, ['flag'])).toBe(true) - expect(hasPath({ nested: { key: undefined } }, ['nested', 'key'])).toBe(true) - expect(hasPath({}, ['missing'])).toBe(false) - expect(hasPath({ leaf: 'x' }, ['leaf', 'deeper'])).toBe(false) - expect(hasPath({ models: ['a'] }, ['models', '0'])).toBe(true) - expect(hasPath({ models: ['a'] }, ['models', '1'])).toBe(false) - expect(hasPath({ root: true }, [])).toBe(true) - expect(hasPath(undefined, [])).toBe(false) - }) - - it('sets nested paths immutably, materializing containers by key shape', () => { - const draft = {} - const next = setPath(draft, ['providers', 'openai', 'baseURL'], 'https://y') - expect(draft).toEqual({}) - expect(next).toEqual({ providers: { openai: { baseURL: 'https://y' } } }) - const withArray = setPath(next, ['models', '0'], { id: 'a' }) - expect(withArray).toEqual({ providers: { openai: { baseURL: 'https://y' } }, models: [{ id: 'a' }] }) - const replaced = setPath(withArray, ['models', '0', 'id'], 'b') - expect(replaced.models).toEqual([{ id: 'b' }]) - expect((withArray as { models: unknown[] }).models).toEqual([{ id: 'a' }]) - expect(() => setPath({}, [], 'x')).toThrow(/non-empty path/) - }) - - it('deletes nested paths immutably and splices array indexes', () => { - const draft = { providers: { openai: { baseURL: 'https://x', apiKey: 'k' } }, models: ['a', 'b'] } - const withoutKey = deletePath(draft, ['providers', 'openai', 'apiKey']) - expect(withoutKey).toEqual({ providers: { openai: { baseURL: 'https://x' } }, models: ['a', 'b'] }) - expect(draft.providers.openai.apiKey).toBe('k') - const withoutModel = deletePath(withoutKey, ['models', '0']) - expect(withoutModel.models).toEqual(['b']) - expect(deletePath(draft, ['providers', 'missing', 'x'])).toBe(draft) - expect(() => deletePath({}, [])).toThrow(/non-empty path/) - }) - - it('deletes keys through array intermediates immutably', () => { - const draft = { models: [{ id: 'a', contextWindow: 1 }] } - const next = deletePath(draft, ['models', '0', 'contextWindow']) - expect(next).toEqual({ models: [{ id: 'a' }] }) - expect(draft.models[0]).toEqual({ id: 'a', contextWindow: 1 }) - }) -}) - -describe('nodeAtPath', () => { - const Root = Schema.object({ - providers: Schema.dict(Schema.object({ baseURL: Schema.string() })), - models: Schema.array(Schema.object({ id: Schema.string() })), - leaf: Schema.string(), - }) - - it('resolves object, dict, and array positions', () => { - const root = rehydrateSchema(Wire(Root)) - expect(nodeAtPath(root, [])).toBe(root) - expect(nodeAtPath(root, ['providers', 'openai'])?.type).toBe('object') - expect(nodeAtPath(root, ['providers', 'openai', 'baseURL'])?.type).toBe('string') - expect(nodeAtPath(root, ['models', '0', 'id'])?.type).toBe('string') - expect(nodeAtPath(root, ['missing'])).toBeUndefined() - expect(nodeAtPath(root, ['missing', 'deeper'])).toBeUndefined() - expect(nodeAtPath(root, ['leaf', 'below'])).toBeUndefined() - }) - - it('tolerates structural nodes missing their relation maps', () => { - expect(nodeAtPath({ type: 'object' } as never, ['x'])).toBeUndefined() - expect(nodeAtPath({ type: 'dict' } as never, ['x'])).toBeUndefined() - }) -}) diff --git a/packages/client/schema-form/tsconfig.json b/packages/client/schema-form/tsconfig.json deleted file mode 100644 index 34abf11c47..0000000000 --- a/packages/client/schema-form/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../runtime-diagnostics/invariants" - } - ] -} diff --git a/packages/client/schema-form/tsdown.config.ts b/packages/client/schema-form/tsdown.config.ts deleted file mode 100644 index b03542c74e..0000000000 --- a/packages/client/schema-form/tsdown.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clientLibrary } from '../tsdown.client.ts' - -export default clientLibrary( - '@deepseek-ai/dsh-client-schema-form', - ['lib/types/index.js', 'lib/types/invariant.js'], -) diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 3da9b90cfb..09fd84275b 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -53,15 +53,11 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@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:^", @@ -69,7 +65,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index aec82bf9d9..68606219bc 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -43,7 +43,7 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote'] +export const inject = ['commandUi', 'sessions', 'slots', 'locale', 'connection', 'remote', 'settingsSchema'] const ACCESS_NS = 'permission.access' @@ -113,7 +113,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new PermissionPresetSettingsController(connection.api) + const controller = new PermissionPresetSettingsController(connection.api, ctx.settingsSchema) const load = (): Promise => controller.load() const select = (preset: string): Promise => controller.select(preset) const injected = (): PermissionRowInjected => ({ diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 6e7199f1be..f69f6a6ec6 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -10,9 +10,7 @@ import type { import { createSnapshotStore, type SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' -import { - nodeAtPath, rehydrateSchema, type SchemaNode, -} from '@deepseek-ai/dsh-client-schema-form' +import type { SchemaNode, SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { displayPermissionPreset } from './presentation.ts' /** Permission's settings namespace on the host wire. */ @@ -47,13 +45,13 @@ interface ConstChoice { * @param view - permission namespace descriptor. * @returns current value and selectable options. */ -export function permissionDefaultOf(view: SettingsNamespaceView): { +export function permissionDefaultOf(view: SettingsNamespaceView, schema: SettingsSchemaService): { currentValue: string options: PermissionDefaultOption[] } { const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value') - const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset']) + const node = schema.nodeAtPath(schema.rehydrate(view.schema), ['defaultPreset']) if (node === undefined) throw new Error('permission settings schema has no defaultPreset field') const rawChoices = node.type === 'union' ? (node.list as SchemaNode[] | undefined) ?? [] @@ -91,7 +89,10 @@ export class PermissionPresetSettingsController { private view: SettingsNamespaceView | undefined /** @param api - Settings wire face. */ - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + private readonly schema: SettingsSchemaService, + ) {} /** * Refresh the permission descriptor. Latest request wins. @@ -161,7 +162,7 @@ export class PermissionPresetSettingsController { } private accept(view: SettingsNamespaceView, writable: boolean): void { - const resolved = permissionDefaultOf(view) + const resolved = permissionDefaultOf(view, this.schema) this.view = view this.store.update((state) => { state.status = 'ready' diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index 9df3920bd5..f7a1ad0737 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { en } from '../src/client/locales.ts' import { PermissionPresetSettingsController } from '../src/client/settings-store.ts' @@ -20,6 +22,12 @@ const SCHEMA = { }, } +const schema = new SettingsSchemaService(new Context()) + +function createController(api: ConstructorParameters[0]) { + return new PermissionPresetSettingsController(api, schema) +} + function view(defaultPreset: string, revision = 0): SettingsNamespaceView { return { ns: 'permission', @@ -58,7 +66,7 @@ function mount(controller: PermissionPresetSettingsController) { describe('PermissionRow', () => { it('loads the descriptor, opens the menu, and selects a new default', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -85,7 +93,7 @@ describe('PermissionRow', () => { it('requires explicit acknowledgement before saving Full access', async () => { const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1)))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate, @@ -109,7 +117,7 @@ describe('PermissionRow', () => { }) it('hides an unavailable namespace and disables a read-only provider', async () => { - const absent = new PermissionPresetSettingsController({ + const absent = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] })), mutate: vi.fn(), @@ -119,7 +127,7 @@ describe('PermissionRow', () => { await waitFor(() => { expect(rendered.container.textContent).toBe('') }) rendered.unmount() - const readonly = new PermissionPresetSettingsController({ + const readonly = createController({ settings: { describe: () => Promise.resolve(ok({ writable: false, hasDocument: false, namespaces: [view('read-only')] })), mutate: vi.fn(), @@ -134,7 +142,7 @@ describe('PermissionRow', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe: () => describe.promise, mutate: () => Promise.resolve({ diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index e4e218fe86..b04c954d83 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -1,5 +1,7 @@ +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { PermissionPresetSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, } from '../src/client/settings-store.ts' @@ -14,6 +16,16 @@ const SCHEMA = { }, } +const schema = new SettingsSchemaService(new Context()) + +function resolveDefault(view: SettingsNamespaceView) { + return permissionDefaultOf(view, schema) +} + +function createController(api: ConstructorParameters[0]) { + return new PermissionPresetSettingsController(api, schema) +} + function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { return { ns: 'permission', @@ -32,7 +44,7 @@ function ok(value: T) { describe('permission settings store', () => { it('derives dynamic options and host labels from the descriptor schema', () => { - expect(permissionDefaultOf(view('read-only'))).toEqual({ + expect(resolveDefault(view('read-only'))).toEqual({ currentValue: 'read-only', options: [ { id: 'read-only', label: 'Read Only' }, @@ -46,7 +58,7 @@ describe('permission settings store', () => { 2: { type: 'object', dict: { defaultPreset: 1 } }, }, } - expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({ + expect(resolveDefault(view('read-only', 0, single))).toEqual({ currentValue: 'read-only', options: [{ id: 'read-only', label: 'Read Only' }], }) @@ -57,23 +69,23 @@ describe('permission settings store', () => { 2: { type: 'object', dict: { defaultPreset: 1 } }, }, } - expect(permissionDefaultOf(view('read-only', 0, undescribed)).options) + expect(resolveDefault(view('read-only', 0, undescribed)).options) .toEqual([{ id: 'read-only', label: 'Read Only' }]) }) it('rejects malformed values and dynamic enums at the wire boundary', () => { - expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) - expect(() => permissionDefaultOf(view('read-only', 0, { + expect(() => resolveDefault({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) + expect(() => resolveDefault(view('read-only', 0, { uid: 1, refs: { 1: { type: 'object', dict: {} } }, }))).toThrow(/no defaultPreset field/) - expect(() => permissionDefaultOf(view('read-only', 0, { + expect(() => resolveDefault(view('read-only', 0, { uid: 2, refs: { 1: { type: 'union' }, 2: { type: 'object', dict: { defaultPreset: 1 } }, }, }))).toThrow(/does not advertise/) - expect(() => permissionDefaultOf(view('read-only', 0, { + expect(() => resolveDefault(view('read-only', 0, { uid: 4, refs: { 1: { type: 'string' }, @@ -82,7 +94,7 @@ describe('permission settings store', () => { 4: { type: 'object', dict: { defaultPreset: 3 } }, }, }))).toThrow(/does not advertise/) - expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/) + expect(() => resolveDefault(view('missing'))).toThrow(/does not advertise/) }) it('loads and writes defaultPreset with optimistic concurrency', async () => { @@ -92,7 +104,7 @@ describe('permission settings store', () => { namespaces: [view('read-only', 4)], }))) const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe, mutate } as never, }) await controller.load() @@ -117,13 +129,13 @@ describe('permission settings store', () => { it('hides the row when the namespace is absent and contains write failures', async () => { const describe = vi.fn(() => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))) - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe, mutate: vi.fn() } as never, }) await controller.load() expect(controller.store.getSnapshot().status).toBe('unavailable') - const failing = new PermissionPresetSettingsController({ + const failing = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => Promise.resolve({ @@ -149,7 +161,7 @@ describe('permission settings store', () => { .mockImplementationOnce(() => first.promise) .mockResolvedValueOnce(ok({ writable: false, hasDocument: false, namespaces: [view('read-only', 2)] })) const mutate = vi.fn() - const controller = new PermissionPresetSettingsController({ + const controller = createController({ settings: { describe, mutate } as never, }) const stale = controller.load() @@ -164,7 +176,7 @@ describe('permission settings store', () => { await controller.select('workspace-write') expect(mutate).not.toHaveBeenCalled() - const rejected = new PermissionPresetSettingsController({ + const rejected = createController({ settings: { describe: () => Promise.resolve({ rpcId: 'test', @@ -177,7 +189,7 @@ describe('permission settings store', () => { await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) - const thrown = new PermissionPresetSettingsController({ + const thrown = createController({ settings: { // Promise consumers must contain unknown rejection values from a // transport implementation, including non-Error legacy clients. @@ -196,7 +208,7 @@ describe('permission settings store', () => { namespaces: SettingsNamespaceView[] }>>>() const describe = vi.fn(() => read.promise) - const idle = new PermissionPresetSettingsController({ settings: { describe, mutate: vi.fn() } as never }) + const idle = createController({ settings: { describe, mutate: vi.fn() } as never }) refreshPermissionIfLoaded(idle) expect(describe).not.toHaveBeenCalled() const loading = idle.load() @@ -209,7 +221,7 @@ describe('permission settings store', () => { writable: boolean namespaces: SettingsNamespaceView[] }>>>() - const disposedRead = new PermissionPresetSettingsController({ + const disposedRead = createController({ settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, }) const reading = disposedRead.load() @@ -224,7 +236,7 @@ describe('permission settings store', () => { hasDocument: false, namespaces: [view('read-only')], }))) - const active = new PermissionPresetSettingsController({ + const active = createController({ settings: { describe: activeDescribe, mutate: () => mutation.promise, @@ -240,7 +252,7 @@ describe('permission settings store', () => { expect(active.store.getSnapshot().status).toBe('saving') const rejectedMutation = Promise.withResolvers>>() - const disposedWrite = new PermissionPresetSettingsController({ + const disposedWrite = createController({ settings: { describe: () => Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] })), mutate: () => rejectedMutation.promise, diff --git a/packages/client/ui-permission-presets/tsconfig.json b/packages/client/ui-permission-presets/tsconfig.json index e614c95723..5c72a81455 100644 --- a/packages/client/ui-permission-presets/tsconfig.json +++ b/packages/client/ui-permission-presets/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../schema-form" - }, { "path": "../ui-commands" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index dd312defcf..82b04435ab 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -50,19 +50,15 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", - "@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-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 24e2112096..4ee5c5688e 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -101,6 +101,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): provider={row.entry.provider} displayName={row.entry.displayName} namespace={namespace} + schema={controller.schema} settingsPath={row.entry.settingsPath} api={api} t={t} diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 5fe5647b88..c5e72c8b44 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -63,7 +63,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'api' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'api' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -269,7 +269,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { // Hand-declared routes live in the pi-ai namespace, which is also the only // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. - const protocols = protocolChoices(state.namespaces.get('llm-pi-ai')) + const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), controller.schema) return (
@@ -297,6 +297,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { {renderProviderEditor({ target, namespace, + schema: controller.schema, api, t, readOnly: !state.writable, @@ -381,6 +382,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ? renderProviderEditor({ target, namespace, + schema: controller.schema, api, t, readOnly: !state.writable, @@ -419,6 +421,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { displayName={addTarget.displayName} hideTitle namespace={addNamespace} + schema={controller.schema} settingsPath={addTarget.settingsPath} api={api} t={t} diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 63d25b2eb6..76e6bc0ded 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -24,9 +24,7 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { CredentialView, IApiClient, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' -import { - deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, -} from '@deepseek-ai/dsh-client-schema-form' +import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' import { DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels, } from './DeepSeekModelsEditor.tsx' @@ -61,6 +59,8 @@ export interface ProviderEditorProps { declared?: boolean /** The owning namespace view (schema, layers, secrets). */ namespace: SettingsNamespaceView + /** Settings-owned synchronous schema and immutable path operations. */ + schema: SettingsSchemaService /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] /** Wire faces for writes and for interrogating a provider endpoint. */ @@ -86,8 +86,12 @@ export interface ProviderEditorProps { } /** A user-section subtree as a plain draft object (absent → empty). */ -function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record { - const subtree = getPath(namespace.user, path) +function draftAt( + schema: SettingsSchemaService, + namespace: SettingsNamespaceView, + path: readonly string[], +): Record { + const subtree = schema.getPath(namespace.user, path) if (typeof subtree !== 'object' || subtree === null || Array.isArray(subtree)) return {} return structuredClone(subtree) as Record } @@ -129,8 +133,13 @@ function layoutOf(ns: string): EditorLayout { } /** The credential reference this profile resolves keys through. */ -function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string { - const profile = getPath(namespace.value, path) +function refFor( + schema: SettingsSchemaService, + namespace: SettingsNamespaceView, + path: readonly string[], + provider: string, +): string { + const profile = schema.getPath(namespace.value, path) const named = typeof profile === 'object' && profile !== null ? (profile as { apiKeyEnv?: unknown }).apiKeyEnv : undefined @@ -143,8 +152,8 @@ function refFor(namespace: SettingsNamespaceView, path: readonly string[], provi * @returns the editor card. */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { - const { namespace, settingsPath, api, t } = props - const [draft, setDraft] = useState>(() => draftAt(namespace, settingsPath)) + const { namespace, schema, settingsPath, api, t } = props + const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) @@ -153,22 +162,22 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // derived fields in the draft prevents a pushed namespace refresh from // turning them into deletions when the following credential write is retried. const [committedOriginal, setCommittedOriginal] = useState( - () => getPath(namespace.user, settingsPath), + () => schema.getPath(namespace.user, settingsPath), ) const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision) - const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema]) - const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath]) - const fallback = getPath(namespace.value, settingsPath) + const root = useMemo(() => schema.rehydrate(namespace.schema), [namespace.schema, schema]) + const node = useMemo(() => schema.nodeAtPath(root, settingsPath), [root, schema, settingsPath]) + const fallback = schema.getPath(namespace.value, settingsPath) const disabled = props.readOnly || busy const layout = layoutOf(namespace.ns) - const keyRef = refFor(namespace, settingsPath, props.provider) + const keyRef = refFor(schema, namespace, settingsPath, props.provider) // The same schema read the create card makes, so the choices offered here // and there cannot drift apart: both come from the adapter's own `Config`. // Only the pi-ai layout has a per-route protocol for the read to find, and // it rehydrates the whole section schema, so the other layouts skip it. const protocols = useMemo( - () => layout === 'pi-ai' ? protocolChoices(namespace) : [], - [layout, namespace], + () => layout === 'pi-ai' ? protocolChoices(namespace, schema) : [], + [layout, namespace, schema], ) useEffect(() => { @@ -189,7 +198,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }, [api.credentials, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { - const value = getPath(source, [key]) + const value = schema.getPath(source, [key]) return typeof value === 'string' && value.trim().length > 0 ? value : undefined } const setField = (key: string, next: string | undefined): void => { @@ -198,12 +207,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // while the draft still carried the spaces into `settings.yaml`, where // both adapters would accept that non-empty string as a real value. const value = next === undefined || next.trim().length === 0 ? undefined : next - setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value)) + setDraft(current => value === undefined + ? schema.deletePath(current, [key]) + : schema.setPath(current, [key], value)) } // The model list is validated by the same per-row checker for both families, // so a bad row is named by its position rather than by a blanket message. - const modelFailure = validateDeepSeekModels(getPath(draft, ['models'])) + const modelFailure = validateDeepSeekModels(schema.getPath(draft, ['models'])) const keyFailure = apiKeyFailure(keyDraft) // What a probe or a write must carry: the typed key with paste whitespace // removed. A blank field yields an empty string, which both call sites read @@ -240,14 +251,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // about to store a key. Otherwise the provider keeps its native auth path. const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined && stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0 - ? setPath(draft, ['apiKeyEnv'], keyRef) + ? schema.setPath(draft, ['apiKeyEnv'], keyRef) : draft if (props.credentialOnly !== true) { // The same checker gates the submit button, so a card cannot reach this // with a bad row; it stays because the schema check below would refuse // the write with a message naming a path instead of the row, and because // nothing but this function decides what is written. - const failure = validateDeepSeekModels(getPath(next, ['models'])) + const failure = validateDeepSeekModels(schema.getPath(next, ['models'])) /* v8 ignore next 3 -- unreachable from the card: the same failure disables submit */ if (failure !== undefined) { return `${t('model')} ${String(failure.index + 1)}: ${t(failure.key)}` @@ -255,7 +266,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */ if (props.credentialOnly !== true && node !== undefined && settingsPath.length === 0) { - const sectionError = validateDraft(node, next) + const sectionError = schema.validate(node, next) if (sectionError !== undefined) return sectionError } const materializesNativeProfile = layout === 'pi-ai' @@ -274,7 +285,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? t('conflict') : response.result.error.message } - setCommittedOriginal(getPath(response.result.value.user, settingsPath)) + setCommittedOriginal(schema.getPath(response.result.value.user, settingsPath)) setExpectedRevision(response.result.value.revision) setDraft(next) } @@ -322,8 +333,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * moment reset drops it, leaving the rows unchanged until a reload. */ const inheritedModels = (): unknown => { - const pinned = getPath(namespace.base, [...settingsPath, 'models']) - return pinned ?? nodeAtPath(root, [...settingsPath, 'models'])?.meta.default + const pinned = schema.getPath(namespace.base, [...settingsPath, 'models']) + return pinned ?? schema.nodeAtPath(root, [...settingsPath, 'models'])?.meta.default } /** @@ -336,11 +347,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // A whole-section `llm-deepseek` profile is a composition fact with no // per-route identity for its schema to carry, hence the family test. const ownsIdentity = family === 'pi-ai' && props.declared === true - const customModels = getPath(draft, ['models']) - const modelsOverridden = hasPath(draft, ['models']) + const customModels = schema.getPath(draft, ['models']) + const modelsOverridden = schema.hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) - const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) - const defaultMaxTokens = getPath(fallback, ['maxTokens']) + const defaultContextWindow = schema.getPath(fallback, ['defaultContextWindow']) + const defaultMaxTokens = schema.getPath(fallback, ['maxTokens']) const keyPlaceholder = keyLocked ? t('keyEnvLocked') : keyState?.configured === true && props.credentialRequired !== true @@ -353,9 +364,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { t, disabled, onChange: (next: Record[]) => { - setDraft(current => setPath(current, ['models'], next)) + setDraft(current => schema.setPath(current, ['models'], next)) }, - onReset: () => { setDraft(current => deletePath(current, ['models'])) }, + onReset: () => { setDraft(current => schema.deletePath(current, ['models'])) }, } return ( <> @@ -397,7 +408,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // the answer the route id. Reading the effective value // instead would echo the stored override back as the // thing clearing restores. - placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName') + placeholder={stringAt(schema.getPath(namespace.base, settingsPath), 'displayName') ?? props.provider} aria-label={t('customDisplayName')} disabled={disabled} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index dc7f32e370..d4bb0dfb03 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -56,7 +56,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration depends on each slot through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsSchema'] /** * Register the Models section once the `settings.section` declaration is on @@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const connection = ctx.get('connection') as ConnectionHandle - const controller = new ModelsSettingsStore(connection.api) + const controller = new ModelsSettingsStore(connection.api, ctx.settingsSchema) const useSnapshot = bindSnapshotSelector(controller.store) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 4389b9a6cb..e970602815 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -11,7 +11,7 @@ import type { } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { getPath, hasPath, nodeAtPath, rehydrateSchema } from '@deepseek-ai/dsh-client-schema-form' +import type { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/client' /** * Any route key walks a dict schema to the same profile node, so the lookup @@ -78,18 +78,25 @@ export function deriveKeyRef(provider: string): string { * @param namespace - the namespace view whose schema declares the profile shape. * @returns the protocol identifiers, or an empty list when the schema has none. */ -export function protocolChoices(namespace: SettingsNamespaceView | undefined): string[] { +export function protocolChoices( + namespace: SettingsNamespaceView | undefined, + schema: SettingsSchemaService, +): string[] { if (namespace === undefined) return [] - const node = nodeAtPath(rehydrateSchema(namespace.schema), ['providers', PROBE_ROUTE, 'api']) + const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), ['providers', PROBE_ROUTE, 'api']) const list = (node as { type?: string; list?: readonly { value?: unknown }[] } | undefined) if (list?.type !== 'union' || list.list === undefined) return [] return list.list.map(entry => entry.value).filter((value): value is string => typeof value === 'string') } /** The credential reference a resolved profile names (its `apiKeyEnv` field). */ -function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined { +function apiKeyEnvOf( + namespace: SettingsNamespaceView | undefined, + path: readonly string[], + schema: SettingsSchemaService, +): string | undefined { if (namespace === undefined) return undefined - const profile = getPath(namespace.value, path) + const profile = schema.getPath(namespace.value, path) if (typeof profile !== 'object' || profile === null) return undefined const ref = (profile as { apiKeyEnv?: unknown }).apiKeyEnv return typeof ref === 'string' && ref.length > 0 ? ref : undefined @@ -108,7 +115,10 @@ export class ModelsSettingsStore { /** * @param api - the wire face (settings/credentials/llm domains). */ - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + readonly schema: SettingsSchemaService, + ) {} /** * Refresh the whole page snapshot: directory and namespaces in parallel, @@ -144,16 +154,16 @@ export class ModelsSettingsStore { const rows: ProviderRow[] = providers.map((entry) => { const namespace = namespaces.get(entry.settingsNs) const configured = namespace !== undefined - && (entry.settingsPath.length === 0 || getPath(namespace.value, entry.settingsPath) !== undefined) + && (entry.settingsPath.length === 0 || this.schema.getPath(namespace.value, entry.settingsPath) !== undefined) const removable = namespace !== undefined && entry.settingsPath.length > 0 - && hasPath(namespace.user, entry.settingsPath) - && !hasPath(namespace.base, entry.settingsPath) + && this.schema.hasPath(namespace.user, entry.settingsPath) + && !this.schema.hasPath(namespace.base, entry.settingsPath) return { entry, configured, removable, - apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath), + apiKeyEnv: apiKeyEnvOf(namespace, entry.settingsPath, this.schema), credential: undefined, } }) diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 66ba8d33a4..4a90b5f353 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -17,6 +17,7 @@ import { apiKeyFailure } from '../src/client/apiKey.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' +import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -185,7 +186,7 @@ type WireFace = ConstructorParameters[0] async function mountFace(scripted: ReturnType) { const { face, update, replace, mutate, set, unset } = scripted - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() const injected: ModelsSectionInjected = { controller, @@ -265,7 +266,7 @@ describe('ModelsSection', () => { face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() render( { face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() cleanup() render( { displayName="DeepSeek" hideTitle namespace={wireNamespaces()[0]!} + schema={settingsSchema} settingsPath={[]} api={face as never} t={t} @@ -621,6 +623,7 @@ describe('ModelsSection', () => { provider="deepseek-official" displayName="DeepSeek" namespace={overridden} + schema={settingsSchema} settingsPath={[]} api={face as never} t={t} @@ -850,6 +853,7 @@ describe('ModelsSection', () => { provider="deepseek-official" displayName="DeepSeek" namespace={bare} + schema={settingsSchema} settingsPath={[]} api={face as never} t={t} @@ -1007,7 +1011,7 @@ describe('ModelsSection', () => { const unhandled = vi.fn() process.on('unhandledRejection', unhandled) try { - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() render( { it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never - const controller = new ModelsSettingsStore(face.face as unknown as WireFace) + const controller = new ModelsSettingsStore(face.face as unknown as WireFace, settingsSchema) await controller.load() render( { hasDocument: false, namespaces: wireNamespaces(), }))) - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) await controller.load() cleanup() render( { it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() - const controller = new ModelsSettingsStore(face as unknown as WireFace) + const controller = new ModelsSettingsStore(face as unknown as WireFace, settingsSchema) render( { cleanup() @@ -124,7 +125,7 @@ function harness(options: { set, }, } - const controller = new ModelsSettingsStore(face as never) + const controller = new ModelsSettingsStore(face as never, settingsSchema) const openSection = vi.fn() const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 246c7d64b1..6681b9e4d7 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -11,6 +11,7 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' +import { settingsSchema } from './settings-schema.client.ts' afterEach(cleanup) @@ -139,7 +140,7 @@ function firstMutate(mutate: ReturnType): MutateCall { async function mountSection(options: Parameters[0] = {}) { const scripted = scriptedFace(options) - const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema) await controller.load() const injected: ModelsSectionInjected = { controller, @@ -183,10 +184,10 @@ function within_(scope: HTMLElement, label: string): HTMLElement { describe('protocolChoices', () => { it('reads the protocols out of the namespace schema and nothing else', async () => { const { namespace } = scriptedFace() - expect(protocolChoices(namespace)).toEqual(PROTOCOLS) - expect(protocolChoices(undefined)).toEqual([]) + expect(protocolChoices(namespace, settingsSchema)).toEqual(PROTOCOLS) + expect(protocolChoices(undefined, settingsSchema)).toEqual([]) const plain = { ...namespace, schema: JSON.parse(JSON.stringify(Schema.object({}).toJSON())) as unknown } - expect(protocolChoices(plain)).toEqual([]) + expect(protocolChoices(plain, settingsSchema)).toEqual([]) await Promise.resolve() }) }) @@ -637,7 +638,7 @@ describe('provider rows', () => { active: true, }], }))) as never - const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace) + const controller = new ModelsSettingsStore(scripted.face as unknown as WireFace, settingsSchema) await controller.load() render((value: T): RpcResponse { @@ -72,7 +73,7 @@ function api(overrides: { describe('ModelsSettingsStore', () => { it('joins rows with configured, removable, and credential state', async () => { const { face, seenRefs } = api() - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -100,7 +101,7 @@ describe('ModelsSettingsStore', () => { it('degrades the credential badge, not the page, when the credential domain fails', async () => { const { face } = api({ describeCredentials: () => Promise.resolve(fail('no provider')) }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() const state = store.store.getSnapshot() expect(state.status).toBe('ready') @@ -112,7 +113,7 @@ describe('ModelsSettingsStore', () => { const { face } = api({ describeCredentials: () => Promise.reject(new Error('credential transport down')), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await expect(store.load()).resolves.toBeUndefined() expect(store.store.getSnapshot()).toMatchObject({ status: 'ready', @@ -125,18 +126,18 @@ describe('ModelsSettingsStore', () => { // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario describeCredentials: () => Promise.reject('credential transport refusal'), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await expect(store.load()).resolves.toBeUndefined() expect(store.store.getSnapshot().credentialError).toBe('credential transport refusal') }) it('surfaces a directory failure and keeps the last good rows', async () => { const { face } = api() - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(store.store.getSnapshot().rows).toHaveLength(4) const broken = api({ providers: () => Promise.resolve(fail('directory down')) }) - const failing = new ModelsSettingsStore(broken.face) + const failing = new ModelsSettingsStore(broken.face, settingsSchema) await failing.load() expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'directory down' }) // The first store's snapshot is untouched by the second's failure. @@ -157,7 +158,7 @@ describe('ModelsSettingsStore', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) const first = store.load() const second = store.load() release?.() @@ -187,7 +188,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() const state = store.store.getSnapshot() expect(state.rows[0]).toMatchObject({ configured: true, removable: false }) @@ -207,7 +208,7 @@ describe('edge joins', () => { ] as never, })), }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(seenRefs).toEqual([]) expect(store.store.getSnapshot().status).toBe('ready') @@ -215,7 +216,7 @@ describe('edge joins', () => { it('surfaces a settings describe failure', async () => { const { face } = api({ describeSettings: () => Promise.resolve(fail('settings down')) }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'settings down' }) }) @@ -224,7 +225,7 @@ describe('edge joins', () => { // The wire can surface non-Error throwables; the store must stringify them. // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario const { face } = api({ providers: () => Promise.reject('plain refusal') }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) await store.load() expect(store.store.getSnapshot()).toMatchObject({ status: 'error', error: 'plain refusal' }) }) @@ -243,7 +244,7 @@ describe('edge joins', () => { return ok({ providers: DIRECTORY }) }, }) - const store = new ModelsSettingsStore(face) + const store = new ModelsSettingsStore(face, settingsSchema) const first = store.load() const second = store.load() await second diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json index a85bfbcc90..2dc6cc2a32 100644 --- a/packages/client/ui-settings-models/tsconfig.json +++ b/packages/client/ui-settings-models/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../runtime" }, - { - "path": "../schema-form" - }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 2934097b94..56bcdb0d98 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, @@ -52,7 +52,7 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials }, }, } as never) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings } } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d86f5f86f2..1d3669d008 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -44,28 +44,28 @@ "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@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:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-connection": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 2ace9e56b1..6e8310242e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -9,6 +9,7 @@ * through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { SettingsSchemaService } from './schema.ts' import { SettingsScopeBinder } from './settings-scope.ts' export type { @@ -16,6 +17,8 @@ export type { SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeBinder } from './settings-scope.ts' +export { SettingsSchemaService } from './schema.ts' +export type { SchemaNode } from './schema.ts' /** * Required services: none. The transport is resolved per caller through @@ -31,5 +34,6 @@ export const inject = [] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - new SettingsScopeBinder(ctx) + const schema = new SettingsSchemaService(ctx) + new SettingsScopeBinder(ctx, schema) } diff --git a/packages/client/ui-settings/src/client/schema.ts b/packages/client/ui-settings/src/client/schema.ts new file mode 100644 index 0000000000..4d922bd4e7 --- /dev/null +++ b/packages/client/ui-settings/src/client/schema.ts @@ -0,0 +1,121 @@ +/** Synchronous schema introspection and immutable settings-draft edits. */ +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' + +/** Live schemastery node used for settings introspection and validation. */ +export type SchemaNode = Schema + +function cloneContainer(container: unknown, key: string): Record | unknown[] { + if (Array.isArray(container)) return [...container as unknown[]] + if (typeof container === 'object' && container !== null) return { ...container as Record } + return /^\d+$/.test(key) ? [] : {} +} + +function cloneSpine(root: Record, path: readonly string[]): { + result: Record + parent: Record | unknown[] + leaf: string +} { + const result = { ...root } + let target: Record | unknown[] = result + for (let index = 0; index < path.length - 1; index++) { + const key = path[index] as string + const child = cloneContainer( + Array.isArray(target) ? target[Number(key)] : target[key], + path[index + 1] as string, + ) + if (Array.isArray(target)) target[Number(key)] = child + else target[key] = child + target = child + } + return { result, parent: target, leaf: path[path.length - 1] as string } +} + +/** + * Settings-owned synchronous schema service. Dynamic client plugins receive + * this Cordis entity instead of importing executable helpers from one another. + */ +export class SettingsSchemaService extends Service { + /** @param ctx - providing ui-settings context. */ + constructor(ctx: Context) { + super(ctx, 'settingsSchema') + } + + /** Rehydrate one serialized `schema.toJSON()` envelope. */ + rehydrate(serialized: unknown): SchemaNode { + return new Schema(serialized as Schema) + } + + /** Return a validation failure message, or `undefined` for a valid draft. */ + validate(schema: SchemaNode, draft: unknown): string | undefined { + try { + ;(schema as unknown as (value: unknown) => unknown)(draft) + return undefined + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + + /** Resolve an object, dict, or array schema node at a settings path. */ + nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined { + let node: SchemaNode | undefined = root + for (const key of path) { + if (node === undefined) return undefined + if (node.type === 'object') node = (node.dict as Record | undefined)?.[key] + else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined + else return undefined + } + return node + } + + /** Read a nested value by a string-key or array-index path. */ + getPath(value: unknown, path: readonly string[]): unknown { + let current: unknown = value + for (const key of path) { + if (Array.isArray(current)) { + current = current[Number(key)] + continue + } + if (typeof current !== 'object' || current === null) return undefined + current = (current as Record)[key] + } + return current + } + + /** Report whether the final path key exists independently of its value. */ + hasPath(value: unknown, path: readonly string[]): boolean { + if (path.length === 0) return value !== undefined + const parent = this.getPath(value, path.slice(0, -1)) + const key = path[path.length - 1] as string + if (Array.isArray(parent)) return Number(key) < parent.length + if (typeof parent !== 'object' || parent === null) return false + return key in parent + } + + /** Immutably set a nested value, materializing missing containers. */ + setPath(root: Record, path: readonly string[], value: unknown): Record { + if (path.length === 0) throw new Error('ui-settings: setPath needs a non-empty path') + const { result, parent, leaf } = cloneSpine(root, path) + if (Array.isArray(parent)) parent[Number(leaf)] = value + else parent[leaf] = value + return result + } + + /** Immutably remove a nested key, preserving an unchanged missing root. */ + deletePath(root: Record, path: readonly string[]): Record { + if (path.length === 0) throw new Error('ui-settings: deletePath needs a non-empty path') + if (!this.hasPath(root, path)) return root + const { result, parent, leaf } = cloneSpine(root, path) + if (Array.isArray(parent)) parent.splice(Number(leaf), 1) + else Reflect.deleteProperty(parent, leaf) + return result + } +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Settings-owned synchronous schema and immutable path operations. */ + settingsSchema: SettingsSchemaService + } +} diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 4668c4924b..c3b941663e 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -10,7 +10,6 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' -import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, type SettingsScopeSpec, type SnapshotStore, @@ -31,6 +30,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import type { SettingsSchemaService } from './schema.ts' type SettingsFace = Pick /** @@ -55,6 +55,7 @@ export class SettingsScopeController implements SettingsScope { private readonly api: SettingsFace, private readonly spec: SettingsScopeSpec, private readonly persistence: 'host' | 'memory' = 'host', + private readonly schema?: SettingsSchemaService, ) { this.store = createSnapshotStore>({ status: persistence === 'host' ? 'loading' : 'unavailable', @@ -201,7 +202,8 @@ export class SettingsScopeController implements SettingsScope { if (typeof view.value !== 'object' || view.value === null || Array.isArray(view.value)) return undefined let failure: string | undefined try { - failure = validateDraft(rehydrateSchema(view.schema), view.value) + if (this.schema === undefined) throw new Error('ui-settings: schema service unavailable') + failure = this.schema.validate(this.schema.rehydrate(view.schema), view.value) } catch (_malformedSchemaEnvelope) { // A schema envelope this client cannot rehydrate vouches for no section; // the value is treated exactly like a schema-invalid one. @@ -228,7 +230,7 @@ export class SettingsScopeBinder extends Service { /** * @param ctx - the providing plugin's context. */ - constructor(ctx: Context) { + constructor(ctx: Context, private readonly schema: SettingsSchemaService) { super(ctx, 'settingsScope') } @@ -249,6 +251,7 @@ export class SettingsScopeBinder extends Service { connection.api, spec, connection.isLoopback ? 'host' : 'memory', + this.schema, ) ctx.effect(() => { const refresh = (namespace?: string): void => { diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 429002028a..627034f217 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +import { SettingsSchemaService } from '../src/client/schema.ts' import { SettingsScopeController, SettingsScopeBinder } from '../src/client/settings-scope.ts' interface UiTestSettings { @@ -379,7 +380,7 @@ describe('SettingsScopeBinder.bind', () => { } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { @@ -411,7 +412,7 @@ describe('SettingsScopeBinder.bind', () => { } as never) let scope!: SettingsScope new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() const fiber = ctx.plugin({ inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index 5ef3ae74a0..fa84d80082 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -18,7 +18,7 @@ "path": "../runtime" }, { - "path": "../schema-form" + "path": "../../../vendor/schemastery" }, { "path": "../../api/remotes/tsconfig.client.json" diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..67a10937ce 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService, SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' @@ -56,7 +56,7 @@ async function bench(isLoopback = true) { ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate, setHostPreference: (next: string) => { preference = next }, From 3e4ad10d0527440188cc02a5af4c4e08a2ef680d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:48:05 +0800 Subject: [PATCH 57/80] refactor(client): make attachment UI a client plugin --- packages/client/ui-attachment/package.json | 39 ++++-- .../src/client/ComposerAttachments.module.css | 4 + .../src/client/ComposerAttachments.tsx | 112 ++++++++++++++++ .../src/client/MessageImages.tsx | 8 ++ .../client/ui-attachment/src/client/index.ts | 20 +++ .../client/ui-attachment/src/client/labels.ts | 45 +++++++ packages/client/ui-attachment/src/index.ts | 18 +-- .../client/ui-attachment/src/invariant.ts | 5 +- packages/client/ui-attachment/tsconfig.json | 9 ++ .../client/ui-attachment/tsdown.config.ts | 39 +----- packages/client/ui-conversation/package.json | 15 ++- .../ui-conversation/src/client/apply.ts | 2 + .../src/client/chat/AssistantMarkdown.tsx | 22 +-- .../src/client/chat/AssistantNodeView.tsx | 4 +- .../src/client/chat/ChatNodeSeat.tsx | 8 +- .../src/client/chat/ChatView.tsx | 17 ++- .../src/client/chat/MessageItem.tsx | 21 ++- .../src/client/contract/slots.ts | 53 +++++++- .../src/client/image-labels.ts | 65 +-------- .../ui-conversation/src/client/index.ts | 4 +- .../src/client/skeleton/InputBar.module.css | 9 -- .../src/client/skeleton/InputBar.tsx | 125 ++---------------- .../tests/chat-branch-tails.client.spec.tsx | 10 +- .../tests/coverage-tails.client.spec.tsx | 15 ++- .../tests/gate-branch-tails.client.spec.tsx | 9 +- .../tests/image-labels.client.spec.tsx | 74 +++++------ .../tests/reasoning-row.client.spec.tsx | 8 +- packages/client/ui-conversation/tsconfig.json | 3 - 28 files changed, 426 insertions(+), 337 deletions(-) create mode 100644 packages/client/ui-attachment/src/client/ComposerAttachments.module.css create mode 100644 packages/client/ui-attachment/src/client/ComposerAttachments.tsx create mode 100644 packages/client/ui-attachment/src/client/MessageImages.tsx create mode 100644 packages/client/ui-attachment/src/client/index.ts create mode 100644 packages/client/ui-attachment/src/client/labels.ts diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 5a1b81e978..8e22c28e57 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", - "description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)", + "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", "version": "0.1.0-rc.7", "publishConfig": { "access": "public" @@ -22,30 +22,53 @@ "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" }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, "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" + "clsx": "^2.0.0" }, "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", + "@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:^", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.d.ts" ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" } } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css new file mode 100644 index 0000000000..770a64cef5 --- /dev/null +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -0,0 +1,4 @@ +.rail { + min-width: 0; + padding: 4px 12px 0; +} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx new file mode 100644 index 0000000000..0525c74ce2 --- /dev/null +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { + ComposerAttachment, ComposerAttachmentsProps, +} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { AttachmentRail } from '../AttachmentRail.tsx' +import type { AttachmentRailItem } from '../AttachmentRail.tsx' +import { DropOverlay } from '../DropOverlay.tsx' +import { ImageLightbox } from '../ImageLightbox.tsx' +import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts' +import css from './ComposerAttachments.module.css' + +/** Rail item retaining its browser-owned attachment for callbacks. */ +interface ComposerRailItem extends AttachmentRailItem { + attachment: ComposerAttachment +} + +/** Draft-image rail, document drop target, and original-image preview slot entry. */ +export function ComposerAttachments({ + attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t, +}: ComposerAttachmentsProps) { + const [preview, setPreview] = useState(null) + const [dragActive, setDragActive] = useState(false) + const dragDepth = useRef(0) + const closePreview = useCallback(() => { setPreview(null) }, []) + + useEffect(() => { + if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) + }, [attachments, preview]) + + useEffect(() => { + const hasFiles = (event: globalThis.DragEvent): boolean => + event.dataTransfer?.types.includes('Files') ?? false + const reset = (): void => { + dragDepth.current = 0 + setDragActive(false) + } + const onDragEnter = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + event.preventDefault() + dragDepth.current += 1 + setDragActive(true) + } + const onDragOver = (event: globalThis.DragEvent): void => { + if (!hasFiles(event) || event.dataTransfer === null) return + event.preventDefault() + event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none' + } + const onDragLeave = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setDragActive(false) + const leftViewport = event.clientX <= 0 || event.clientY <= 0 + || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight + if ((event.target === document.documentElement || event.target === document.body) && leftViewport) reset() + } + const onDrop = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + event.preventDefault() + reset() + if (canAcceptDrop) onAddImages([...(event.dataTransfer?.files ?? [])]) + } + document.addEventListener('dragenter', onDragEnter) + document.addEventListener('dragover', onDragOver) + document.addEventListener('dragleave', onDragLeave) + document.addEventListener('drop', onDrop) + window.addEventListener('dragend', reset) + return () => { + document.removeEventListener('dragenter', onDragEnter) + document.removeEventListener('dragover', onDragOver) + document.removeEventListener('dragleave', onDragLeave) + document.removeEventListener('drop', onDrop) + window.removeEventListener('dragend', reset) + } + }, [canAcceptDrop, onAddImages]) + + const railItems = useMemo(() => attachments.map(attachment => ({ + id: attachment.id, + previewUrl: attachment.previewUrl, + alt: attachment.file.name || t('image.pending'), + removeLabel: t('image.remove', { name: attachment.file.name }), + attachment, + })), [attachments, t]) + + return ( + <> + {dragActive && ( + + )} + {railItems.length > 0 && ( +
+ { setPreview(item.attachment) }} + onRemove={(item) => { onRemoveImage(item.attachment.id) }} + /> +
+ )} + {preview !== null && ( + + )} + + ) +} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx new file mode 100644 index 0000000000..0d0dac02f8 --- /dev/null +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -0,0 +1,8 @@ +import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ImageGallery } from '../MessageImage.tsx' +import { messageImageLabels } from './labels.ts' + +/** Historical message-image slot entry. */ +export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) { + return +} diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts new file mode 100644 index 0000000000..616e9c7292 --- /dev/null +++ b/packages/client/ui-attachment/src/client/index.ts @@ -0,0 +1,20 @@ +/** Browser attachment plugin: fills conversation's composer and message-image slots. */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ComposerAttachments } from './ComposerAttachments.tsx' +import { MessageImages } from './MessageImages.tsx' + +/** Slot registry required by this presentation plugin. */ +export const inject = ['slots'] + +/** Register attachment presentation without exporting React components as package values. */ +export function apply(ctx: ClientContext): void { + ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ + name: 'conversation.input.attachments', + locale: 'conversation', + }, ComposerAttachments)) + ctx.slots.inject('conversation.message.images', () => ctx.slots.register({ + name: 'conversation.message.images', + locale: 'conversation', + }, MessageImages)) +} diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts new file mode 100644 index 0000000000..cc83d5791b --- /dev/null +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -0,0 +1,45 @@ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { AttachmentRailLabels } from '../AttachmentRail.tsx' +import type { DropOverlayLabels } from '../DropOverlay.tsx' +import type { ImageLightboxLabels } from '../ImageLightbox.tsx' +import type { MessageImageLabels } from '../MessageImage.tsx' + +/** Resolve original-image lightbox strings from the conversation namespace. */ +export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLabels { + return { dialog: t('image.preview'), close: t('image.closePreview') } +} + +/** Resolve historical message-image strings from the conversation namespace. */ +export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImageLabels { + return { + image: t('image.label'), + open: t('image.openOriginal'), + openNamed: label => t('image.openOriginalLabel', { label }), + loading: t('image.loading'), + loadFailed: t('image.loadFailed'), + lightbox: lightboxLabels(t), + } +} + +/** Resolve the document-level drop invitation and its optional limits line. */ +export function dropOverlayLabels( + t: TranslateNS<'conversation'>, + accepting: boolean, + limits?: { readonly count: number; readonly size: string }, +): DropOverlayLabels { + if (!accepting) return { title: t('image.dropBlocked') } + return { + title: t('image.dropTitle'), + desc: limits === undefined ? undefined : t('image.dropDesc', limits), + } +} + +/** Resolve draft-image rail strings from the conversation namespace. */ +export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels { + return { + group: t('image.pending'), + open: t('image.openOriginal'), + scrollLeft: t('image.scrollLeft'), + scrollRight: t('image.scrollRight'), + } +} diff --git a/packages/client/ui-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts index bef6c900a6..4bb65a79cc 100644 --- a/packages/client/ui-attachment/src/index.ts +++ b/packages/client/ui-attachment/src/index.ts @@ -1,16 +1,4 @@ -/** - * Pure React attachment atoms (zero cordis): the composer draft-image rail, - * the chat-history image gallery, the original-image lightbox, and the - * full-page drop overlay. Owners resolve every string through their own - * locale namespace and pass it down; nothing here reads application state. - * @module @deepseek-ai/dsh-client-ui-attachment - */ +/** Host half of the browser-only attachment presentation plugin. */ -export { AttachmentRail } from './AttachmentRail.tsx' -export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx' -export { DropOverlay } from './DropOverlay.tsx' -export type { DropOverlayLabels } from './DropOverlay.tsx' -export { ImageLightbox } from './ImageLightbox.tsx' -export type { ImageLightboxLabels } from './ImageLightbox.tsx' -export { ImageGallery, MessageImage } from './MessageImage.tsx' -export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx' +/** No host-side behavior; the client half registers the React slot entries. */ +export function apply(): void {} diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts index 47d18f97b8..5358704929 100644 --- a/packages/client/ui-attachment/src/invariant.ts +++ b/packages/client/ui-attachment/src/invariant.ts @@ -15,9 +15,8 @@ export const name = 'client-ui-attachment-invariant' export const inject = ['invariants'] /** - * No runtime invariant: pure props-in React atoms with no Cordis API — - * no events, no services, no mutable cross-plugin state; rendering contracts - * are asserted directly by this package's component specs. + * No runtime invariant: the package contributes only effect-owned slot entries; + * the slot registry owns their lifecycle and validates their declarations. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index c9ccd04a95..2f5cd3a73e 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -14,6 +14,15 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, { "path": "../ui-primitives" } diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index d8c37d8a2c..e70803de17 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,35 +1,6 @@ -import { clientOnly } from '../tsdown.client.ts' +import { clientBundle } from '../tsdown.client.ts' -// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only -// the package differs). On a third atoms package, extract a shared css-stub -// client-library preset in packages/client/tsdown.client.ts instead of a -// fourth copy. -/** - * ui-attachment is browser-only, but its lib bundle IS imported under plain - * Node because the web shell is a lib (dsh-client-web's lib chain reaches - * this package). CSS imports are therefore stubbed to empty modules instead - * of externalized — the hashed class maps only matter in bundler contexts - * (loader module table / vite source paths), which compile src directly and - * never read lib. - */ -export default clientOnly([{ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'neutral', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, - plugins: [{ - name: 'dsh-css-stub', - resolveId(source: string) { - if (!source.endsWith('.css')) return null - return `\0dsh-css-stub:${source}.mjs` - }, - load(id: string) { - if (!id.startsWith('\0dsh-css-stub:')) return null - return 'export default {};' - }, - }], -}]) +export default clientBundle( + '@deepseek-ai/dsh-client-ui-attachment', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 12e5c7f6c2..8fd8c44d4c 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -48,7 +48,6 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", "@deepseek-ai/schemastery": "workspace:^" }, @@ -61,11 +60,8 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -73,7 +69,12 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "react": "^18.2.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -86,7 +87,6 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", @@ -104,7 +104,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", + "@deepseek-ai/dsh-settings": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f57caea9e5..463c481eef 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -282,6 +282,7 @@ export function apply(ctx: Context): void { // access control, model right); empty until their owning plugins // register. children: { + 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, }, @@ -381,6 +382,7 @@ export function apply(ctx: Context): void { locale: NS, children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT }, + 'conversation.message.images': { kind: 'single', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bb766da778..c758827317 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,14 +9,12 @@ // their branch action is enabled only when the node is also the completed // turn's transcript tail. Think / tool-head-only nodes stay chrome-free. -import { memo, useMemo } from 'react' +import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' -import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { messageImageLabels } from '../image-labels.ts' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -25,8 +23,8 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined - /** Session-authorized durable image loader. */ - loadImage?: ImageLoader + /** Render consecutive image blocks through the attachment slot. */ + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -35,9 +33,8 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, loadImage, mentions, t, + blocks, streaming, interrupted, renderMessageImages, mentions, t, }: AssistantMarkdownProps) { - const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) @@ -82,7 +79,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ group.push(next) i += 1 } - rendered.push() + rendered.push( + + {renderMessageImages({ + images: group.map(({ attachment }) => ({ attachment })), + align: 'start', + })} + , + ) break } // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx index 72e8a6ae28..850036f0cd 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, openFile, loadImage, fileMentions, t, + node, useTurnData, openFile, renderMessageImages, fileMentions, t, }: ChatNodeViewProps<'assistant-step'>) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -25,7 +25,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ blocks={data.blocks} streaming={data.status === 'running'} interrupted={data.status === 'interrupted'} - loadImage={loadImage} + renderMessageImages={renderMessageImages} mentions={mentions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx index f3343a183f..bc9c96fd41 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -18,7 +18,7 @@ type RoutedChatNodeOwner = { /** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - loadImage, fileMentions, useSession, renderSlot, t, + renderMessageImages, fileMentions, useSession, renderSlot, t, }: ChatNodeSeatProps) { const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) const routedNode = node as ChatNode | undefined @@ -30,9 +30,11 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ openFile, inspectCall, forkAt, - loadImage, + renderMessageImages, fileMentions, - }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions]) + }, [ + node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions, + ]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 58e63b312f..4d9df510e1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -12,10 +12,10 @@ // ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool // lifecycle updates replace only their own row without remounting it. -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' @@ -164,6 +164,10 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const renderMessageImages = useCallback( + owner => renderSlot('conversation.message.images', { ...owner, loadImage }), + [loadImage, renderSlot], + ) const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline]) const listRef = useRef(null) @@ -389,7 +393,7 @@ export function ChatView({ openFile={openFile} inspectCall={inspectCall} forkAt={forkAt} - loadImage={loadImage} + renderMessageImages={renderMessageImages} fileMentions={fileMentions} renderSlot={renderSlot} t={t} @@ -402,7 +406,12 @@ export function ChatView({ wait, tool execution, streaming) so it never flickers per step. */} {running && } {pendingSteering.map(item => ( - + ))}
{!atBottom && ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 00b5110d68..ecb0d11caa 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -9,9 +9,7 @@ import type { ModelRetryNode, TurnErrorNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' -import { messageImageLabels } from '../image-labels.ts' +import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' @@ -177,10 +175,10 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, imageLoader, actions, pending = false, t, + content, renderMessageImages, actions, pending = false, t, }: { content: readonly unknown[] - imageLoader: ImageLoader + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ @@ -193,7 +191,7 @@ function UserStyleBubble({ return (
- + {renderMessageImages({ images, align: 'end' })} {showBubble &&
{projectUserText(text)} {rest.map((block, i) => )} @@ -210,16 +208,15 @@ function UserStyleBubble({ * @param props - Pending message content and conversation translator. * @returns the pending steering bubble. */ -export function PendingSteeringBubble({ content, loadImage, t }: { +export function PendingSteeringBubble({ content, renderMessageImages, t }: { content: readonly unknown[] - loadImage?: ImageLoader + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] t: ChatViewSlotProps['t'] }): ReactNode { - const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) return ( ( @@ -236,13 +233,13 @@ export function PendingSteeringBubble({ content, loadImage, t }: { /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ - node, loadImage, t, + node, renderMessageImages, t, }: ChatNodeViewProps<'user' | 'steering'>) { const data = node.data return ( ( void + /** Remove one draft image through the conversation service. */ + onRemoveImage: (id: DraftAttachmentId) => void + /** Display-ready limits for the drop invitation. */ + dropLimits?: { readonly count: number; readonly size: string } | undefined +} + +/** Historical image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + /** Consecutive image blocks rendered as one gallery. */ + images: readonly { readonly attachment: ImageAttachmentRef }[] + /** Session-authorized durable image loader. */ + loadImage: (attachment: ImageAttachmentRef) => Promise + /** Message-side alignment. */ + align: 'start' | 'end' +} + +/** Slot-backed renderer used by chat nodes without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** @@ -83,6 +110,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { hookContext: string inject: ChatNodeTurnDataInjected } + /** Optional renderer for one consecutive group of durable message images. */ + 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } /** * The chat view's per-command row hole: keyed dispatch on the command * name (`command/run.name`; a run-less cross-window node has none and @@ -199,6 +228,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * command face through its own inject. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } + /** Optional draft-image rail, drop target, and preview surface inside the composer. */ + 'conversation.input.attachments': { + kind: 'single' + scope: 'session-maybe' + owner: ComposerAttachmentsOwnerProps + } /** * The named plan-status seat in the composer tool row, immediately right * of the access-mode control — one occupant, so taking it means rendering @@ -361,8 +396,8 @@ export interface ChatNodeOwnerProps { openFile: (path: string) => void inspectCall: (callId: CallId) => void forkAt: (seq: number) => void - /** Resolve a session-authorized historical image for inline display. */ - loadImage: (attachment: ImageAttachmentRef) => Promise + /** Render a historical image group through the attachment slot. */ + renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } @@ -544,7 +579,9 @@ export interface InputControlOwnerProps { /** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> - & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> + & PropsRenderSlots< + 'conversation.input.attachments' | 'conversation.input.plan' | 'conversation.input.model' + > & InjectFace & PropsLocale<'conversation'> @@ -709,9 +746,17 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'> + PropsRuntime<'conversation.view'> + & PropsRenderSlots<'conversation.chat.node' | 'conversation.message.images'> & PropsStore & ChatViewInjected & PropsLocale<'conversation'> +/** Full props of the attachment plugin's composer entry. */ +export type ComposerAttachmentsProps = + PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> + +/** Full props of the attachment plugin's message-gallery entry. */ +export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'> + /** * Injected share of the details slot: the panel is otherwise a pure reader of * the shared chat store, but its close button is a layout orchestration call. diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index bed6f5c89a..e322755473 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -1,10 +1,5 @@ -/** Bridges the `conversation` locale namespace to the zero-cordis attachment - * atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no - * application state; owners resolve every string). */ +/** Attachment error and limit copy owned by the conversation input flow. */ -import type { - AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels, -} from '@deepseek-ai/dsh-client-ui-attachment' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ConversationKey } from './locales.ts' @@ -56,61 +51,3 @@ export function attachmentErrorText( } return t('image.sendFailed', { reason }) } - -/** - * Resolve the original-image lightbox strings. - * @param t - the conversation-namespace translate. - * @returns the lightbox dialog and close-control labels. - */ -export function lightboxLabels(t: Translate): ImageLightboxLabels { - return { dialog: t('image.preview'), close: t('image.closePreview') } -} - -/** - * Resolve the chat-history image strings. - * @param t - the conversation-namespace translate. - * @returns the message-image labels including the forwarded lightbox strings. - */ -export function messageImageLabels(t: Translate): MessageImageLabels { - return { - image: t('image.label'), - open: t('image.openOriginal'), - openNamed: label => t('image.openOriginalLabel', { label }), - loading: t('image.loading'), - loadFailed: t('image.loadFailed'), - lightbox: lightboxLabels(t), - } -} - -/** - * Resolve the full-page drop overlay strings. - * @param t - the conversation-namespace translate. - * @param accepting - whether drops are currently accepted. - * @param limits - per-message limits for the desc line, when known. - * @returns the overlay title, with the limits desc while accepting. - */ -export function dropOverlayLabels( - t: Translate, - accepting: boolean, - limits?: { count: number; size: string }, -): DropOverlayLabels { - if (!accepting) return { title: t('image.dropBlocked') } - return { - title: t('image.dropTitle'), - desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }), - } -} - -/** - * Resolve the composer draft-image rail strings. - * @param t - the conversation-namespace translate. - * @returns the rail group, open-tooltip, and paging-arrow labels. - */ -export function attachmentRailLabels(t: Translate): AttachmentRailLabels { - return { - group: t('image.pending'), - open: t('image.openOriginal'), - scrollLeft: t('image.scrollLeft'), - scrollRight: t('image.scrollRight'), - } -} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 0574aa8375..4a8b27acbb 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -30,10 +30,10 @@ export type { export type { ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, - ComposerAttachment, ComposerChainProps, ConversationInjected, + ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, - TurnTailOwnerProps, UseChatNodeTurnData, + MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, TurnTailOwnerProps, UseChatNodeTurnData, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6a2eb5fdf4..6635322a5a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -122,15 +122,6 @@ padding: 10px 12px 0; } -/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek - Chat's spacing above the thumbnails; the card's 12px flex gap owns the space - below. The rail itself (arrows, hidden scrollbar, card geometry) is the - ui-attachment atom's. */ -.attachments { - min-width: 0; - padding: 4px 12px 0; -} - /* Floating overlay anchor (menu / popupSelect shell): entries position themselves against the card (bottom: 100% + gap); closed entries render null. */ .overlayAnchor { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 000174f513..501001215d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -12,8 +12,6 @@ import clsx from 'clsx' import { IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' -import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' @@ -23,12 +21,10 @@ import type {} from '@deepseek-ai/dsh-goal/client' // wire types: apiproxy's sessions contract declares it, and client-runtime's // api-remotes import already places it in every client program. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' -import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' +import type { ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' -import { - attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels, -} from '../image-labels.ts' +import { attachmentErrorText, imageSizeText } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import { isSafariBrowser, repairSafariTextareaLayout } from './safari.ts' @@ -37,11 +33,6 @@ import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } -/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */ -interface ComposerRailItem extends AttachmentRailItem { - attachment: ComposerAttachment -} - export type InputBarProps = ComposerBarProps export function InputBar({ @@ -74,8 +65,6 @@ export function InputBar({ [draftImages, input?.imageIds], ) const empty = draft.trim() === '' && attachments.length === 0 - const [preview, setPreview] = useState(null) - const [dragActive, setDragActive] = useState(false) // Transient error banner (image-intake rejections and prompt failures): the // seq keys the Toast so an identical repeated message restarts the // hold-then-fade cycle instead of silently reusing the faded one. @@ -104,7 +93,6 @@ export function InputBar({ }, [promptError, showToast, t, imageLimits]) const inputRef = useRef(null) const cardRef = useRef(null) - const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) const safari = useMemo(() => isSafariBrowser(navigator), []) @@ -168,11 +156,6 @@ export function InputBar({ 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]) - // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does // not act. @@ -464,74 +447,7 @@ export function InputBar({ if (rejected !== null) showToast(rejected) }, [addImages, attachments, imageLimits, showToast, t]) - // Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live - // on the document so a drop anywhere over the window adds images, not only - // over the composer card. Safe as document-level state: the composer-bar - // slot is `kind: 'single'`, so at most one bar is mounted to bind these. - // Text drags carry no 'Files' type and pass through untouched, keeping the - // native drop-text-into-textarea path. The overlay layer itself is - // pointer-inert, so it never disturbs the enter/leave count. const canAcceptDrop = !locked && !machineBusy && addImages !== undefined - useEffect(() => { - const hasFiles = (event: globalThis.DragEvent): boolean => - event.dataTransfer?.types.includes('Files') ?? false - const reset = (): void => { - dragDepthRef.current = 0 - setDragActive(false) - } - const onDragEnter = (event: globalThis.DragEvent): void => { - if (!hasFiles(event)) return - event.preventDefault() - dragDepthRef.current += 1 - setDragActive(true) - } - const onDragOver = (event: globalThis.DragEvent): void => { - if (!hasFiles(event) || event.dataTransfer === null) return - event.preventDefault() - event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none' - } - const onDragLeave = (event: globalThis.DragEvent): void => { - if (!hasFiles(event)) return - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - if (dragDepthRef.current === 0) setDragActive(false) - // Leaving through the viewport edge does not balance the count on every - // engine; a page-root leave at the border means the drag left the window. - const leavingViewport = event.clientX <= 0 || event.clientY <= 0 - || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight - if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset() - } - const onDrop = (event: globalThis.DragEvent): void => { - if (!hasFiles(event)) return - event.preventDefault() - reset() - if (!canAcceptDrop) return - intakeImages([...(event.dataTransfer?.files ?? [])]) - } - document.addEventListener('dragenter', onDragEnter) - document.addEventListener('dragover', onDragOver) - document.addEventListener('dragleave', onDragLeave) - document.addEventListener('drop', onDrop) - window.addEventListener('dragend', reset) - return () => { - document.removeEventListener('dragenter', onDragEnter) - document.removeEventListener('dragover', onDragOver) - document.removeEventListener('dragleave', onDragLeave) - document.removeEventListener('drop', onDrop) - window.removeEventListener('dragend', reset) - } - }, [canAcceptDrop, intakeImages]) - - const closePreview = useCallback(() => { setPreview(null) }, []) - - // Rail thumbnails with their strings resolved here: the attachment atoms are - // zero-cordis and read no locale. - const railItems = useMemo(() => attachments.map(attachment => ({ - id: attachment.id, - previewUrl: attachment.previewUrl, - alt: attachment.file.name || t('image.pending'), - removeLabel: t('image.remove', { name: attachment.file.name }), - attachment, - })), [attachments, t]) const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine @@ -655,15 +571,6 @@ export function InputBar({ return (
- {dragActive && ( - - )} {toast !== null && ( {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} - {railItems.length > 0 && ( -
- { setPreview(item.attachment) }} - onRemove={(item) => { removeImage?.(item.attachment.id) }} - /> -
- )} + {renderSlot('conversation.input.attachments', { + attachments, + canAcceptDrop, + onAddImages: intakeImages, + onRemoveImage: (id) => { removeImage?.(id) }, + dropLimits: imageLimits === undefined ? undefined : { + count: imageLimits.maxImagesPerMessage, + size: imageSizeText(imageLimits.maxImageBytes), + }, + })} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14 @@ -810,14 +717,6 @@ export function InputBar({
- {preview !== null && ( - - )} {footer}
) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index 67a213a98e..2164fcd059 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -21,7 +21,7 @@ import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx' import { zh } from '../src/client/locales.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' @@ -42,6 +42,7 @@ afterEach(() => { // Mirrors the real lookup chain (conversation namespace, then common). const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null const RETRY_ID = 'retry-fixture' as Extract['retryId'] interface MessageItemProps { @@ -949,7 +950,12 @@ describe('useCalendarDay boundary refresh', () => { describe('small branch tails', () => { it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { const view = render( - , + , ) expect(view.getByText('one-liner')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx index b0ce994f44..c6e3563e3e 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.client.spec.tsx @@ -13,6 +13,7 @@ import { zh } from '../src/client/locales.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null afterEach(cleanup) @@ -31,13 +32,20 @@ describe('tails', () => { { kind: 'other', block: { type: 'mystery' } }, ]} streaming + renderMessageImages={renderMessageImages} />, ) expect(view.getByText('Think')).toBeTruthy() expect(view.getByText('thinking hard')).toBeTruthy() expect(view.getByText(/未知内容块/)).toBeTruthy() const stopped = render( - , + , ) expect(stopped.getByText('已停止')).toBeTruthy() }) @@ -50,10 +58,13 @@ describe('tails', () => { t={t} blocks={[{ kind: 'tool-call', callId: 'c', name: 'todo_write', argsRaw: '{}' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) expect(empty.container.firstChild).toBeNull() - const blank = render() + const blank = render( + , + ) expect(blank.container.firstChild).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx index 588ca52c43..a934efb727 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx @@ -21,6 +21,7 @@ import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' // Mirrors the real lookup chain (conversation namespace, then common). const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */ class ResizeObserverStub { @@ -64,6 +65,7 @@ describe('render branch tails', () => { t={t} blocks={[{ kind: 'reasoning', text: 'done thinking' }, { kind: 'text', text: 'answer' }]} streaming + renderMessageImages={renderMessageImages} />, ) // reasoning at index 0 with a later block: running is false → ok state. @@ -100,7 +102,12 @@ describe('render branch tails', () => { it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => { const view = render( - , + , ) expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx index bec1fa8ebe..5b01fc5712 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx @@ -1,14 +1,13 @@ // @vitest-environment jsdom -// The conversation-side bridge to the ui-attachment atoms: dictionary strings -// flow through image-labels into the gallery, and assistant images keep their -// block position between text blocks. +// Conversation-owned attachment errors and the message-image slot handoff. import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { cleanup, render } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import type { RenderMessageImages } from '../src/client/contract/slots.ts' import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' import { en, zh } from '../src/client/locales.ts' @@ -26,6 +25,21 @@ const attachment = { name: 'history.png', } +type MessageImagesRenderOwner = Parameters[0] + +function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { + return (owner) => { + calls.push(owner) + return ( +
+ {owner.images.map(({ attachment: image }, index) => ( + {image.name} + ))} +
+ ) + } +} + describe('attachment rejection copy', () => { const limits = { maxImageBytes: 5 * 1024 * 1024, @@ -60,42 +74,24 @@ describe('attachment rejection copy', () => { }) }) -describe('assistant images through the label bridge', () => { - it('resolves zh dictionary strings and opens the lightbox on a single click', async () => { +describe('assistant image slot handoff', () => { + it('passes one image group and its message alignment to the renderer', () => { + const calls: MessageImagesRenderOwner[] = [] const view = render( Promise.resolve('blob:history')} + renderMessageImages={imageRenderer(calls)} />, ) - const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' }) - expect(frame.getAttribute('title')).toBe('查看原图') - await view.findByAltText('history.png') - fireEvent.click(frame) - expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) - expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + expect(view.getByTestId('message-images').getAttribute('data-align')).toBe('start') + expect(calls).toHaveLength(1) + expect(calls[0]?.images).toEqual([{ attachment }]) }) - it('resolves the active English dictionary', async () => { - const view = render( - Promise.resolve('blob:history')} - />, - ) - const frame = await view.findByRole('button', { name: 'history.png, click to view original' }) - await view.findByAltText('history.png') - fireEvent.click(frame) - expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() - expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() - }) - - it('merges consecutive image blocks into one tiled gallery, split by text', async () => { + it('merges consecutive image blocks into one group and splits groups at text', () => { + const calls: MessageImagesRenderOwner[] = [] const view = render( { { kind: 'image', attachment }, ]} streaming={false} - loadImage={() => Promise.resolve('blob:grouped')} + renderMessageImages={imageRenderer(calls)} />, ) - await view.findAllByAltText('history.png') - const galleries = view.container.querySelectorAll('[data-align="start"]') + const galleries = view.getAllByTestId('message-images') expect(galleries).toHaveLength(2) - expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2) - expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1) + expect(galleries.map(gallery => gallery.getAttribute('data-count'))).toEqual(['2', '1']) + expect(calls.map(call => call.images.length)).toEqual([2, 1]) }) - it('keeps assistant images at their original position between text blocks', async () => { + it('keeps the renderer output at the image block position between text blocks', () => { + const calls: MessageImagesRenderOwner[] = [] const view = render( { { kind: 'text', text: 'after' }, ]} streaming={false} - loadImage={() => Promise.resolve('blob:middle')} + renderMessageImages={imageRenderer(calls)} />, ) - const image = await view.findByAltText('history.png') + const image = view.getByTestId('message-images') const before = view.getByText('before') const after = view.getByText('after') expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) diff --git a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx index 62e6ac7848..551a286a88 100644 --- a/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/reasoning-row.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx' import { zh } from '../src/client/locales.ts' let nextAnimationFrameId = 1 @@ -37,6 +37,7 @@ afterEach(() => { }) const t = makeTranslate(zh, commonZh) +const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null describe('ReasoningRow', () => { it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => { @@ -45,6 +46,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]} streaming + renderMessageImages={renderMessageImages} />, ) expect(view.getByText('运行中')).toBeTruthy() @@ -59,6 +61,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]} streaming + renderMessageImages={renderMessageImages} />, ) expect(summary.scrollLeft).toBe(0) @@ -73,6 +76,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) flushAnimationFrames(3) @@ -88,6 +92,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) const row = view.getByRole('button') @@ -106,6 +111,7 @@ describe('ReasoningRow', () => { t={t} blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]} streaming={false} + renderMessageImages={renderMessageImages} />, ) fireEvent.click(view.getByText('Think')) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 79e9e3dc7c..a88ba5c8c1 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../ui-slots" }, - { - "path": "../ui-attachment" - }, { "path": "../ui-primitives" }, From f37bc082c551cb4fa5569e157186323c47e8600f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:39:18 +0800 Subject: [PATCH 58/80] refactor(client): move web rendering into a dynamic plugin --- .../2026-07-19-gui-web-client-architecture.md | 18 +- ...26-07-19-gui-web-client-architecture.zh.md | 18 +- ...26-07-22-slot-type-chain-implementation.md | 4 +- ...07-22-slot-type-chain-implementation.zh.md | 4 +- .../2026-07-23-client-plugin-loading-model.md | 19 +- ...26-07-23-client-plugin-loading-model.zh.md | 19 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...-render-and-attachment-ownership.i18n.yaml | 6 + ...-client-render-and-attachment-ownership.md | 43 ++++ ...ient-render-and-attachment-ownership.zh.md | 43 ++++ ...8-themed-scrollbars-and-reserved-gutter.md | 2 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 2 +- .../2026-08-10-pre-plugin-theme-bootstrap.md | 6 +- ...026-08-10-pre-plugin-theme-bootstrap.zh.md | 6 +- ...-08-11-web-attachment-display-alignment.md | 6 +- ...-11-web-attachment-display-alignment.zh.md | 6 +- ...026-07-26-web-syntax-highlighting-shiki.md | 2 +- ...-07-26-web-syntax-highlighting-shiki.zh.md | 2 +- apps/web/src/main.ts | 2 +- apps/web/tests/assembled-boot.ts | 10 +- apps/web/vite.config.ts | 4 +- knip.json | 12 +- packages/bundle/web-app/cordis.patch.yml | 6 + packages/bundle/web-app/package.json | 2 + packages/client/README.md | 4 +- packages/client/README.zh.md | 4 +- .../client/render-service/README.i18n.yaml | 6 + packages/client/render-service/README.md | 19 ++ packages/client/render-service/README.zh.md | 19 ++ packages/client/render-service/package.json | 72 ++++++ .../src/client}/DocumentTitle.tsx | 8 +- .../src => render-service/src/client}/app.tsx | 20 +- .../client/render-service/src/client/index.ts | 44 ++++ packages/client/render-service/src/index.ts | 4 + .../client/render-service/src/invariant.ts | 30 +++ .../tests/app.client.spec.tsx | 15 +- .../tests/document-title.client.spec.tsx | 7 +- .../tests/render-service.client.spec.tsx | 65 +++++ packages/client/render-service/tsconfig.json | 27 ++ .../client/render-service/tsdown.config.ts | 3 + packages/client/tsdown.client.ts | 80 ++++-- packages/client/ui-attachment/README.md | 6 +- packages/client/ui-attachment/README.zh.md | 6 +- .../src/client/ComposerAttachments.tsx | 21 +- .../client/ui-attachment/src/client/labels.ts | 26 +- .../tests/attachment-rail.client.spec.tsx | 9 + .../composer-attachments.client.spec.tsx | 160 ++++++++++++ .../tests/message-image.client.spec.tsx | 56 +++++ .../ui-attachment/tests/plugin.client.spec.ts | 38 +++ .../tests/chat-branch-tails.client.spec.tsx | 2 +- .../tests/input-bar.client.spec.tsx | 128 ++++------ .../src/client/settings-store.ts | 1 + .../tests/browser-plugin.client.spec.ts | 2 + .../ui-settings-models/src/client/store.ts | 1 + .../tests/apply.client.spec.ts | 4 +- packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/README.zh.md | 4 +- .../client/ui-settings/src/client/schema.ts | 51 +++- .../ui-settings/tests/plugin.client.spec.ts | 4 +- .../ui-settings/tests/schema.client.spec.ts | 101 ++++++++ .../tests/settings-scope.client.spec.ts | 29 ++- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- packages/client/ui-theme/package.json | 2 - packages/client/ui-theme/src/client/index.ts | 2 + packages/client/ui-theme/src/client/styles.ts | 31 +++ packages/client/ui-theme/src/css-modules.d.ts | 5 + .../tests/client-styles.client.spec.ts | 32 +++ packages/client/ui-theme/tsdown.config.ts | 5 - .../tests/workflow-run.client.spec.tsx | 2 +- packages/client/web/README.md | 9 +- packages/client/web/README.zh.md | 9 +- packages/client/web/package.json | 8 +- packages/client/web/src/AppRoot.module.css | 66 ----- packages/client/web/src/AppRoot.tsx | 60 ----- packages/client/web/src/app-shell.ts | 50 ---- packages/client/web/src/base.css | 11 +- packages/client/web/src/boot-page.module.css | 80 ++++++ packages/client/web/src/boot-page.ts | 75 ++++++ packages/client/web/src/boot.ts | 147 +++++++++++ packages/client/web/src/boot.tsx | 238 ------------------ packages/client/web/src/index.ts | 17 +- packages/client/web/src/loader-status.ts | 82 +----- packages/client/web/src/platform.ts | 2 - packages/client/web/src/seed.ts | 4 - .../client/web/tests/app-root.client.spec.tsx | 76 ------ .../web/tests/app-shell.client.spec.tsx | 61 ----- .../web/tests/base-styles.client.spec.ts | 54 +--- .../client/web/tests/boot-page.client.spec.ts | 53 ++++ packages/client/web/tsconfig.json | 8 +- packages/client/web/tsdown.config.ts | 2 +- pnpm-lock.yaml | 131 +++++----- scripts/client-bundle-css.spec.ts | 60 ++++- scripts/gen-cordis-catalog.ts | 3 +- .../verify-package-readme-model-experience.ts | 2 +- tsconfig.base.json | 5 +- tsconfig.client.json | 2 +- 98 files changed, 1675 insertions(+), 1049 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md create mode 100644 .agents/notes/implemented/architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md create mode 100644 packages/client/render-service/README.i18n.yaml create mode 100644 packages/client/render-service/README.md create mode 100644 packages/client/render-service/README.zh.md create mode 100644 packages/client/render-service/package.json rename packages/client/{web/src => render-service/src/client}/DocumentTitle.tsx (73%) rename packages/client/{web/src => render-service/src/client}/app.tsx (53%) create mode 100644 packages/client/render-service/src/client/index.ts create mode 100644 packages/client/render-service/src/index.ts create mode 100644 packages/client/render-service/src/invariant.ts rename packages/client/{web => render-service}/tests/app.client.spec.tsx (73%) rename packages/client/{web => render-service}/tests/document-title.client.spec.tsx (83%) create mode 100644 packages/client/render-service/tests/render-service.client.spec.tsx create mode 100644 packages/client/render-service/tsconfig.json create mode 100644 packages/client/render-service/tsdown.config.ts create mode 100644 packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx create mode 100644 packages/client/ui-attachment/tests/plugin.client.spec.ts create mode 100644 packages/client/ui-settings/tests/schema.client.spec.ts create mode 100644 packages/client/ui-theme/src/client/styles.ts create mode 100644 packages/client/ui-theme/tests/client-styles.client.spec.ts delete mode 100644 packages/client/web/src/AppRoot.module.css delete mode 100644 packages/client/web/src/AppRoot.tsx delete mode 100644 packages/client/web/src/app-shell.ts create mode 100644 packages/client/web/src/boot-page.module.css create mode 100644 packages/client/web/src/boot-page.ts create mode 100644 packages/client/web/src/boot.ts delete mode 100644 packages/client/web/src/boot.tsx delete mode 100644 packages/client/web/tests/app-root.client.spec.tsx delete mode 100644 packages/client/web/tests/app-shell.client.spec.tsx create mode 100644 packages/client/web/tests/boot-page.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 070b857f14..efe3688162 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -22,23 +22,23 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ │ │ │ │ conversation/trajectory(fetch bundle,按需) │ -└────────────────────────────────┘ │ ├ app-shell 伪行(壳内静态注册,同一治理) │ +└────────────────────────────────┘ │ ├ render-service(fetch bundle,React 根) │ │ └ session scope ×N(观看驱动,惰性建) │ - │ React: loading 页 → settled → 整 UI 一次成型 │ + │ DOM loading 页 → settled → React UI 一次成型 │ └────────────────────────────────────────────────────┘ ``` ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `