Merge remote-tracking branch 'origin/master' into worktree/default-model-persistence

# Conflicts:
#	apps/web/tests/models-settings.e2e.ts
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.md
#	packages/client/ui-models/README.zh.md
#	packages/client/ui-models/src/client/ModelsSection.module.css
#	packages/client/ui-models/src/client/ModelsSection.tsx
This commit is contained in:
Yichen Jiang
2026-08-07 13:54:15 +08:00
78 changed files with 3662 additions and 368 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
2026-07-30-web-config-plane.md: 5225460be1d66b85a05ff2fd5ae2826b0e6c41d7
2026-07-30-web-config-plane.zh.md: 53a21ddf31640d963c413e1793276de694547311
2026-07-30-web-config-plane.md: 11554077d1848dcdf59b896dd9c29a39fd2f55d4
2026-07-30-web-config-plane.zh.md: 527c2de8155a56789358b801f9c374e16c81931b

View File

@@ -22,7 +22,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override.
**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `<ROUTE>_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md).
## Alternatives considered
@@ -36,4 +36,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
## Consequences
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.
The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability.

View File

@@ -22,7 +22,7 @@ PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原rehydrate为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`deepseek 有 `reasoningEffort`pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id``name``contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border``--surface``--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态每个模型一个带边框的条目ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K``M` 后缀(`1M` 即 1000K与容量的通行标注方式一致并存储纯数值字段持有焦点期间保留键入的文本因为若每次按键都从解析出的数值重新推导该文本`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle``--dsw-alias-text-tertiary``--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile而破坏性确认会提交唯一一条 unset并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖
**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `<ROUTE>_API_KEY`仅在输入密钥时,pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.md)中
## 曾考虑的替代方案
@@ -36,4 +36,4 @@ PR1 让 LLM大语言模型适配器配置在 seam 层面免重启,但唯
## 后果
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个。设置外壳场景会截获无路径参数的原生意图seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明取消 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID名称上下文窗口、移除活动模型行并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。替换渲染器只花了一次提交且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据
整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与已点名目标的删除确认态各有 ARIA golden另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个。设置外壳场景会截获无路径参数的原生意图seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID名称上下文窗口、移除活动模型行并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件fixture测试前置数据、golden、文档、python未保留兼容别名。替换渲染器只花了一次提交且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)为从未声明可配置性的存活路由提供页面地址。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md
2026-07-31-code-runtime-portable-identifier-seam.md: 9e9df50235b3505458e3645e2c6ff6e9bd439183
2026-07-31-code-runtime-portable-identifier-seam.zh.md: 31d2410ee4809d0693f2e7897e61c50163bb0758

View File

@@ -0,0 +1,44 @@
# Agent Note: the code-runtime seam owns portable-identifier exclusions
Status: implemented
English | [中文](2026-07-31-code-runtime-portable-identifier-seam.zh.md)
## Problem
The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a Code Mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract.
A second backend written against a different language (CPython, arriving in a later PR of this stack) would either re-declare its own rules — letting `lambda` pass the worker and fail Python, or `$tools` pass the worker and fail every non-JS backend — or import the worker's, inverting the dependency so the seam's implementation reached into a sibling implementation. Neither keeps the portability promise real: it would hold only for the backend a caller happened to test against.
## Decision
The seam package (`@deepseek-ai/dsh-code-runtime`) exports the portable-identifier exclusion contract as four named constants, and every backend imports them rather than re-declaring:
- `PORTABLE_RESERVED_WORDS` — the union of ECMAScript and Python reserved words. A namespace global or error-class name matching any is refused on all backends, so `lambda` is refused even though it is a legal JS parameter name. Adding a language widens this union, which is a deliberate breaking review of existing binding names.
- `RESERVED_BINDING_GLOBALS` — globals some backend owns in the program's namespace: `console` (the worker's log capture), `__dsh_main__`/`__builtins__`/`__name__` (the Python bootstrap's wrapper and seeded module globals), and `__debug__` (not a seeded slot but a CPython compile-time constant that rejects assignment, so an injected global under that name is unreachable — the same portability split by a different mechanism). Refused everywhere so a namespace list cannot pick a name that works on one backend and collides on another.
- `RESERVED_ERROR_MEMBERS` — error-member names every backend refuses: the JS `Error` slots (`name`, `message`, `stack`) and Python's exception-protocol members (`args`, `with_traceback`, `add_note`).
- `DUNDER_MEMBER` — the dunder-form regex (`__x__`, non-empty middle), refused as an error member wholesale because several are constrained CPython descriptors whose exact set is an interpreter-version detail.
The seam also narrows the portable identifier subset to `[A-Za-z_][A-Za-z0-9_]*` (documented on `CodeBindingNamespace.global` and `CodeBindingErrorClass`), dropping the JS-only `$`. The worker consumes the shared constants directly by their seam names — `PORTABLE_RESERVED_WORDS` for both binding-global and error-class names, `RESERVED_BINDING_GLOBALS` for backend-owned slots, `RESERVED_ERROR_MEMBERS` plus `DUNDER_MEMBER` for error members — with no local re-alias; its `IDENTIFIER` regex loses `$`.
The constants live at the seam even though only one backend ships in this PR: the whole point is that the contract is language-agnostic and owned above any single language. A backend that violated it would be the bug, and the shared set is where a reviewer looks to see what "portable" means.
## Scope
This PR delivers only the seam extension and the worker's adoption of it. No Python backend, `py-types` renderer, or Code Mode language dispatch ships here — they are later PRs in the stack that depend on these exports. The seam README's worker-only wording is left unchanged for the same reason: linking to a `dsh-code-runtime-python` README that does not yet exist would break the dead-link gate.
`RESERVED_BINDING_GLOBALS` currently encodes the not-yet-merged Python bootstrap's concrete design: it seeds exactly `__builtins__`/`__name__` and wraps the program under `__dsh_main__`. The Python-backend PR that seeds any additional module global (`__doc__`, `__loader__`, `__spec__`, `__file__`, `__package__`, …) MUST widen this set in the same change, exactly as adding a language widens `PORTABLE_RESERVED_WORDS` — a name the bootstrap seeds but the set omits is the portability split this contract exists to prevent.
## Alternatives considered
**Each backend declares its own exclusions.** Rejected: it makes the portability promise per-backend. A binding list the caller tested on the worker could be refused by Python, which is exactly the split the seam exists to prevent.
**The Python backend imports the worker's constants.** Rejected: it inverts the dependency — the seam's implementations would reach into a sibling implementation for a contract neither owns. The contract belongs above both, at the seam.
**Keep `$` in the portable identifier subset.** Rejected: `$` is JS-only spelling. Allowing it would let `$tools` pass the worker and fail every non-JS backend, breaking portability for a purely cosmetic gain.
## Consequences
Bought: one place — the seam package — defines what a portable binding name is, and every backend enforces the same contract by import. A namespace list valid on one backend is valid on all, verifiably, not by coincidence of which backend the caller tested.
Cost: existing worker callers using a `$`-containing global now fail identifier validation. Under the pre-release stance this is a corrected foundation, not a compatibility break to shim. The worker's seam-misuse tests gain cases for `$tools`, Python exception members (`args`), dunders (`__dict__`), and a Python-owned global (`__dsh_main__`), proving the shared set is enforced from the worker side.

View File

@@ -0,0 +1,44 @@
# Agent Notecode-runtime seam 拥有可移植标识符排除集
Status: implemented
[English](2026-07-31-code-runtime-portable-identifier-seam.md) | 中文
## Problem
code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 Code Mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$``IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性契约。
一个针对不同语言CPython将在本 stack 后续 PR 中到来)编写的第二后端,要么重新声明自己的规则——让 `lambda` 通过 worker 却在 Python 上失败,或让 `$tools` 通过 worker 却在每个非 JS 后端上失败——要么导入 worker 的规则,从而反转依赖,使 seam 的一个实现伸手进入另一个兄弟实现。二者都无法让可移植承诺成真:它只对调用方恰好测试过的那个后端成立。
## Decision
seam 包(`@deepseek-ai/dsh-code-runtime`)以四个具名常量导出可移植标识符排除契约,每个后端导入它们而非重新声明:
- `PORTABLE_RESERVED_WORDS`——ECMAScript 与 Python 保留字的联集。任何命名空间 global 或 error-class 名称匹配其中之一,都在所有后端上被拒绝,因此 `lambda` 即便是合法的 JS 参数名也被拒绝。新增一门语言即扩宽此联集,这是对现有绑定名称的一次有意的破坏性复审。
- `RESERVED_BINDING_GLOBALS`——某个后端在程序命名空间中拥有的 global`console`worker 的日志捕获)、`__dsh_main__`/`__builtins__`/`__name__`Python bootstrap 的包装器与预置模块 global以及 `__debug__`(不是 seed 的槽位,而是 CPython 编译期常量,赋值会被拒,故以该名注入的 global 不可达——同一种可移植性分裂,只是机制不同)。在所有后端上被拒绝,使命名空间列表无法选到一个在某后端能用、在另一后端冲突的名称。
- `RESERVED_ERROR_MEMBERS`——每个后端都拒绝的 error-member 名称JS `Error` 槽位(`name``message``stack`)与 Python 异常协议成员(`args``with_traceback``add_note`)。
- `DUNDER_MEMBER`——dunder 形式正则(`__x__`,非空中缀),作为 error member 被整体拒绝,因为其中若干是受约束的 CPython 描述符,其确切集合是解释器版本细节。
seam 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0-9_]*`(记录在 `CodeBindingNamespace.global``CodeBindingErrorClass` 上),去掉 JS 专有的 `$`。worker 直接以 seam 名消费这些共享常量——binding-global 与 error-class 名称用 `PORTABLE_RESERVED_WORDS`、后端拥有槽位用 `RESERVED_BINDING_GLOBALS`、error member 用 `RESERVED_ERROR_MEMBERS``DUNDER_MEMBER`——不再本地起别名;其 `IDENTIFIER` 正则去掉 `$`
尽管本 PR 只交付一个后端,这些常量仍置于 seam要点正是该契约与语言无关且拥有权在任何单一语言之上。违反它的后端才是 bug而共享集合正是复审者查看"可移植"含义的地方。
## Scope
本 PR 只交付 seam 扩展与 worker 对它的采用。这里不交付任何 Python 后端、`py-types` 渲染器或 Code Mode 的语言分发——它们是本 stack 中依赖这些导出的后续 PR。seam README 中仅描述 worker 的措辞保持不变,理由相同:链接到一个尚不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。
`RESERVED_BINDING_GLOBALS` 当前编码了尚未合并的 Python bootstrap 的具体设计:它恰好 seed `__builtins__`/`__name__`,并把程序包装在 `__dsh_main__` 之下。任何 seed 额外模块 global`__doc__``__loader__``__spec__``__file__``__package__` 等)的 Python 后端 PR 必须在同一改动中扩宽此集合,正如新增一门语言即扩宽 `PORTABLE_RESERVED_WORDS`——bootstrap 会 seed 却不在集合中的名称,正是本契约要防止的可移植性分裂。
## Alternatives considered
**每个后端声明自己的排除集。** 拒绝:这让可移植承诺变成逐后端成立。调用方在 worker 上测过的绑定列表可能被 Python 拒绝,而这正是 seam 存在要防止的分裂。
**Python 后端导入 worker 的常量。** 拒绝这反转依赖——seam 的实现会为一个二者都不拥有的契约伸手进入兄弟实现。契约属于二者之上,即 seam。
**在可移植标识符子集中保留 `$`。** 拒绝:`$` 是 JS 专有拼写。允许它会让 `$tools` 通过 worker 却在每个非 JS 后端上失败,为纯粹表面的好处破坏可移植性。
## Consequences
获得一个地方——seam 包——定义什么是可移植绑定名称,每个后端通过导入执行同一契约。在一个后端上有效的命名空间列表在所有后端上都有效,这是可验证的,而非取决于调用方测试了哪个后端的巧合。
代价:现有使用含 `$` global 的 worker 调用方现在会在标识符校验时失败。在预发布立场下这是一次被纠正的地基,而非需要 shim 的兼容性破坏。worker 的 seam-misuse 测试新增了 `$tools`、Python 异常成员(`args`、dunder`__dict__`)与一个 Python 拥有的 global`__dsh_main__`)等用例,从 worker 侧证明共享集合被执行。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
2026-07-29-sticky-composer-conversation-scroll.md: d3fed7a9d0b1f39f9551fbd85e0f83515b1a2690
2026-07-29-sticky-composer-conversation-scroll.zh.md: 2beee34d3bb68832d14b7607b43aa11e1425d53d
2026-07-29-sticky-composer-conversation-scroll.md: 8657080078e475d6e90f17a6c0f8cb5b2fb1555d
2026-07-29-sticky-composer-conversation-scroll.zh.md: a588903adf51c6fcdd01f851d4f5e6570f695242

View File

@@ -14,7 +14,7 @@ The active conversation column split scrolling: the chat (and trajectory) view o
Session stats live on `'conversation.composer.dock'` (above `'conversation.input.dock'`). The InputBar textarea, when inside the host, chains `wheel` with `{ passive: false }`: while the capped textarea can still scroll in that direction it keeps the native gesture; only at its own edge does it `preventDefault` and apply `deltaY` to the host.
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry. A passive wheel listener takes its pre-input baseline from the last main-thread-delivered or programmatically written `scrollTop`, because Chromium may advance compositor geometry before delivering the event; the current non-negative floor excludes a concurrent layout clamp from reader movement. A scroll without matching wheel movement re-pins while following and only refreshes the semantic position while reading. ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
Chat history prepend follows reader intent through stable rendered node/call identities rather than whole-scrollport height deltas. `ChatView` records the first visible `data-chat-anchor-key` and its top relative to the scrollport when paging starts, reselects the currently visible stable anchor after every reader scroll while the request is in flight, and compensates by that row's post-prepend rectangle delta. Reaching the bottom or appending the reader's own message cancels the paging anchor, so a late page cannot pull the view away from the newest content. Bottom follow is stored state rather than raw scroll geometry; how reader input is recognized — device-agnostic deviation from the observed-top ledger of the last delivered or written `scrollTop` — is owned by the [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md). ChatView's single `ResizeObserver` follows streaming, tool disclosure, and draft resize only while bottom ownership remains pinned, without a second per-chunk scroll write.
## Alternatives considered
@@ -26,8 +26,8 @@ Chat history prepend follows reader intent through stable rendered node/call ide
**Keep StatsLine inside ChatView below the message column.** Rejected: outside the sticky composer it would scroll away while the input stayed pinned.
**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership remain outside the provenance model instead of adding a general input state machine.
**Model every browser scroll input source.** Rejected for this narrow fix: the reproduced desktop path uses wheel/trackpad input. Pointer/touch scrolling, native-scrollbar dragging, keyboard scrolling, focus navigation, and nested overflow ownership were left outside the provenance model instead of adding a general input state machine. The [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) later closed this deferral by generalizing attribution through the observed-top ledger, still without an input state machine.
## Consequences
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve wheel/trackpad scroll decisions, including Chromium's compositor-first delivery and stream-finalization clamp/regrow. Other browser scroll inputs do not change follow ownership under this narrow provenance rule. No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft.
Wheel over the footer scrolls the transcript; the visible layout is a fixed header, scrolling transcript, and sticky bottom composer. Stats appear on every active view tab. Nested view scrollers under the host are suppressed so sticky Turn headers in Trajectory stick to the column host. Concurrent history, streaming, tool expansion, and composer reflow preserve reader scroll decisions, including Chromium's compositor-first delivery and stream-finalization shrink clamp. Follow ownership extends to every reader input under the [reader-scroll-attribution note](2026-08-06-reader-scroll-attribution-observed-top-ledger.md). No session → blank Hero and Hero → active both keep the same textarea DOM node and InputHub draft.

View File

@@ -14,7 +14,7 @@ Status: implemented
会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
Chat 历史前插通过稳定的已渲染 nodecall 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
Chat 历史前插通过稳定的已渲染 nodecall 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态;读者输入如何被识别——即以与设备无关的方式偏离由最近一次交付或写入的 `scrollTop` 构成的 observed-top ledger——由[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)负责`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
## Alternatives considered
@@ -26,8 +26,8 @@ Chat 历史前插通过稳定的已渲染 nodecall 身份跟随读者意图
**把 StatsLine 留在 ChatView 消息列下方。** 否决:落在 sticky 编辑器之外会随内容滚走,而输入区仍钉在底部。
**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权仍不纳入输入来源模型,也为此新增通用输入状态机。
**为每一种浏览器滚动输入来源建模。** 此次窄范围修复不采用:已复现的桌面端路径使用滚轮/触控板输入。指针/触控滚动、拖动原生滚动条、键盘滚动、焦点导航与嵌套 overflow 所有权当时被留在输入来源模型之外,也为此新增通用输入状态机。[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)后来通过 observed-top ledger 泛化了归因,补上了这一延后事项,且仍未引入输入状态机。
## Consequences
在页脚上滚轮会滚动 transcript可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留滚轮/触控板的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段滚动位置受钳制后滚动容器重新增长的情况。在这条窄范围的输入来源规则下,其他浏览器滚动输入不会改变贴底跟随所有权。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。
在页脚上滚轮会滚动 transcript可见布局是固定标题栏、可滚动 transcript 与 sticky 底部编辑器。统计出现在每一个活跃视图标签上。宿主下的嵌套视图 scroller 被抑制,因而 Trajectory 的 sticky Turn 标题贴在列宿主上。并发历史加载、流式输出、工具展开与编辑器重排会保留读者的滚动决定,包括 Chromium 先推进合成器几何状态再交付事件,以及流收尾阶段的收缩钳制。贴底跟随所有权依据[读者滚动归因笔记](2026-08-06-reader-scroll-attribution-observed-top-ledger.md)扩展到每一种读者输入。无 session → blank Hero 与 Hero → active 都保持同一 textarea DOM 节点以及 InputHub 草稿。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md
2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09
2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6

View File

@@ -0,0 +1,27 @@
# Agent Note: Recoverable provider credential lifecycle
Status: implemented
English | [中文](2026-08-06-provider-credential-lifecycle.zh.md)
## Problem
The Models editor spans independent settings and credential RPC domains. It previously committed provider settings before storing the API key but kept the revision and original subtree from when the card opened. If the credential write failed, retry replayed the already-committed settings mutation with a stale revision and produced a conflict, leaving the user unable to complete the second stage from the same card. A blank pi-ai key also wrote the derived `apiKeyEnv` without a credential, which prevented pi-ai from using provider-native discovery. At deletion, the inverse leak remained: the profile disappeared but its page-stored key stayed in `.env` and silently became active when the provider was added again. Generic row actions and confirmation copy did not identify which provider would be changed.
## Decision
Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery.
Deletion removes a credential only when the joined row identifies the exact `<ROUTE>_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material.
## Alternatives considered
**Add a cross-domain transaction RPC.** Settings and credentials have separate owning services and durable stores; introducing a new host transaction would broaden the public wire and still require compensation for provider-specific persistence failures. The UI checkpoint makes the current ordered stages recoverable without adding a fourth configuration contract.
**Delete every credential reference named by a removed profile.** A custom reference can be shared, externally managed, or intentionally survive profile churn. Exact equality with this page's derived target plus configured+writable state is the narrow evidence available to the page; anything weaker risks deleting a credential it does not own.
**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct stored literal secrets or concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration.
## Consequences
The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Confirmed status is visible without turning route liveness, native authentication, or a failed credential lookup into a false error, and a successful replacement remains observable even when the row stays green. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, status visibility, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md).

View File

@@ -0,0 +1,27 @@
# Agent Note: 可恢复的提供方凭据生命周期
Status: implemented
[English](2026-08-06-provider-credential-lifecycle.md) | 中文
## 问题
Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它先提交提供方 settings再存储 API 密钥,却一直保留卡片打开时的 revision 和原始子树。如果凭据写入失败,重试会用陈旧 revision 重放已提交的 settings 变更,并产生冲突,导致用户无法从同一张卡片完成第二个阶段。空的 pi-ai 密钥还会写入派生的 `apiKeyEnv`,却不写入凭据,从而阻止 pi-ai 使用提供方原生凭据发现。删除时则存在相反的残留问题profile 消失了,页面存储的密钥却保留在 `.env` 中,并在重新添加提供方时静默地恢复作用。笼统的行操作与确认文案也没有标明要更改哪个提供方。
## 决策
提供方保存仍在现有 wire 领域上按先 settings、后凭据的两阶段顺序执行但卡片会把成功的 settings 响应视为提交检查点。它会在尝试 `credentials.set` 之前,用返回的脱敏 descriptor 替换比较基准子树与预期 revision如果第二阶段失败草稿密钥与卡片会继续显示重试不会产生 settings op只会再次写入凭据。首次 settings 提交之前发生的真实并发变更仍会以 `settings-conflict` 失败。UI 与 DeepSeek 直连 resolver 边界均会去除所输密钥的首尾空白且只有标准化密钥非空时pi-ai 才会记录派生引用;留空密钥会具化一个空的、不带引用的 profile以便使用提供方原生凭据发现。
只有当联接所得的行识别出该页面派生的精确 `<ROUTE>_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥并在自身内显示操作失败而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。
## 曾考虑的替代方案
**添加跨领域事务 RPC。**settings 与凭据分属不同的主管服务与持久存储;引入新的 Host 事务会扩大公开 wire 面而且仍需要补偿提供方特定的持久化失败。UI 检查点让当前的有序阶段变得可恢复,无需添加第四项配置契约。
**删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。
**先删除 settings再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。
## 后果
Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见同时不会把路由存活状态、原生认证或凭据查询失败误报为错误即使该行继续显示绿色密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥而存在歧义的凭据会有意保留交由手动管理。保存与删除在跨持久存储时仍非原子操作进程可能在两个阶段之间崩溃但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权以及凭据settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md
2026-08-06-reader-scroll-attribution-observed-top-ledger.md: 2dcd0178e4216d4d3e2edcd3bfcc05b63606c333
2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: ffb03fdb68c9cb040e311077dd698b310a033fdd

View File

@@ -0,0 +1,37 @@
# Agent Note: Reader scroll attribution through the observed-top ledger
Status: implemented
English | [中文](2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md)
## Problem
ChatView's bottom-follow recognized only wheel/trackpad gestures as reader input: while pinned to the floor, a scroll event without matching wheel movement was treated as programmatic and snapped back. Touch panning, native-scrollbar dragging, and keyboard paging therefore could not leave the bottom of a streaming transcript — on a phone the tail was effectively locked. That wheel-only provenance was a deliberate deferral in the [sticky-composer note](2026-07-29-sticky-composer-conversation-scroll.md), which rejected a general input state machine "for this narrow fix" and left every other scroll source outside the model.
## Decision
Reader input is no longer identified by device. ChatView keeps an observed-top ledger (`observedTopRef`): the last `scrollTop` either delivered on the main thread or written by the component, recorded synchronously at every programmatic write site — bottom follow, open restore, prepend anchoring, resize follow, and scroll delivery itself. When a scroll event arrives, a position that deviates from `min(ledger, floor)` by more than half a pixel is reader input; a position on the ledger (a delayed programmatic delivery) or exactly on the shrunken floor (a browser clamp after content shrank) preserves the current ownership state. Ownership then changes only through reader input under the existing threshold rule: within `FOLLOW_THRESHOLD` of the floor re-pins, beyond it releases follow and shows Back to bottom. The wheel listener and its epoch bookkeeping are deleted; the component listens to `scroll` alone, so wheel, touch, scrollbar, keyboard, and any future input source are covered by one rule.
## Contract change: coalesced shrink-plus-regrow clamps
A shrink clamp whose layout regrows within the same rendering update before the clamp's scroll event is delivered is geometrically indistinguishable from reader input, so it now reads as the reader and releases follow (Back to bottom recovers). Realistic React-commit-driven shrink and regrow is still absorbed: the layout-effect follow re-pins and re-records the ledger per commit, and a shrink-only clamp lands exactly on `min(ledger, floor)`. Only a non-React reflow that shrinks and regrows inside one update mis-attributes. The previous wheel model kept following in that raced case; the unit contract was rewritten to the absorbed-shrink-only guarantee in the same change.
## Testing
Unit specs in `packages/client/ui-conversation/tests/chat-view.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger.
The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly.
## Alternatives considered
**Keep the wheel-only model.** Rejected: it is the defect. Touch, scrollbar, and keyboard readers cannot take ownership away from a streaming tail, and each newly supported device would need its own carve-out.
**Enumerate input devices.** Adding `touchstart`/`pointerdown`/`keydown` listeners beside the wheel epoch was the obvious extension. Rejected: native-scrollbar dragging exposes no input event to latch before its scrolls arrive, device lists rot as browsers add sources, and every listener would need its own compositor-delivery grace window — the input state machine the sticky-composer note already declined to build.
**Absorb the coalesced shrink-plus-regrow clamp with heuristics.** Floor-mismatch grace windows or deferred rAF re-checks could keep the raced clamp from reading as the reader. Rejected: streaming rewrites the floor at chunk pace (24 ms) against ~16 ms frames, so any grace window either swallows genuine touch input during streaming — reopening the bug this change fixes — or is too short to cover the race it targets. The mis-attribution is accepted and recoverable instead.
**Drive real touch and scrollbar devices in e2e.** Rejected by the environment, not by preference: every synthesis path (CDP touch gestures, touch event sequences, synthetic mouse on classic scrollbars, headed under Xvfb) was probed and cannot scroll; the details live in Testing above.
## Consequences
Every reader input owns bottom-follow uniformly, with less code: the wheel listener, its epoch counter, and the pre-input baseline bookkeeping are gone, and attribution rides state the component already maintained. The sticky-composer note's layout, wheel chaining, and prepend-anchoring decisions are untouched and remain authoritative; its narrow provenance rule is superseded by this note. The cost is the contract change above — a coalesced non-React shrink-plus-regrow clamp now pauses follow until the reader returns to the floor or presses Back to bottom — traded for touch, scrollbar, and keyboard correctness during streaming. The e2e lane gains non-wheel coverage only within what its browser can synthesize; if gesture synthesis starts working in a future Chromium, the fling emulation can be replaced by real touch strokes without changing the asserted contract.

View File

@@ -0,0 +1,37 @@
# Agent Note: 经由 observed-top ledger 的读者滚动归因
Status: implemented
[English](2026-08-06-reader-scroll-attribution-observed-top-ledger.md) | 中文
## 问题
ChatView 的贴底跟随此前只把滚轮触控板手势识别为读者输入钉在底部floor期间一个没有对应滚轮位移的滚动事件会被视为程序化滚动并被拉回底部。因此触控平移、拖动原生滚动条与键盘翻页都无法离开流式 transcript文本记录的底部在手机上尾部实际上被锁死。这种仅认滚轮的输入来源判定是 [sticky-composer 笔记](2026-07-29-sticky-composer-conversation-scroll.md)中有意的暂缓:该笔记拒绝为「此次窄范围修复」建立通用输入状态机,把其余所有滚动来源都留在模型之外。
## 决策
读者输入不再依据设备来识别。ChatView 维护一份 observed-top ledger`observedTopRef`):即最近一次由主线程交付、或由组件自身写入的 `scrollTop`,并在每一个程序化写入点(贴底跟随、打开时恢复、前置锚定、尺寸变化跟随以及滚动交付本身)同步记录。滚动事件到达时,偏离 `min(ledger, floor)` 超过半像素的位置即为读者输入;落在 ledger 上的位置(迟到的程序化交付),或恰好落在收缩后底部上的位置(内容收缩后的浏览器钳制),则维持当前的所有权状态。此后所有权只经由读者输入、按既有阈值规则变化:位置距底部在 `FOLLOW_THRESHOLD` 以内则重新贴底,超出则释放跟随并显示「回到底部」。滚轮监听器及其 epoch 簿记已删除;组件只监听 `scroll`,因此滚轮、触控、滚动条、键盘以及未来任何输入来源都由同一条规则覆盖。
## 契约变更:收缩与重新增长被合并的钳制
如果一次收缩钳制的布局在同一次渲染更新内、赶在该钳制的滚动事件交付之前重新增长,那么这个事件在几何上与读者输入无法区分,因此它现在会被判读为读者并释放跟随(可经「回到底部」恢复)。现实中由 React 提交commit驱动的收缩与重新增长仍会被吸收layout effect 中的跟随会在每次提交后重新贴底并重新记录 ledger而仅收缩的钳制会恰好落在 `min(ledger, floor)` 上。只有在同一次更新内先收缩再重新增长的非 React 重排会被误归因。旧的滚轮模型在这种竞态情形下会保持跟随;单元测试契约已在同一变更中改写为只吸收纯收缩的保证。
## 测试
`packages/client/ui-conversation/tests/chat-view.spec.tsx` 中的单元测试直接钉住 ledger 契约:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md):在已停稳的 transcript 上做键盘翻页以及对着按节奏推进的流式输出做一次触控式惯性快滑momentum fling两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。
该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。
## 曾考虑的替代方案
**保留仅认滚轮的模型。** 否决:它本身就是缺陷所在。触控、滚动条与键盘读者无法从流式尾部夺走所有权,而每种新支持的设备都需要单独开一个特例。
**逐一枚举输入设备。** 在滚轮 epoch 旁边加挂 `touchstart`/`pointerdown`/`keydown` 监听器是最显而易见的扩展。否决:拖动原生滚动条在其滚动事件到达之前不暴露任何可供锁存的输入事件;设备清单会随浏览器新增输入来源而逐渐腐化;而且每个监听器都需要自己的合成器交付宽限窗口——这正是 sticky-composer 笔记当初就拒绝构建的那个输入状态机。
**用启发式吸收收缩与重新增长被合并的钳制。** 针对底部失配的宽限窗口,或推迟到 rAF 的复查本可让这种竞态下的钳制不被判读为读者。否决流式输出以分片节奏24 ms改写底部而帧间隔约 16 ms因此任何宽限窗口要么会在流式输出期间吞掉真实的触控输入重新打开本次变更所修复的缺陷要么短到盖不住它想针对的竞态。转而接受这一误归因它是可恢复的。
**在 e2e 中驱动真实的触控与滚动条设备。** 否决来自环境而非偏好取舍每条合成路径CDP 触控手势、触控事件序列、经典滚动条上的合成鼠标、Xvfb 下的有头模式)都逐一试过,均无法滚动;细节见上文「测试」一节。
## 后果
每种读者输入现在都以同一方式拥有贴底跟随,而代码更少:滚轮监听器、它的 epoch 计数器以及输入前基线簿记均已移除归因搭载在组件本就维护的状态之上。sticky-composer 笔记中的布局、滚轮链式处理与前置锚定决策原样保留,仍为权威;其窄范围的输入来源规则由本笔记取代。代价就是上文的契约变更:一次收缩与重新增长被合并的非 React 钳制现在会暂停跟随直到读者回到底部或按下「回到底部」以此换来流式输出期间触控、滚动条与键盘的正确性。e2e 车道获得的非滚轮覆盖仅限其浏览器能够合成的范围;若手势合成在未来某个 Chromium 版本中开始可用,可以在不改变所断言契约的前提下,把快滑模拟替换为真实的触控划动。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md
2026-06-15-code-mode.md: b6a24ecd9700e32912b8112b59cbd8b6ab131eb5
2026-06-15-code-mode.zh.md: a00a43ece1e581190de6096be8138df25a23f07f
2026-06-15-code-mode.md: 99bbed3edab32512f88ece9694d6519a1f89c2dd
2026-06-15-code-mode.zh.md: ca1bbe9ed3e412186763d1ed4fca9ed06669d4c3

View File

@@ -6,7 +6,7 @@ English | [中文](2026-06-15-code-mode.zh.md)
## Problem
In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and the loop dispatches each call through `ctx.tools.execute()` **sequentially** (parallel tool execution is an explicit open TODO in `dsh-tools` and [docs/architecture.md](../../../../docs/architecture.md)), with **every** intermediate `tool-result` re-entering the model's context on the next request.
In the registry's native presentation, the agent loop advertises every visible capability as a JSON-schema function definition. `ToolRegistry` contributes its schemas to the system-prompt assembly, the assembly's `tools` land on the wire (and in the logged request header), the model invokes one `tool-call` block per step, and at the time of this note the loop dispatched each call through `ctx.tools.execute()` **sequentially** (parallel tool execution was an open TODO then; bounded parallel dispatch has since shipped — the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md), the rolling pool in [docs/architecture.md](../../../../docs/architecture.md)) with **every** intermediate `tool-result` re-entering the model's context on the next request.
For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not.
@@ -32,7 +32,7 @@ This note owns Code Mode's presentation, composition, isolation, and settlement
**Interaction with `toolOrder`, stated up front:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it.
**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders TypeScript declarations plus fixed usage instructions for the scope's visible capabilities. It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output.
**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output.
**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition.
@@ -48,7 +48,7 @@ Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentat
**Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision.
**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata.
**Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler that replaced it.
**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md).
@@ -64,7 +64,7 @@ Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool en
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole.
- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary.
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them.
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the shipped backend; a Python backend would say so, and pair with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` requires `language === 'typescript'` in the MVP — its codegen emits TS — and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).
- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the first backend; a Python backend says `'python'` and pairs with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` accepts any `language` with a registered SDK renderer and `run_code` flavor (TypeScript and Python ship; see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)) and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all).
Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator.
@@ -85,11 +85,11 @@ The worker runtime provides containment, not a security boundary: model code can
### What the model sees
The SDK instructs the model to write an async erasable-TypeScript body, call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Calls remain sequential even under `Promise.all`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching.
The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Both flavors state the same contract in their own primitive: independent read-only calls MAY overlap under `Promise.all` (TypeScript) or `asyncio.gather` (Python), mutating calls run alone in submission order, and dependent work sequences with `await`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching.
## Consequences
Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch remains serialized, while per-call contexts retain their source, envelope, and metadata through the outer result.
Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol surface. Sub-dispatch starts in submission order under a bounded overlap pool, while per-call contexts retain their source, envelope, and metadata through the outer result.
## Testing
@@ -106,7 +106,7 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem
**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls.
**Parallel native dispatch in the loop.** The other answer to round-trip cost; still valid future work (the open TODO), still blocked on concurrency-safety metadata, and still no composition — it parallelizes calls the model already decided on in one step. Code Mode's serialized-queue decision keeps the two compatible: when the metadata lands, both native parallel dispatch and per-tool binding parallelism unlock together.
**Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. Code Mode's queue decision kept the two compatible, and that is how it played out: the metadata landed as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch and per-tool binding parallelism unlocked on the same classifier.
**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it.
@@ -128,6 +128,6 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem
**Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary.
**Serialized-only sub-dispatch.** `Promise.all` gains no wall-clock parallelism yet, only fewer round-trips; models may over-expect. The instructions state it; lifting it is tied to the same concurrency-safety metadata the native parallel-dispatch TODO needs.
**Sub-dispatch overlap is bounded by tool safety claims, not by the caller.** A program's `Promise.all` or `asyncio.gather` buys wall-clock parallelism only across calls the tool itself classifies concurrency-safe; a run of exclusive calls still costs its round-trips in sequence, and models may over-expect. Both flavors' SDK instructions state the real contract. This note shipped the serialized placeholder that made the risk absolute; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler and its overlap cap.
**Budget metering reads the event loop, not a flag.** Busy-time polling (`eventLoopUtilization()`) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at `computeMs`; idle-on-slow-binding survives to `maxWallMs`), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass. `maxWallMs` is config, and it reaches `setTimeout`, which clamps a delay above `MAX_TIMER_DELAY_MS` (2^31-1 ms) to 1 ms; a positivity check alone therefore accepts a 25-day ceiling that expires on the first tick and times out every run. The worker runtime range-checks the field at load for that reason. `computeMs` needs no upper bound because it is compared against measured utilization instead of being handed to a timer.

View File

@@ -6,7 +6,7 @@ Status: implemented
## 问题
在注册表的原生呈现方式下agent loop智能体循环将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式wire format也记录在请求头日志中模型每步调用一个 `tool-call` 块,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行`dsh-tools` [docs/architecture.md](../../../../docs/architecture.md) 中明确标注的 open TODO且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。
在注册表的原生呈现方式下agent loop智能体循环将每个可见能力以 JSON Schema 函数定义的形式通告给模型。`ToolRegistry` 将其 schema 贡献给系统提示词组装,组装结果中的 `tools` 落到协议格式wire format也记录在请求头日志中模型每步调用一个 `tool-call` 块,而在本 note 写作时,循环通过 `ctx.tools.execute()` **逐个**分发每次调用(并行工具执行当时还是 open TODO此后有界的并行分发已经交付——见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.md),以及 [docs/architecture.md](../../../../docs/architecture.md) 中的 rolling pool——且**每一个**中间 `tool-result` 都会在下一次请求时重新进入模型上下文。
对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。
@@ -32,7 +32,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
**与 `toolOrder` 的交互,预先说明:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug使用 Code Mode 的部署需要更新其 order 配置或移除它。
**SDK 提示词段。**`'code'``'both'`tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染 TypeScript 声明加固定的使用说明。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**SDK 提示词段。**`'code'``'both'`tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript[语言分发 note](2026-07-31-code-mode-language-dispatch.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。
**组装所有权。** `run_code``tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。
@@ -48,7 +48,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
**子调用上下文通过父调用延后。**`run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据
**并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 契约相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10设为 `1` 恢复串行分发exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责
**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput``run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时Web会通过通用原始内容回退机制使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy 输出落盘预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。
@@ -64,7 +64,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
- `CodeBindingNamespace = { global: string; functions: Record<string, (args: unknown) => Promise<CodeJsonValue>>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与返回值可以完整跨越实现的序列化边界。
- `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时,执行 promise 仍会 fulfill并通过 `error` 字段返回失败结果。只有调用方/seam 误用(例如重复的绑定命名空间)时,`run()` 才会 reject消费方仍在自己的错误边界处理不合规后端的拒绝。
- `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.md)独立报告的正交结果;超时的 run 不是异常abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。
- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——交付的后端为 `'typescript'`Python 后端声明自己,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'``'container'` 等)。`dsh-tools` 在 MVP 中要求 `language === 'typescript'`——其代码生成输出 TS——否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——首个后端为 `'typescript'`Python 后端声明 `'python'`,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'``'container'` 等)。`dsh-tools` 接受任何注册了 SDK 渲染器与 `run_code` flavor 的 `language`TypeScript 与 Python 已交付;见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)否则组装会大声失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。
请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会大声失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。
@@ -85,11 +85,11 @@ worker 运行时只能约束程序的运行,而不构成安全边界:模型
### 模型看到的内容
SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。即使在 `Promise.all` 下调用仍保持顺序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScriptPython 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一契约:相互独立的只读调用可以在 `Promise.all`TypeScript`asyncio.gather`Python下重叠有副作用的调用按提交顺序单独运行有依赖的工作用 `await`序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。
## 后果
切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发保持序列化,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。
切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议面的完整性。子分发在有界的重叠池下按提交顺序启动,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。
## 测试
@@ -106,7 +106,7 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw
**在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。
**循环中的并行原生分发。** 往返成本的另一个答案;仍是有效的未来工作open TODO被并发安全元数据阻塞,且无组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的序列化队列决策保持两者兼容:当元数据就绪时,原生并行分发每工具绑定并行化一起解锁。
**循环中的并行原生分发。** 决策当时对往返成本的另一个答案;被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的队列决策保持两者兼容,后续也正是这样落地的:元数据以 `isConcurrencySafe` 的形式就绪(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.md)),原生 rolling-pool 分发每工具绑定并行化基于同一个分类器一起解锁。
**始终排他(忠于 Cloudflare无模式** 否决,因为本 SDK 的主要消费方是编码 agent其日常的单次调用`bash``read``edit`作为原生调用已经是最优的强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。
@@ -128,6 +128,6 @@ SDK 指示模型编写一个异步的可擦除 TypeScript 函数体,通过 `aw
**大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。
**仅序列化的子分发。** `Promise.all` 尚未获得挂钟并行性,仅减少往返次数;模型可能过度期望。说明中已声明;解除此限制与原生并行分发 TODO 所需的并发安全元数据绑定
**子分发的重叠由工具自身的安全声明限定,而非由调用方决定** 程序里的 `Promise.all` `asyncio.gather` 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实契约。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责
**预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明「pending 的分发不能暂停它」)是抵御恶意程序的关键。两种情况均有单元测试(带 pending 诱饵分发的热循环会在耗尽 `computeMs` 预算时终止;等待慢速绑定的空闲程序则会持续运行至 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项,且会传入 `setTimeout`,后者会把超过 `MAX_TIMER_DELAY_MS`2^31-1 ms的延迟夹到 1 ms因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
2026-07-31-code-mode-language-dispatch.md: 96001252d6494d058a8df9974fb5a0d59e7d7112
2026-07-31-code-mode-language-dispatch.zh.md: aa7eb2a6b4b9117f1d707b37afcdbe12b814bad2

View File

@@ -0,0 +1,46 @@
# Agent Note: Code Mode language dispatch and the Python SDK renderer
Status: implemented
English | [中文](2026-07-31-code-mode-language-dispatch.zh.md)
## Problem
Code Mode generated one SDK flavor: TypeScript. `ToolRegistry` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime.
This is the tool-facing half of the multi-language Code Mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately.
## Decision
Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at prompt assembly, against two parallel tables in `dsh-tools`:
- `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages.
- `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree.
Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, reached by definition readers and `schemas()`, of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes `requireCodeRuntime` first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the prose that names the well-known values instead of deriving them (the seam's `dsh-code-runtime` README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair; this package's own README pair and its `Config.mode` JSDoc — no gate checks any of it), with no `agent-loop` or registry-structure change.
`code-mode.ts` depends only on the runtime seam (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer therefore lands independently of the protocol and backend PRs — it needs only the seam's `language` field, which is already on master.
### The Python SDK renderer
`py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. Lexicographic means one ordered member stream: a tool whose name is not a legal attribute is listed as a `tools[name]` comment in its sorted position rather than partitioned to the end, matching how the TypeScript flavor quotes an exotic key in place. That stream forces one thing directly — comment lines are not statements, so a tool set that emits no method at all still needs an explicit `pass`. Three further rules are Python-specific rather than consequences of the ordering. The usage contract states that the declarations are static stubs and arguments are plain `dict`/`list` values: a `TypedDict` reads as a constructible class, so a model that writes `FooArgs(field=1)` gets a `NameError` — TypeScript's `interface` is visibly a type, and the TS flavor's "runs type-stripped" clause already covers it. A description becomes the method's docstring emitted as the FIRST statement of its body: above the `async def` the first one would document the `Tools` class and the rest would be dead expressions, leaving every method undocumented. And a `list[…]` chain degrades to `Any` past `MAX_LIST_NESTING`, because CPython's tokenizer rejects a line with more than 200 open brackets and the block must stay parseable Python — the same reason `docLines` escapes quotes and backslashes. `ts-types` needs neither: TypeScript attaches a leading `/** … */` to the member that follows it and bounds nesting nowhere in its grammar.
The standard that cap serves is grammatical validity, and the boundary is deliberate: a long `A | B | …` union is valid Python at any length and is left uncapped, even though CPython's `compile()` exhausts its C recursion walking the left-nested `BinOp` spine (measured on 3.9: 1,000 branches compile, 5,000 raise `RecursionError`). Nothing compiles this block — it is prompt text — so that limit costs nothing, whereas capping union length would retire the deep-chain tests that pin the walk's linear time and the class-name propagation cap. A future renderer that does need compilable output should flatten unions rather than truncate them.
`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party registration (a `defineTool` literal or a raw registration) or a wire-derived plain JSON schema — the former is trusted per AGENTS.md, the latter is a `JSON.parse` product that physically cannot carry accessors, and `renderType` re-validates the whole tree on every call regardless — so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one".
## Alternatives considered
- **A `language` config field on `ToolRegistry`.** Deployment would then have two places to name the language (the loaded runtime and the tools config) that can disagree; the loaded runtime is the single source of truth, so the registry reads it rather than duplicating it.
- **Importing the Python backend into `code-mode.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable.
- **A default renderer for an unknown language.** A silent fallback would emit a TypeScript SDK over, e.g., a Ruby runtime — the model would see instructions in the wrong language. Failing loud at assembly is the repository's misconfiguration stance.
## Consequences
Adding a backend language is three parallel edits — a `CodeSdkLanguage` member, an `SDK_RENDERERS` entry, and a `RUN_CODE_FLAVORS` entry — plus the renderer function the second points at, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step, and that invariant is checked statically rather than left to review: both are `satisfies`-checked against that one union, so a language added to one and not the other fails `typecheck`. This is the mechanical form the drift risk deserves — the runtime `Object.hasOwn` guards would catch it too, but only once a backend reporting that language ships: one PR after the drift, at the consumer's integration point rather than where it was introduced, and on this base never, since no second backend exists. The tables keep their `Record<string, …>` declared type because `CodeRuntime.language` is an unconstrained `string`; the union pins what the harness ships, the guards reject what a runtime reports. What stays outside that check is the prose that names the well-known values instead of deriving them: `dsh-code-runtime`'s README pair, its `CodeRuntime.language` JSDoc, and the `docs/core-data-structures/code-runtime.md` pair at the seam, plus this package's own README pair and its `Config.mode` JSDoc. Earlier notes name the values as the state at their own PR and are not on that list. Two separate reasons keep it ungated. Prose is not type-checked at all, wherever the union lives. And no type-level pin can stand in for it here: the interface package must not import its consumer's table, and `CodeRuntime.language` stays an unconstrained `string` by design, so moving the union into the seam would not apply it either. A unit test pinning the two key sets equal was rejected in favor of this: it would buy the same check at the cost of a test-only export of two private tables, and would run later than the compiler does. Which of the two runtime failures surfaces depends on the entry point, for a language absent from both tables: assembly reports the missing renderer, because `wireSchemas` calls `requireCodeRuntime` before projecting, while the public `schemas()` reaches `run_code`'s language-aware getters first and reports the missing flavor. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend.
The cost is that the Python branch of both tables is unreachable on this base: `CodeRuntime.language` is set by the loaded backend, the only published backend is `dsh-code-runtime-worker` (`'typescript'`), and the registry reads the loaded runtime rather than a config field, so no assembled application can select `renderToolsSdkPy` or `PYTHON_FLAVOR`. The model-visible surface is therefore unchanged by this note's work until a backend reporting `'python'` is published, and this PR's coverage is unit-level — the renderer output plus the dispatch and rejection paths. The keyless snapshot for the Python model interface belongs to the PR that publishes that backend, because only there does a real `cordis.yml` over published plugins produce a Python assembly; a snapshot example that mounted a fixture runtime here would assert against a test double, which [docs/testing.md](../../../../docs/testing.md) rejects as a substitute for the assembled application transcript.
Two runtime contracts the Python SDK text asserts are owed by that same backend PR. First, the instructions tell the model that exactly `tools` and `ToolCallError` are bound and that the declared `TypedDict` classes are not, so the backend must inject those two names — with `ToolCallError.toolName` populated per the seam's `errorClass` contract — and must NOT bind the declared class names into the program's globals; injecting them "helpfully" would make the SDK text false. Second, the language has to be bound to the request: `requireCodeRuntime` resolves `ctx.codeRuntime` separately at assembly and at `run_code` execution, so a reload that swapped the runtime between those two points would hand a program written against one flavor to the other. The split is finer than those two points — `run_code`'s `description` and `parameters` getters each call `resolveFlavor(peekRuntime())`, and `schemaOf` destructures both, so one projection reads the runtime twice; both reads are for `run_code`'s own schema, since the getters are installed on that one definition and every other definition carries plain data properties. A reload between those two reads yields a single schema whose two halves name different languages. Neither is reachable here — one published backend means both reads return the same flavor and no program ever runs against this renderer's output — and the cross-language rejection is not testable until a second language exists.
Third, that PR owns the CPython floor, and with it the renderer's Unicode-table skew. Four expressions read the running engine's tables (Node 22.23.1: Unicode 17.0) while the interpreter uses its own (CPython 3.9.6: 13.0.0): `isBareIdentifier`'s `IDENTIFIER`, and `camelCase`'s split set, head test, and `toUpperCase()`. An interpreter older than the engine is the failing direction — the engine emits a character its tokenizer refuses, taking the whole block down — and it arrives by three independent paths. Through the predicate, a bare method or field name carrying a character added between the two versions — to `XID_Start` at its head, or to `XID_Continue` in any tail position, the middle of a name included. Through `camelCase`'s XID reads, a class name, which reaches emitted text whenever any object shape in the tool's schema declares a `TypedDict`, and which the predicate's verdict on the tool name does not gate: `zz-` plus U+1E4D0 never reaches the predicate's skew, since the `-` rejects it outright, yet it still declares `class Zz𞓐xArgs`. Through the case mapping, a class name derived from a tool the predicate accepted — a different table and a wider window than XID membership: U+019B is XID_Start and NFKC-stable, so `async def ƛ` compiles on 3.9.6, but Node uppercases it to U+A7DC (unassigned there; CPython's own `.upper()` is the identity) and `class ꟜArgs` fails with `invalid non-printable character U+A7DC`. The exposure window is the characters and mappings that changed between the two versions, so the PR that names a supported CPython range must decide explicitly between accepting it and pinning all four read points to tables for that floor — pinning the predicate alone leaves both class-name paths open. Nothing here can decide it: the floor does not exist yet, and a table pinned to a guess would be a deployment-varying constant with no configurability behind it. A second axis rides along with the floor and is not one of the four: the names and syntax the block would evaluate at definition time. `TypedDict` needs 3.8, the PEP 585 builtin generics `dict[str, Any]` and `list[…]` need 3.9, an `A | B` annotation 3.10, and `NotRequired` 3.11. These are not parse failures — the block parses on any version, which is the standard the `MAX_LIST_NESTING` cap serves — but definition-time evaluation failures, and nothing in the product evaluates this text. Recording them with the read points keeps "parseable on the supported range" from being read as "executable on it".

View File

@@ -0,0 +1,46 @@
# Agent Note: Code Mode 语言分发与 Python SDK 渲染器
Status: implemented
[English](2026-07-31-code-mode-language-dispatch.md) | 中文
## 问题
Code Mode 只生成一种 SDK 形态TypeScript。`ToolRegistry``tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。
这是多语言 Code Mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。
## 决策
语言选择就是对 `ctx.codeRuntime.language` 的查表,在 prompt 装配时惰性解析,查 `dsh-tools` 里两张平行的表:
- `SDK_RENDERERS`index.ts把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk``python → renderToolsSdkPy``tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。
- `RUN_CODE_FLAVORS`code-mode.ts把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description``code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。
两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies``typecheck` 处拒绝两个守卫都看不到这种输入它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,由直读 definition 的读者与 `schemas()` 到达,其中 doc-catalog 采集是唯一已交付的一个,而它们都不会喂给模型,因为组装路径先过 `requireCodeRuntime`)降级到 TypeScript flavor而挂载了未知语言则 fail loud——这不是下方被否决的静默回退那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器以及点名已知值而非从中派生的散文seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/core-data-structures/code-runtime.md` 双语对;本包自己的 README 双语对与它的 `Config.mode` JSDoc无任何 gate 检查其中任何一处),不动 `agent-loop`,也不动注册表结构。
`code-mode.ts` 只依赖运行时 seam`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。
### Python SDK 渲染器
`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有三条规则并非源自排序,而是 Python 特有。其一,用法约定声明这些声明只是静态存根、参数为普通 `dict`/`list` 值:`TypedDict` 读起来像一个可构造的类,模型若写 `FooArgs(field=1)` 会得到 `NameError`——TypeScript 的 `interface` 一眼就是类型,且 TS 形态的「runs type-stripped」一句已经覆盖了它。其二描述会成为方法的 docstring且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其三,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。
该上限服务的标准是**语法合法性**,这条边界是有意划定的:长的 `A | B | …` union 在任何长度下都是合法 Python故不设上限——尽管 CPython 的 `compile()` 在沿左嵌套 `BinOp` 脊柱下降时会耗尽 C 递归(在 3.9 上实测1,000 个分支可编译5,000 个抛 `RecursionError`)。没有任何东西会编译这个块——它是提示词文本——所以那条限制在这里没有代价;而给 union 长度封顶会作废那几个钉住 walk 线性时间与类名传播上限的深链测试。将来若有渲染器确实需要可编译的输出,应当把 union 拍平,而不是截断。
`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数输入是第一方注册`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」而非「扛住对抗性的可变 schema」。
## Alternatives considered
- **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。
- **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR 先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。
- **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。
## Consequences
新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS``RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR且触发点在消费方的集成处而非漂移引入处在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`union 钉住本仓库交付了什么守卫拒绝运行时报告了什么。落在这条检查之外的是点名已知值而非从中派生的散文seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/core-data-structures/code-runtime.md` 双语对,再加本包自己的 README 双语对与它的 `Config.mode` JSDoc。更早的 note 点名这些值时记的是其自身 PR 当时的状态,不在此列。让它无 gate 的是两条独立理由。其一散文根本不受类型检查union 放在哪里都一样。其二,类型级替代在这里也不可用:接口包不得 import 其消费方的表,而 `CodeRuntime.language` 按设计保持不受约束的 `string`,即便把 union 迁进 seam 也不会作用到它。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。
代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker``'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy``PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR因为只有在那里一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。
Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一说明文字告诉模型运行时恰好绑定 `tools``ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code``description``parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。
其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表Node 22.23.1Unicode 17.0而解释器用它自己的表CPython 3.9.613.0.0`isBareIdentifier``IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC在那里未分配CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs``invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。还有第二条轴随该下限一同确定,且不属于那四个读取点:本块在定义期会被求值的那些名字与语法。`TypedDict` 需要 3.8PEP 585 的内建泛型 `dict[str, Any]``list[…]` 需要 3.9`A | B` 形式的注解需要 3.10`NotRequired` 需要 3.11。这些不是解析失败——本块在任何版本上都能解析,这正是 `MAX_LIST_NESTING` 上限所服务的标准——而是定义期求值失败,且产品中没有任何东西会求值这段文本。把它们与那四个读取点记在一起,可避免把「在所支持范围上可解析」读成「在其上可执行」。

View File

@@ -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-07-24-web-gui-browser-e2e-lane.md
2026-07-24-web-gui-browser-e2e-lane.md: f8519a9622d2f7216226a695db95dbebdbf24ea1
2026-07-24-web-gui-browser-e2e-lane.zh.md: 294f3e840e0242d9a0d9c53ac510d44d3b0d100f
2026-07-24-web-gui-browser-e2e-lane.md: 095ad9055c8462ecec67609ed9af55df2a2d7adb
2026-07-24-web-gui-browser-e2e-lane.zh.md: eb20ee378eb5154bafcb7a448202c66dacda9199

View File

@@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module,
### Coverage contract
The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds.
The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. The required lane additionally carries an 88-turn synthetic Chat scroll contract mixing wrapped Markdown, fenced code, and paired bash calls/results. Real wheel, composer, tool, tab, session, and viewport interactions assert a named settled row's top relative to the transcript scrollport and distance from the true bottom across concurrent history prepend plus paced streaming, pinned/away streaming, tool-disclosure offscreen cycles, expanded-history view/session remount, width reflow, immediate pinned remount, composer resize, and textarea wheel chaining; real keyboard paging and a touch-style fling emulation additionally pin wheel-free bottom-follow ownership ([reader-scroll-attribution note](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md)); it deliberately pins neither DOM cardinality nor absolute `scrollTop`, so the same contract can qualify a virtualized implementation. A separate interaction contract over the same fixture pins heterogeneous-row order, independent adjacent tool disclosure, exact user-message clipboard content, a turn-bounded message fork, source/child isolation, and a real follow-up turn in the child; wheel input only navigates to semantic targets and carries no geometry expectation. A short live-history contract starts from a blank workspace and drives consecutive composer turns, including real bash call/result rounds and a paced long final response, pinning one session identity, exact per-turn event ownership, browser echo uniqueness, and composer recovery without timing thresholds.
### CI stance

View File

@@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
### 覆盖契约
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离以及子会话中的一次真实追问轮次wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。
该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。必需车道还包含一份合成的 88 轮 Chat 滚动契约,其中混合了换行 Markdown、围栏代码以及成对的 bash 调用/结果。真实 wheel、输入框、工具、tab、会话与 viewport 交互会在并发历史前插加带节奏流式输出、贴底/离底流式输出、工具 disclosure 离屏循环、扩展历史后的视图/会话重新挂载、宽度重排、贴底后立即重新挂载、输入框尺寸变化以及 textarea wheel 链场景中,断言一个具名已结算行相对 transcript scrollport 的顶部位置和到真实底部的距离;真实键盘翻页与触摸式惯性滑动模拟额外钉住不依赖 wheel 的贴底跟随所有权([读者滚动归因笔记](../bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md)它刻意不钉 DOM 基数或绝对 `scrollTop`,因此同一契约可以验收虚拟化实现。另一份基于同一 fixture 的交互契约钉住异构行顺序、相邻工具 disclosure 的独立状态、用户消息剪贴板内容的精确值、以轮次为边界的消息 fork、源会话/子会话隔离以及子会话中的一次真实追问轮次wheel 输入只用于导航到语义目标,不承载几何预期。一份简短的实时历史契约从空白工作区开始,连续驱动输入框轮次,其中包括真实的 bash 调用/结果轮次和一段带节奏的长篇最终响应;它钉住单一会话身份、每轮事件的精确归属、浏览器回显唯一性与输入框恢复,不设置时间阈值。
### CI 立场

View File

@@ -40,6 +40,11 @@ const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
const INPUTS_SESSION_ID = 'chat-scroll-inputs-e2e'
const FLING_SESSION_ID = 'chat-scroll-fling-e2e'
const LIVE_FLING_PROMPT = 'CHAT_SCROLL_FLING_USER Keep streaming while I fling back through older output.'
const LIVE_FLING_FIRST = 'CHAT_SCROLL_FLING_STREAM_FIRST'
const LIVE_FLING_DONE = 'CHAT_SCROLL_FLING_STREAM_DONE'
const HISTORY_FIXTURE = createChatScrollFixture({
markerPrefix: 'HISTORY',
@@ -58,6 +63,10 @@ const RESTORE_FIXTURE_B = createChatScrollFixture({
title: 'CHAT_SCROLL_RESTORE_B comparison session',
turns: 32,
})
const INPUTS_FIXTURE = createChatScrollFixture({
markerPrefix: 'INPUTS',
title: 'CHAT_SCROLL_INPUTS non-wheel reader input session',
})
interface ScrollGeometry {
readonly distanceFromBottom: number
@@ -273,6 +282,34 @@ async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
await nextPaint(page)
}
/**
* Touch-style momentum fling over the transcript. Headless Chromium in the
* test lane cannot synthesize device scrolling (Input.synthesizeScrollGesture
* and Input.dispatchTouchEvent both deliver DOM events without moving any
* scroller, and compositor scrollbars ignore synthetic mouse input), so the
* fling replays the signature a real pan leaves on the scrollport: per-frame
* decaying displacements the component never authored, carrying no wheel
* events. Wheel-sign semantics: positive deltaY reads downward.
*/
async function flingTranscript(page: Page, deltaY: number): Promise<void> {
await page.locator('[data-conversation-scroll]').evaluate(async (host, delta) => {
const direction = Math.sign(delta)
let remaining = Math.abs(delta)
// Fast launch decaying toward a floor speed, like a released finger. The
// floor stays above the follow threshold so contended frames (streaming
// writes racing the fling) still deviate far enough to read as input.
let velocity = Math.max(120, remaining / 8)
while (remaining > 0) {
const step = Math.min(velocity, remaining)
host.scrollTop += direction * step
remaining -= step
velocity = Math.max(48, velocity * 0.9)
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
}
}, deltaY)
await nextPaint(page)
}
async function wheelToHistoryStart(page: Page): Promise<void> {
for (let attempt = 0; attempt < 12; attempt += 1) {
if ((await scrollGeometry(page)).scrollTop <= 1) break
@@ -683,4 +720,112 @@ describe('web e2e: long Chat scroll contract', () => {
assertClean(world)
})
}, 180_000)
// Keyboard is the only non-wheel device this lane's Chromium can drive for
// real (see flingTranscript for the probe results on touch and scrollbars),
// so it stands in for the whole hardware input pipeline here.
it.skipIf(MODE === 'record')('keyboard paging owns bottom-follow without wheel input', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-keyboard',
seeds: [{ fixture: INPUTS_FIXTURE, id: INPUTS_SESSION_ID }],
}, async (world) => {
await openSeed(
world.page,
INPUTS_FIXTURE,
INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns),
)
await expectBottom(world.page)
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
// Focus rides the last seeded tool row (a tabbable button whose keydown
// handler passes scrolling keys through). End first normalizes the
// focus-driven scrollIntoView back to the floor.
const lastToolRow = world.page.locator(
`[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
)
await lastToolRow.focus()
await world.page.keyboard.press('End')
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
for (let press = 0; press < 3; press += 1) {
await world.page.keyboard.press('PageUp')
await nextPaint(world.page)
}
await backToBottom.waitFor({ timeout: 10_000 })
await expect.poll(async () => (await scrollGeometry(world.page)).distanceFromBottom, { timeout: 10_000 })
.toBeGreaterThan(100)
await world.page.keyboard.press('End')
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
assertClean(world)
})
}, 180_000)
it.skipIf(MODE === 'record')('touch-style fling scrolling owns streaming bottom-follow without wheel input', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-scroll-fling-stream',
replay: [
replayEntry(toolStream()),
replayEntry(textStream(LIVE_FLING_FIRST, LIVE_FLING_DONE, 240)),
],
seeds: [{ fixture: INPUTS_FIXTURE, id: FLING_SESSION_ID }],
}, async (world) => {
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
await openSeed(world.page, INPUTS_FIXTURE, INPUTS_FIXTURE.markers.assistant(INPUTS_FIXTURE.turns))
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
const settled = world.scaffold.whenTurnSettled(60_000)
let released = false
try {
const composer = world.page.locator('textarea:enabled').last()
await composer.fill(LIVE_FLING_PROMPT)
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
await expectBottom(world.page)
// Fling away while the turn is mid-flight: the scroll burst alone must
// release bottom ownership, exactly like a wheel scroll would, even
// while streaming keeps re-asserting the floor between frames.
await flingTranscript(world.page, -900)
await backToBottom.waitFor({ timeout: 10_000 })
const awayAnchor = await visibleFlowAnchor(world.page)
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
await writeFile(releasePath, 'release\n')
released = true
await expect.poll(
() => world.events.some(event => event.type === 'tool/result'),
{ timeout: 15_000 },
).toBe(true)
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksBeforeRelease + 5)
await expectSameFlowTop(world.page, awayAnchor)
// Fling back to the floor: re-pin must come from the reader's scroll
// itself, and follow must then own the still-streaming tail. The
// retry loop chases the floor that streaming keeps pushing down.
for (let attempt = 0; attempt < 8; attempt += 1) {
if ((await scrollGeometry(world.page)).distanceFromBottom <= 1) break
await flingTranscript(world.page, 1_600)
}
await expectBottom(world.page)
await expect.poll(() => backToBottom.count(), { timeout: 10_000 }).toBe(0)
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
await expect.poll(
() => world.events.filter(event => event.type === 'assistant/chunk').length,
{ timeout: 15_000 },
).toBeGreaterThan(chunksAtRepin + 5)
await expectBottom(world.page)
} finally {
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
}
await settled
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
await world.page.getByText(LIVE_FLING_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
await expectBottom(world.page)
assertClean(world)
})
}, 180_000)
})

View File

@@ -1,15 +1,17 @@
// Web e2e scenario: the Models settings page end to end through the real
// wire — the add card offers the dormant pi-ai catalog, typing an API key
// wire — the add card offers the dormant pi-ai catalog, a blank key saves a
// reference-free profile for provider-native auth, and typing an API key later
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
// while the settings document records only that reference; the saved row
// appears after the route topology invalidation without presenting liveness
// as provider status. The customized-settings fold writes the curated
// while the settings document records only that reference. Each saved row
// appears after route topology invalidation without presenting liveness as
// provider status. The customized-settings fold writes the curated
// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
// never shadow the derived reference. Removing that row is guarded by the
// localized provider-confirmation dialog before the unset reaches the wire.
// never shadow the derived reference. The deletion dialog distinguishes a
// reference-free profile from a page-managed key before the credential and
// settings unsets reach the wire.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -26,6 +28,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
@@ -71,34 +74,71 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(options).toContain('anthropic')
expect(options).toContain('minimax-cn')
await pick.selectOption('minimax-cn')
await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
}, 60_000)
it('stores the key under the derived reference and the route registers live', async () => {
it('saves a blank key as a reference-free provider-native profile', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '保存', exact: true }).click()
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0)
expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0)
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('minimax-cn: {}')
expect(document).not.toContain('MINIMAX_CN_API_KEY')
}, 60_000)
it('describes reference-free deletion without claiming a credential exists', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="删除 minimax-cn"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
}, 60_000)
it('stores the key under the derived reference and keeps the route live', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The profile lands in settings.yaml with only the derived reference, the
// key value lands in the harness home's .env, the dormant route
// registers, and the topology frame invalidates the page into the row.
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
await expect.poll(
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(0)
await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 })
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
const credentialFile = join(scaffold.harnessHome, '.env')
await expect.poll(
async () => readFile(credentialFile, 'utf8').catch(() => ''),
{ timeout: 10_000 },
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
it('applies a customized-settings field as a merge patch', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.getByRole('button', { name: '编辑' }).click()
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
await dialog.getByText('自定义设置').click()
const effort = dialog.getByLabel('推理强度')
await effort.waitFor({ timeout: 10_000 })
@@ -107,6 +147,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
// The editor closes back to the row; the fold's write merged into the
// stored profile beside the reference.
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('reasoning: high')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
@@ -149,35 +190,32 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('confirms provider deletion before removing its settings profile', async () => {
it('confirms an identified provider deletion before removing its profile and key', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
const settingsDialog = page.getByRole('dialog', { name: '设置' })
// Two rows carry a delete action now that a route is also declared; this
// scenario is about minimax-cn, so it names its own row.
const minimaxRow = settingsDialog.locator('li').filter({ hasText: 'minimax-cn' }).first()
await minimaxRow.getByRole('button', { name: '删除', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn' })
await deleteDialog.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(
page,
'[role="dialog"][aria-label="删除模型提供方"]',
'[role="dialog"][aria-label="删除 minimax-cn"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
await minimaxRow.getByRole('button', { name: '删除', exact: true }).click()
await page.getByRole('dialog', { name: '删除模型提供方' })
.getByRole('button', { name: '删除提供方', exact: true }).click()
await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
await page.getByRole('dialog', { name: '删除 minimax-cn' })
.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
await expect.poll(
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
.toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
.not.toContain('MINIMAX_CN_API_KEY')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除模型提供方' }).count(),
async () => page.getByRole('dialog', { name: '删除 minimax-cn' }).count(),
{ timeout: 10_000 },
).toBe(0)
await page.keyboard.press('Escape')
@@ -185,7 +223,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR,
['configured.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
'empty.expected.md', 'native-delete.expected.md',
])
})
})

View File

@@ -13,11 +13,13 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- status: 已保存 minimax-cn。
- list:
- listitem:
- text: minimax-cn
- button "编辑"
- button "删除"
- img "API 密钥已配置"
- button "编辑 minimax-cn": 编辑
- button "删除 minimax-cn": 删除
- button "添加提供方":
- img
- text: 添加提供方

View File

@@ -1,7 +1,7 @@
- dialog "删除模型提供方":
- heading "删除模型提供方" [level=2]
- dialog "删除 minimax-cn":
- heading "删除 minimax-cn" [level=2]
- button "关闭":
- img
- paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型
- paragraph: 删除 minimax-cn 会移除其配置和存储的 API 密钥
- button "取消"
- button "删除提供方"
- button "删除 minimax-cn"

View File

@@ -55,7 +55,7 @@
- option "zai-coding-cn"
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥
- /placeholder: 输入 API 密钥,或留空使用环境认证
- group: 自定义设置
- button "取消"
- button "保存"

View File

@@ -0,0 +1,7 @@
- dialog "删除 minimax-cn":
- heading "删除 minimax-cn" [level=2]
- button "关闭":
- img
- paragraph: 删除 minimax-cn 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。
- button "取消"
- button "删除 minimax-cn"

View File

@@ -16,7 +16,8 @@
- list:
- listitem:
- text: DeepSeek
- button "编辑"
- img "API 密钥已配置"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: DeepSeek deepseek-official API 密钥
- textbox "API 密钥":
- /placeholder: 已配置——输入新值可替换

View File

@@ -678,7 +678,10 @@ Requires: `llm`
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
/**
* Trimmed literal API key; whitespace-only is absent. Prefer
* {@link apiKeyEnv} to keep secrets out of configuration files.
*/
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
@@ -2228,8 +2231,9 @@ export interface Config {
/**
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
* Code modes require a TypeScript runtime and fail prompt assembly when it is
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
* Code modes require a `ctx.codeRuntime` whose `language` has a registered
* SDK renderer (TypeScript or Python) and fail prompt assembly when it is
* absent or has no renderer. Under `code`, native names in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
/**
@@ -2246,7 +2250,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'code' | 'both'
```
Source: [`packages/core/tools/src/index.ts:592`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:616`](../packages/core/tools/src/index.ts)
## `@deepseek-ai/dsh-typert-loader`

View File

@@ -842,7 +842,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
'tools/change'(): void
```
Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:191`](../../packages/core/tools/src/index.ts)
### `tools/code-dispatch-log` — waterfall
@@ -866,7 +866,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:173`](../../packages/core/tools/src/index.ts)
### `tools/execute` — waterfall
@@ -888,7 +888,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:124`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:148`](../../packages/core/tools/src/index.ts)
### `tools/post-execute` — waterfall
@@ -911,7 +911,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
### `tools/pre-execute` — waterfall
@@ -932,7 +932,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:137`](../../packages/core/tools/src/index.ts)
### `tools/result` — emit
@@ -951,7 +951,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:157`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:181`](../../packages/core/tools/src/index.ts)
## `workflow/*`

View File

@@ -398,7 +398,7 @@ abstract run(request: CodeRunRequest): Promise<CodeRunResult>
Types: [CodeRunRequest](../core-data-structures/code-runtime.md) · [CodeRunResult](../core-data-structures/code-runtime.md)
Source: [`packages/code-runtime/code-runtime/src/index.ts:33`](../../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`
@@ -2523,7 +2523,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/index.ts)
## `ctx.typert` — `TypertRegistry`

View File

@@ -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/core-data-structures/code-runtime.md
code-runtime.md: 64de3c45d4f1d1d981daa6c6f074abb667e0aa52
code-runtime.zh.md: daf07aaf613852a6c4a7b1aff152fcc61052fbca
code-runtime.md: fbce7d812b7609716fb43ae01610253008e0a92c
code-runtime.zh.md: 700146cfaa9cfab37ec4d85e550020acf1d9f294

View File

@@ -72,9 +72,14 @@ Each `CodeBindingNamespace` becomes one global object of async callables inside
* of a particular consumer such as Code Mode.
*/
interface CodeBindingErrorClass {
/** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
/** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */
name: string
/** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
/**
* Non-empty own property for the member name. The portable exclusion set is
* `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty
* middle), enforced identically by every backend; any other name —
* identifiers or not — is accepted everywhere.
*/
memberNameProperty: string
}
```
@@ -88,7 +93,16 @@ interface CodeBindingErrorClass {
* collisions.
*/
interface CodeBindingNamespace {
/** The global identifier the program sees (must be a valid JS identifier). */
/**
* The global identifier the program sees. Must match the LANGUAGE-PORTABLE
* identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved
* words, so the same namespace list works against every backend regardless
* of `language` — a JS-only spelling like `$tools` is rejected by design,
* not just by the Python backend. Names that satisfy the identifier rule but
* name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`,
* `__dsh_main__`) are also refused everywhere; see its declaration for the
* exact set and why each entry is reserved.
*/
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record<string, CodeBindingFunction>
@@ -144,4 +158,4 @@ interface CodeRunFailure {
## The service
`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` is the well-known value; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes.
`CodeRuntime` (`ctx.codeRuntime`, abstract — defined in [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts)) is `run(request)` plus two readonly descriptors: `language` (what the program must be written in — `'typescript'` and `'python'` are the well-known values, those `dsh-tools` presents, and only `'typescript'` has a published backend; a consumer generating language-specific presentation switches on it and fails loud on one it cannot present) and `isolation` (the execution substrate — `'worker-thread'`, `'process'`, `'container'`; a diagnostic label, **not a security claim**). Implementations must keep runs isolated from each other (no cross-run state) and dispose to quiescence: in-flight runs are terminated and awaited before teardown completes.

View File

@@ -72,9 +72,14 @@ interface CodeRunResult {
* of a particular consumer such as Code Mode.
*/
interface CodeBindingErrorClass {
/** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
/** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */
name: string
/** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
/**
* Non-empty own property for the member name. The portable exclusion set is
* `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty
* middle), enforced identically by every backend; any other name —
* identifiers or not — is accepted everywhere.
*/
memberNameProperty: string
}
```
@@ -88,7 +93,16 @@ interface CodeBindingErrorClass {
* collisions.
*/
interface CodeBindingNamespace {
/** The global identifier the program sees (must be a valid JS identifier). */
/**
* The global identifier the program sees. Must match the LANGUAGE-PORTABLE
* identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved
* words, so the same namespace list works against every backend regardless
* of `language` — a JS-only spelling like `$tools` is rejected by design,
* not just by the Python backend. Names that satisfy the identifier rule but
* name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`,
* `__dsh_main__`) are also refused everywhere; see its declaration for the
* exact set and why each entry is reserved.
*/
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record<string, CodeBindingFunction>
@@ -144,4 +158,4 @@ interface CodeRunFailure {
## 服务
`CodeRuntime``ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,`'typescript'` 是已知值;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose资源释放时等待系统完全停稳teardown 要等到所有进行中的运行均已终止并结算后才完成。
`CodeRuntime``ctx.codeRuntime`,抽象服务,定义于 [`packages/code-runtime/code-runtime/src/index.ts`](../../packages/code-runtime/code-runtime/src/index.ts))由 `run(request)` 加两个只读描述符组成:`language`(程序必须使用的语言,已知值为 `'typescript'` 与 `'python'`,即 `dsh-tools` 能呈现的那些,其中只有 `'typescript'` 有已发布的后端;生成语言相关展示的消费方据此切换,遇到无法展示的语言时应显式报错)和 `isolation`(执行基底,`'worker-thread'`、`'process'`、`'container'`;仅为诊断标签,**不构成安全承诺**)。实现必须保证各次运行彼此隔离(无跨运行状态),并在 dispose资源释放时等待系统完全停稳teardown 要等到所有进行中的运行均已终止并结算后才完成。

View File

@@ -44,12 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:124`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:148`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:137`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

View File

@@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s
| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
| --- | --- | --- | --- | --- | --- |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session; ConPTY is roadmap work), with native `C:\...` paths and `$env:NAME` variables. |
@@ -139,7 +139,7 @@ Execute a TypeScript program against the available tools. Write the BODY of an a
Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts)
Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.
Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.
## `@deepseek-ai/dsh-plan-mode`

View File

@@ -372,9 +372,6 @@ export function ChatView({
const [atBottom, setAtBottom] = useState(true)
/** Last position delivered or written on the main thread. */
const observedTopRef = useRef(0)
/** Pre-input position for the current wheel gesture. */
const wheelStartRef = useRef<number | null>(null)
const wheelEpochRef = useRef(0)
/** Paging anchor: semantic row/position at click, updated by reader scrolls
* while the request is pending and restored after the prepend lands. */
const anchorRef = useRef<PagingAnchor | null>(null)
@@ -394,8 +391,6 @@ export function ChatView({
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
const toBottom = (el: HTMLElement): void => {
wheelStartRef.current = null
wheelEpochRef.current += 1
anchorRef.current = null
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
@@ -472,17 +467,19 @@ export function ChatView({
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
if (local === null) return
const el = scrollerOf(local)
// Only wheel input may make raw scroll geometry change follow ownership.
// Browser clamping and delayed programmatic scroll events otherwise have
// the same event shape and must preserve the current ownership state.
// Only reader input may make raw scroll geometry change follow ownership:
// a delivered position that deviates from the observed-top ledger (every
// programmatic write records itself there synchronously). This covers
// wheel, touch, scrollbar, and keyboard alike without naming devices.
// Browser shrink-clamps land exactly on the floor min and delayed
// programmatic deliveries land on the ledger itself, so both preserve
// the current ownership state.
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const wheelStart = wheelStartRef.current
const movedByWheel = wheelStart !== null
&& Math.abs(el.scrollTop - Math.min(wheelStart, floor)) > 0.5
const isAtBottom = movedByWheel
const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > 0.5
const isAtBottom = movedByReader
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
: atBottomRef.current
if (!movedByWheel && isAtBottom) {
if (!movedByReader && isAtBottom) {
toBottom(el)
return
}
@@ -501,34 +498,18 @@ export function ChatView({
observedTopRef.current = el.scrollTop
}
// Bind scroll and the wheel provenance needed to distinguish reader input
// from layout-driven scrolls on the resolved scrollport once per mount.
// Bind the scroll listener on the resolved scrollport once per mount;
// reader-input attribution rides the observed-top ledger, not per-device
// input listeners.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
const onScroll = (): void => { onScrollRef.current() }
const onWheel = (event: WheelEvent): void => {
if (event.ctrlKey || event.deltaY === 0) return
const startTop = observedTopRef.current
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const canMove = event.deltaY < 0 ? startTop > 1 : startTop < floor - 1
if (!canMove) return
wheelStartRef.current = startTop
const epoch = ++wheelEpochRef.current
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (wheelEpochRef.current === epoch) wheelStartRef.current = null
})
})
}
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('wheel', onWheel, { capture: true, passive: true })
return () => {
wheelStartRef.current = null
el.removeEventListener('scroll', onScroll)
el.removeEventListener('wheel', onWheel, true)
}
}, [])

View File

@@ -158,9 +158,9 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
}
/** Simulate reader input before the browser delivers the host scroll event. */
/** Simulate reader input (any device): a delivered position that deviates
* from the observed-top ledger of programmatic writes. */
function readerScroll(element: HTMLElement, top: number): void {
fireEvent.wheel(element, { deltaY: top < element.scrollTop ? -120 : 120 })
element.scrollTop = top
fireEvent.scroll(element)
}
@@ -939,7 +939,7 @@ describe('ChatView', () => {
expect(view.queryByLabelText('回到底部')).toBeNull()
})
it('keeps following when a delayed clamp scroll arrives after layout regrows', () => {
it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -947,12 +947,12 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// The wheel cannot move farther down. A stream-finalization shrink clamps
// the old position, then reflow grows the layout before scroll delivery.
fireEvent.wheel(scroller, { deltaY: 120 })
metrics.setLayout(1_040, 500)
// Stream finalization shrinks the column: the browser clamps the pinned
// position onto the new floor and delivers a scroll event. The clamp
// lands exactly on the ledger's floor min, so it is not reader input.
metrics.setLayout(800, 700)
fireEvent.scroll(scroller)
expect(scroller.scrollTop).toBe(740)
expect(scroller.scrollTop).toBe(500)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
@@ -961,7 +961,7 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(900)
})
it('uses the last delivered top when compositor scrolling precedes passive wheel delivery', () => {
it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
@@ -969,8 +969,10 @@ describe('ChatView', () => {
scroller.scrollTop = 700
fireEvent.scroll(scroller)
// Chromium advances compositor geometry before delivering the event:
// attribution must compare against the observed-top ledger, never a
// baseline sampled from already-moved raw geometry.
scroller.scrollTop = 500
fireEvent.wheel(scroller, { deltaY: -200 })
fireEvent.scroll(scroller)
expect(view.getByLabelText('回到底部')).toBeTruthy()
})

View File

@@ -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-models/README.md
README.md: ea3efd5b0a7ee3599fda74cd9a361222170c473d
README.zh.md: 96290d6e56f36d485ea3e0b661197eda4cc40c09
README.md: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876
README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model list and endpoint interrogation
@@ -29,7 +29,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.

View File

@@ -4,11 +4,11 @@
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek或 `reasoning`pi-ai以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile因此能保留提供方原生认证例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另有 `reasoningEffort`deepseek或 `reasoning`pi-ai以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器其路由保持无标签不会被当成内置。
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带卡片打开时`revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前`revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问
@@ -29,7 +29,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
## 已知限制与暂缓事项
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)。DeepSeek 公开 `baseURL``reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`pi-ai 公开 `baseURL``reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile却刻意不清除那条派生凭据重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。

View File

@@ -38,6 +38,13 @@
color: var(--dsw-alias-state-warn-label);
}
.savedNotice {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-success-primary);
}
.rows {
list-style: none;
/* Extra air between the title/intro block and the first provider card. */
@@ -65,6 +72,13 @@
gap: 10px;
}
.rowIdentity {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rowName {
font-size: 14px;
line-height: 22px;
@@ -73,9 +87,9 @@
}
/* Reads as an annotation on the name, not as a second name: caption size and
the secondary label tone, so it never competes with the row's own title.
`rowActions` keeps the `margin-left: auto`, which is what holds the tag
beside the name instead of letting it drift across the row. */
the secondary label tone, so it never competes with the row's own title. It
sits inside `rowIdentity` with the credential dot, which is what keeps it
beside the name rather than drifting toward the actions. */
.rowTag {
flex: none;
padding: 1px 6px;
@@ -86,6 +100,23 @@
color: var(--dsw-alias-label-secondary);
}
.credentialDot {
box-sizing: border-box;
display: inline-block;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
}
.credentialDotConfigured {
background: var(--dsw-alias-state-success-primary);
}
.credentialDotMissing {
background: var(--dsw-alias-state-error-primary);
}
.rowActions {
display: inline-flex;
align-items: center;

View File

@@ -1,10 +1,11 @@
/**
* Models settings section: the provider rows joined from the configurable
* directory, settings namespaces, and credential states, with one editor
* card at a time. A whole-section provider without a configured key (the
* unconfigured DeepSeek posture) renders as its open setup card instead of a
* row; the add flow is a card carrying the dormant-provider select. Every
* mutation writes through the wire, while a provider removal first requires
* card at a time. Rows expose only confirmed API-key state through accessible
* solid configured or missing dots. A whole-section provider without a
* configured key (the unconfigured DeepSeek posture) renders as its open setup
* card instead of a row; the add flow is a card carrying the dormant-provider
* select. Every mutation writes through the wire, while a provider removal first requires
* confirmation; the page re-renders from pushed invalidations or the
* post-apply reload.
*/
@@ -15,9 +16,9 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import { CustomProviderCard } from './CustomProviderCard.tsx'
import { messageOf, protocolChoices } from './store.ts'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
import { ProviderEditor } from './ProviderEditor.tsx'
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -39,42 +40,73 @@ export interface ModelsSectionInjected {
*/
export type ModelsSectionProps = Partial<ModelsSectionInjected>
/** The editor target: an existing row or a dormant directory entry. */
interface EditorTarget {
/** Provider identity shared by row actions and confirmation copy. */
export interface ProviderIdentity {
/** Stable provider route id. */
provider: string
/** Human-facing provider name. */
displayName: string
}
/** One existing row or dormant directory entry addressed by an editor action. */
interface EditorTarget extends ProviderIdentity {
settingsNs: string
settingsPath: readonly string[]
/** Writable credential identified under this page's conventional reference. */
credentialRef?: string
}
/** Values that vary around the shared provider-editor rendering. */
interface ProviderEditorRenderProps extends Pick<
ProviderEditorProps,
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
> {
target: EditorTarget
}
/** Render an editor for either the setup posture or an expanded provider row. */
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
return (
<ProviderEditor
provider={target.provider}
displayName={target.displayName}
settingsPath={target.settingsPath}
{...props}
/>
)
}
/**
* Remove one user-added provider profile by unsetting its path in the stored
* user section, then reload. The removal names the profile rather than
* rebuilding the section: this page only ever holds the redacted descriptor,
* so a rebuilt section would drop every literal secret stored elsewhere in
* the namespace along with the profile being removed.
* @param api - settings wire face.
* Remove one user-added provider and its page-managed credential. Credential
* removal comes first so a second-step failure leaves the provider row visible
* and the whole operation safely retryable; both unsets are idempotent.
* The settings removal names the profile rather than rebuilding its redacted
* namespace, which would drop literal secrets stored elsewhere.
* @param api - settings and credential wire faces.
* @param controller - the page store to refresh.
* @param target - the provider's settings address.
* @param target - the provider's settings address and optional managed credential.
* @returns the failure message, or undefined once the write and reload landed.
*/
export async function removeProviderProfile(
api: Pick<IApiClient, 'settings'>,
api: Pick<IApiClient, 'settings' | 'credentials'>,
controller: ModelsSettingsStore,
target: { settingsNs: string; settingsPath: readonly string[] },
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
): Promise<string | undefined> {
let response
try {
response = await api.settings.mutate({
if (target.credentialRef !== undefined) {
const credential = await api.credentials.unset({ ref: target.credentialRef })
if (!credential.result.ok) return credential.result.error.message
}
const response = await api.settings.mutate({
ns: target.settingsNs,
ops: [{ op: 'unset', path: [...target.settingsPath] }],
})
if (!response.result.ok) return response.result.error.message
} catch (error) {
// The transport rejected rather than answering; the caller must be able
// to say so instead of the row silently staying put.
// to retry the idempotent operation instead of the row silently staying.
return messageOf(error)
}
if (!response.result.ok) return response.result.error.message
await controller.load()
return undefined
}
@@ -93,14 +125,33 @@ export function needsSetup(row: ProviderRow): boolean {
}
function targetOf(row: ProviderRow): EditorTarget {
const managedRef = deriveKeyRef(row.entry.provider)
const credentialRef = row.apiKeyEnv === managedRef
&& row.credential?.configured === true
&& row.credential.writable
? managedRef
: undefined
return {
provider: row.entry.provider,
displayName: row.entry.displayName,
settingsNs: row.entry.settingsNs,
settingsPath: row.entry.settingsPath,
...credentialRef === undefined ? {} : { credentialRef },
}
}
/** Stable visible and accessible identity for one provider target. */
export function providerTargetLabel(target: ProviderIdentity): string {
return target.provider === target.displayName
? target.provider
: `${target.displayName} (${target.provider})`
}
/** Replace the one provider placeholder in localized destructive-action copy. */
export function providerCopy(template: string, target: ProviderIdentity): string {
return template.replace('{provider}', () => providerTargetLabel(target))
}
/**
* Render the Models section content column.
* @param props - slot-delivered injected dependencies.
@@ -119,28 +170,35 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const [adding, setAdding] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
const [deleting, setDeleting] = useState(false)
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
const [declaring, setDeclaring] = useState(false)
const closeEditor = (changed: boolean): void => {
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
setEditing(undefined)
setAdding(false)
setDeclaring(false)
if (changed) void controller.load()
if (changed) {
setSavedTarget(target)
void controller.load()
}
}
const closeDelete = (): void => {
if (deleting) return
setDeleteTarget(undefined)
setDeleteFailure(undefined)
}
const confirmDelete = (): void => {
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
if (deleteTarget === undefined || deleting) return
setDeleting(true)
setDeleteFailure(undefined)
void removeProviderProfile(api, controller, deleteTarget)
.then((failure) => {
if (failure !== undefined) {
controller.fail(failure)
setDeleteFailure(failure)
return
}
setDeleteTarget(undefined)
@@ -176,6 +234,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
{savedTarget === undefined
? null
: (
<p className={styles['savedNotice']} role="status" aria-live="polite">
{providerCopy(t('savedProvider'), savedTarget)}
</p>
)}
<ul className={styles['rows']}>
{configured.map((row) => {
const target = targetOf(row)
@@ -187,35 +252,60 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
// setup card IS its presence on the page.
return (
<li key={row.entry.provider} className={styles['setupCard']}>
<ProviderEditor
provider={target.provider}
displayName={target.displayName}
namespace={namespace}
settingsPath={target.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
{renderProviderEditor({
target,
namespace,
api,
t,
readOnly: !state.writable,
onClose: (changed) => { closeEditor(changed, target) },
})}
</li>
)
}
const open = !adding && editing?.provider === row.entry.provider
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
const credentialMissing = !credentialConfigured
&& row.apiKeyEnv !== undefined
&& row.credential?.configured === false
return (
<li key={row.entry.provider} className={styles['rowCard']}>
<div className={styles['rowHead']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
{/* Only the adapter can tell a hand-declared route from a
shipped one it also has a stored profile for, so the tag
follows its answer and stays off when it gives none. */}
{row.entry.declared === true
? <span className={styles['rowTag']}>{t('customTag')}</span>
: null}
<span className={styles['rowIdentity']}>
<span className={styles['rowName']}>{row.entry.displayName}</span>
{/* Only the adapter can tell a hand-declared route from a
shipped one it also has a stored profile for, so the tag
follows its answer and stays off when it gives none. */}
{row.entry.declared === true
? <span className={styles['rowTag']}>{t('customTag')}</span>
: null}
{credentialConfigured
? (
<span
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
role="img"
aria-label={t('credentialConfigured')}
title={t('credentialConfigured')}
/>
)
: credentialMissing
? (
<span
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
role="img"
aria-label={t('credentialMissing')}
title={t('credentialMissing')}
/>
)
: null}
</span>
<span className={styles['rowActions']}>
<button
type="button"
className={styles['secondaryButton']}
aria-label={providerCopy(t('editProvider'), target)}
onClick={() => {
setSavedTarget(undefined)
// One card at a time: leaving `declaring` set would show
// the create card beside this editor, and closing either
// one discards the other's draft.
@@ -231,8 +321,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<button
type="button"
className={styles['dangerButton']}
aria-label={providerCopy(t('removeProvider'), target)}
disabled={!state.writable}
onClick={() => { setDeleteTarget(target) }}
onClick={() => {
setSavedTarget(undefined)
setDeleteFailure(undefined)
setDeleteTarget(target)
}}
>
{t('remove')}
</button>
@@ -241,18 +336,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
</span>
</div>
{open
? (
<ProviderEditor
provider={target.provider}
displayName={target.displayName}
namespace={namespace}
settingsPath={target.settingsPath}
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
/>
)
? renderProviderEditor({
target,
namespace,
api,
t,
readOnly: !state.writable,
onClose: (changed) => { closeEditor(changed, target) },
})
: null}
</li>
)
@@ -290,7 +381,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
onClose={(changed) => { closeEditor(changed, addTarget) }}
/>
</div>
)
@@ -305,7 +396,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
api={api}
t={t}
readOnly={!state.writable}
onClose={closeEditor}
onClose={(changed) => {
setDeclaring(false)
if (changed) void controller.load()
}}
/>
</div>
)
@@ -323,6 +417,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
const first = addable[0]
/* v8 ignore next -- the button is disabled while nothing is addable */
if (first === undefined) return
setSavedTarget(undefined)
setDeclaring(false)
setAdding(true)
setEditing(targetOf(first))
@@ -336,7 +431,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
type="button"
className={styles['addButton']}
disabled={protocols.length === 0 || !state.writable}
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
onClick={() => {
setSavedTarget(undefined)
setAdding(false)
setEditing(undefined)
setDeclaring(true)
}}
>
<IconPlusOutline16 size={14} />
{t('customAdd')}
@@ -347,9 +447,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
<Modal
open={deleteTarget !== undefined}
onClose={closeDelete}
title={t('deleteTitle')}
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
closeLabel={t('close')}
description={t('deleteDescription')}
description={deleteTarget === undefined
? ''
: providerCopy(
deleteTarget.credentialRef === undefined
? t('deleteDescription')
: t('deleteDescriptionWithCredential'),
deleteTarget,
)}
className={styles['deleteDialog'] as string}
footer={(
<>
@@ -362,11 +469,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
disabled={deleting}
onClick={confirmDelete}
>
{deleting ? t('deleting') : t('deleteConfirm')}
{deleteTarget === undefined
? ''
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
</Button>
</>
)}
/>
>
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
</Modal>
</div>
)
}

View File

@@ -3,7 +3,9 @@
* field is a single write-only **API key** input (the page never asks for an
* environment-variable name — a typed key stores through `credentials.set`
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
* a key is entered; a blank key materializes a reference-free profile for
* provider-native authentication);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
* DeepSeek's id/name/context-window model catalog). Everything else stays
@@ -123,10 +125,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
// The revision this card opened at. A write carrying it is refused if
// anything else — another tab, an external edit of settings.yaml — moved the
// namespace meanwhile, instead of silently overwriting that change.
const [openedAt] = useState(() => namespace.revision)
// A settings success advances both retry baselines immediately. Keeping the
// 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<unknown>(
() => 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)
@@ -184,11 +189,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
*/
const applyOnce = async (): Promise<string | undefined> => {
const ns = namespace.ns
const original = getPath(namespace.user, settingsPath)
// The pi-ai profile must name the reference the key stores under, so a
// dormant add (or a legacy profile without one) records the derivation.
const normalizedKey = keyDraft.trim()
// A pi-ai profile names the conventional reference only when this page is
// 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
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
? setPath(draft, ['apiKeyEnv'], keyRef)
: draft
{
@@ -207,17 +212,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const sectionError = validateDraft(node, next)
if (sectionError !== undefined) return sectionError
}
const ops = pathOps(settingsPath, original, next)
const materializesNativeProfile = layout === 'pi-ai'
&& fallback === undefined
&& committedOriginal === undefined
&& Object.keys(next).length === 0
const ops: SettingsPathOpView[] = materializesNativeProfile
? [{ op: 'set', path: [...settingsPath], value: {} }]
: pathOps(settingsPath, committedOriginal, next)
if (ops.length > 0) {
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
const response = await api.settings.mutate({ ns, ops, expectedRevision })
if (!response.result.ok) {
return response.result.error.code === 'settings-conflict'
? t('conflict')
: response.result.error.message
}
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
setExpectedRevision(response.result.value.revision)
setDraft(next)
}
if (keyDraft.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
if (normalizedKey.length > 0) {
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
if (!stored.result.ok) return stored.result.error.message
}
setKeyDraft('')
@@ -276,6 +290,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
const keyPlaceholder = keyLocked
? t('keyEnvLocked')
: keyState?.configured === true
? t('keyStored')
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
const catalogProps = {
models,
@@ -296,9 +315,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
type="password"
autoComplete="off"
value={keyDraft}
placeholder={keyLocked
? t('keyEnvLocked')
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
placeholder={keyPlaceholder}
aria-label={t('keyInput')}
disabled={disabled || keyLocked}
onChange={(event) => { setKeyDraft(event.target.value) }}

View File

@@ -6,23 +6,30 @@ export const en = {
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
edit: 'Edit',
editProvider: 'Edit {provider}',
remove: 'Delete',
deleteTitle: 'Delete model provider?',
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
deleteConfirm: 'Delete provider',
deleting: 'Deleting provider',
removeProvider: 'Delete {provider}',
deleteTitle: 'Delete {provider}?',
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
deleteConfirm: 'Delete {provider}',
deleting: 'Deleting {provider}…',
add: 'Add provider',
provider: 'Provider',
close: 'Close',
cancel: 'Cancel',
apply: 'Apply',
applying: 'Applying…',
savedProvider: 'Saved {provider}.',
credentialConfigured: 'API key configured',
credentialMissing: 'API key missing',
readOnly: 'The settings document is read-only in this deployment.',
loadFailed: 'Loading the provider directory failed',
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
retry: 'Retry',
keyInput: 'API key',
keyPlaceholder: 'Enter your API key',
keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication',
keyStored: 'Configured — enter a new value to replace',
keyEnvLocked: 'Provided by the launch environment (read-only)',
customized: 'Customized settings',
@@ -91,23 +98,30 @@ export const zh: typeof en = {
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
edit: '编辑',
editProvider: '编辑 {provider}',
remove: '删除',
deleteTitle: '删除模型提供方?',
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
deleteConfirm: '删除提供方',
deleting: '正在删除提供方…',
removeProvider: '删除 {provider}',
deleteTitle: '删除 {provider}',
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
deleteConfirm: '删除 {provider}',
deleting: '正在删除 {provider}…',
add: '添加提供方',
provider: '提供方',
close: '关闭',
cancel: '取消',
apply: '保存',
applying: '保存中…',
savedProvider: '已保存 {provider}。',
credentialConfigured: 'API 密钥已配置',
credentialMissing: 'API 密钥缺失',
readOnly: '当前部署的设置文档为只读。',
loadFailed: '加载提供方目录失败',
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
retry: '重试',
keyInput: 'API 密钥',
keyPlaceholder: '输入 API 密钥',
keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证',
keyStored: '已配置——输入新值可替换',
keyEnvLocked: '由启动环境提供(只读)',
customized: '自定义设置',

View File

@@ -125,18 +125,6 @@ export class ModelsSettingsStore {
*/
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
/**
* Surface a failure from an operation the page ran outside {@link load} —
* a row removal — on the same banner a load failure uses.
* @param message - the failure text to show.
*/
fail(message: string): void {
this.store.update((s) => {
s.status = 'error'
s.error = message
})
}
/**
* Refresh the whole page snapshot: directory and namespaces in parallel,
* then one batched credential describe over every referenced ref. A

View File

@@ -53,7 +53,7 @@ describe('ui-models apply', () => {
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
expect(injected.t('nav')).toBe('模型')
expect(injected.t('deleteTitle')).toBe('删除模型提供方')
expect(injected.t('deleteTitle')).toBe('删除 {provider}')
expect(typeof injected.controller.load).toBe('function')
expect(typeof injected.useSnapshot).toBe('function')
expect(injected.api).toBeDefined()
@@ -80,10 +80,10 @@ describe('ui-models apply', () => {
b.locale.setLocale('en')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
expect(injected().t('deleteTitle')).toBe('Delete {provider}?')
b.locale.setLocale('zh')
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
expect(injected().t('deleteTitle')).toBe('删除模型提供方')
expect(injected().t('deleteTitle')).toBe('删除 {provider}')
})
it('locale change while the slot is undeclared stays a no-op', async () => {

View File

@@ -5,7 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import Schema from 'schemastery'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
import {
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
} from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import {
@@ -18,6 +20,8 @@ import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const t: ModelsSectionInjected['t'] = key => en[key]
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
/** Open one row's capacity disclosure (1-based, as the labels read). */
function expandRow(position: number): void {
@@ -136,11 +140,13 @@ function scriptedFace(overrides: {
replace?: ReturnType<typeof vi.fn>
mutate?: ReturnType<typeof vi.fn>
set?: ReturnType<typeof vi.fn>
unset?: ReturnType<typeof vi.fn>
} = {}) {
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({})))
const face = {
llm: {
providers: vi.fn(() => Promise.resolve(ok({
@@ -170,16 +176,16 @@ function scriptedFace(overrides: {
}])),
}))),
set,
unset: vi.fn(() => Promise.resolve(ok({}))),
unset,
},
}
return { face, update, replace, mutate, set }
return { face, update, replace, mutate, set, unset }
}
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
const { face, update, replace, mutate, set } = scriptedFace(overrides)
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
const controller = new ModelsSettingsStore(face as unknown as WireFace)
await controller.load()
const injected: ModelsSectionInjected = {
@@ -189,7 +195,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
t,
}
const view = render(<ModelsSection {...injected} />)
return { view, face, update, replace, mutate, set, controller }
return { view, face, update, replace, mutate, set, unset, controller }
}
describe('ModelsSection', () => {
@@ -207,9 +213,36 @@ describe('ModelsSection', () => {
expect(screen.getByText('openai')).toBeTruthy()
expect(screen.queryByText('Active')).toBeNull()
expect(screen.queryByText('Inactive')).toBeNull()
const configured = screen.getByRole('img', { name: en.credentialConfigured })
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
expect(configured.className).toContain('credentialDotConfigured')
expect(configured.closest('li')?.textContent).toContain('openai')
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
expect(screen.getByText(en.add)).toBeTruthy()
})
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
const { face } = scriptedFace()
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)
await controller.load()
render(<ModelsSection
controller={controller}
useSnapshot={bindSnapshotSelector(controller.store)}
api={face as never}
t={t}
/>)
const missing = screen.getByRole('img', { name: en.credentialMissing })
expect(missing.getAttribute('title')).toBe(en.credentialMissing)
expect(missing.className).toContain('credentialDotMissing')
expect(missing.closest('li')?.textContent).toContain('openai')
expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull()
expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull()
})
it('turns the setup card into a row once the credential reports configured', async () => {
const { face } = await mountSection()
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
@@ -254,6 +287,13 @@ describe('ModelsSection', () => {
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
})
it('uses one stable provider identity in action copy', () => {
const target = { provider: 'deepseek-official', displayName: 'DeepSeek' }
expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)')
expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?')
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
})
it('names only the fields the card can see, so an unseen secret survives', () => {
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
// side, so no op mentions it and the seam leaves it alone.
@@ -268,11 +308,16 @@ describe('ModelsSection', () => {
it('stores a typed key write-only from the setup card without touching settings', async () => {
const { set, update, face } = await mountSection()
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(key, { target: { value: 'sk-live' } })
fireEvent.change(key, { target: { value: ' sk-live ' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
expect(update).not.toHaveBeenCalled()
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
expect((await screen.findByRole('status')).textContent).toBe(
providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
)
fireEvent.click(screen.getByText(en.add))
expect(screen.queryByRole('status')).toBeNull()
})
it('applies customized deepseek fields as path ops', async () => {
@@ -777,6 +822,7 @@ describe('ModelsSection', () => {
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
const addKey = keys[keys.length - 1] as HTMLInputElement
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
@@ -788,6 +834,59 @@ describe('ModelsSection', () => {
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
})
it('keeps pi-ai provider-native authentication when no key is entered', async () => {
const { mutate, set } = await mountSection()
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
expectedRevision: 0,
})
expect(set).not.toHaveBeenCalled()
})
it('retries only the credential after refreshed settings already committed', async () => {
const committed = wireNamespaces()[2]!
const afterSettings: SettingsNamespaceView = {
...committed,
value: { providers: {
...(committed.value as { providers: object }).providers,
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
} },
user: { providers: {
...(committed.user as { providers: object }).providers,
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
} },
revision: 1,
}
const mutate = vi.fn(() => Promise.resolve(ok(afterSettings)))
const set = vi.fn()
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
.mockResolvedValueOnce(ok({}))
const { face, controller } = await mountSection({ mutate, set })
fireEvent.click(screen.getByText(en.add))
await screen.findByLabelText(en.provider)
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await screen.findByText('credential store unavailable')
expect(mutate).toHaveBeenCalledOnce()
face.settings.describe.mockResolvedValue(ok({
writable: true,
hasDocument: false,
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
}))
await act(async () => { await controller.load() })
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
expect(mutate).toHaveBeenCalledOnce()
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
})
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
await mountSection()
fireEvent.click(screen.getByText(en.add))
@@ -876,6 +975,7 @@ describe('ModelsSection', () => {
fireEvent.change(key, { target: { value: 'sk-live' } })
fireEvent.click(screen.getByText(en.apply))
await screen.findByText(/shadowed by the read-only environment/)
expect(screen.queryByRole('status')).toBeNull()
})
it('locks the key input when the launch environment provides the credential', async () => {
@@ -898,34 +998,37 @@ describe('ModelsSection', () => {
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
const editorKey = keys[keys.length - 1] as HTMLInputElement
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
})
it('requires confirmation before removing a user-added provider', async () => {
const { replace, mutate } = await mountSection()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
expect(dialog.textContent).toContain(en.deleteDescription)
const { replace, mutate, unset } = await mountSection()
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
expect(unset).not.toHaveBeenCalled()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
.getByRole('button', { name: en.close }))
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
.getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) })
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
expect(replace).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
@@ -939,20 +1042,22 @@ describe('ModelsSection', () => {
resolveRemoval = resolve
}))
await mountSection({ mutate })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(mutate).toHaveBeenCalledOnce()
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(confirm.disabled).toBe(true)
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm)
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
expect(mutate).toHaveBeenCalledOnce()
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
})
})
it('renders the load failure with a retry control', async () => {
@@ -1057,15 +1162,58 @@ describe('ModelsSection', () => {
expect(controller.store.getSnapshot().rows).toBe(before)
})
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
// The whole click path: the row's Remove button, the transport rejecting
// with a non-Error value, and the store surfacing it where a load failure
// would appear — rather than the row silently staying put.
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
.getByRole('button', { name: en.deleteConfirm }))
await screen.findByText(`${en.loadFailed}: the host refused`)
it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
const mutate = vi.fn()
.mockResolvedValueOnce(fail('the host refused'))
.mockResolvedValueOnce(ok(wireNamespaces()[2]!))
const { unset } = await mountSection({ mutate })
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) })
fireEvent.click(confirm)
await within(dialog).findByText('the host refused')
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
expect(unset).toHaveBeenCalledOnce()
expect(mutate).toHaveBeenCalledOnce()
fireEvent.click(confirm)
await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) })
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) })
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
})
})
it('retains credentials that are not identified as page-managed', async () => {
const { unset, mutate } = await mountSection()
const target = { provider: 'zombie', displayName: 'zombie' }
fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) }))
const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) })
expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target))
fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
expect(unset).not.toHaveBeenCalled()
expect(mutate.mock.calls[0]?.[0]).toEqual({
ns: 'llm-pi-ai',
ops: [{ op: 'unset', path: ['providers', 'zombie'] }],
})
})
it('does not remove provider settings when its managed credential removal is refused', async () => {
const { face, controller, mutate } = await mountSection({
unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))),
})
const failure = await removeProviderProfile(
face as unknown as Parameters<typeof removeProviderProfile>[0],
controller,
{
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'openai'],
credentialRef: 'OPENAI_API_KEY',
},
)
expect(failure).toBe('credential is read-only')
expect(mutate).not.toHaveBeenCalled()
})
it('reports a transport rejection instead of failing the removal silently', async () => {

View File

@@ -145,7 +145,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
t,
}
render(<ModelsSection {...injected} />)
return scripted
return { ...scripted, controller }
}
/** Open the editor of one configured row and expand its customized fold. */
@@ -946,4 +946,20 @@ describe('hand-declared providers', () => {
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
})
it('reloads the section after creating a hand-declared provider', async () => {
const { controller, mutate } = await mountSection()
const load = vi.spyOn(controller, 'load')
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
expect(screen.queryByText(en.customTitle)).toBeNull()
})
})

View File

@@ -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/README.md
README.md: 2d32a05071efdfa05c336211196bb769ed5a5fc7
README.zh.md: 62c9a395ac3cf2b5cd55455ab5a273a1e276f6b7
README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the generated TypeScript SDK); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
The code-execution capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's [Code Mode](../core/tools/README.md) (`tools: { mode: code }` — the `run_code` tool and the SDK generated in the loaded runtime's `language`); design in the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). **Product** packages.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
代码执行能力 seam参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)`tools: { mode: code }`,即 `run_code` 工具和生成的 TypeScript SDK设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
代码执行能力 seam参见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):一个抽象运行时接口,用于对宿主提供的异步绑定执行模型编写的程序,并捕获它打印和返回的内容。消费方是工具注册表的 [Code Mode](../core/tools/README.md)`tools: { mode: code }`,即 `run_code` 工具和按所加载运行时 `language` 生成的 SDK设计见 [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|

View File

@@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
@@ -65,20 +65,12 @@ const ELU_POLL_INTERVAL_MS = 25
/** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */
const MIN_OUTPUT_BYTES = 4
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
const RESERVED_WORDS = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
'private', 'protected', 'public', 'arguments', 'eval',
])
/** Valid async-function parameter name (the binding global becomes one). */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/** Error properties whose binding-member replacement would destroy the promised Error contract. */
const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack'])
/**
* The seam's language-portable identifier subset (see
* `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same
* namespace list must be usable against every backend regardless of language.
*/
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* The shell a program is wrapped in for the type-strip, matching the
@@ -328,10 +320,19 @@ export class WorkerCodeRuntime extends CodeRuntime {
private validateBindings(request: CodeRunRequest): Map<string, CodeBindingNamespace> {
const bindings = new Map<string, CodeBindingNamespace>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
if (!IDENTIFIER.test(namespace.global) || PORTABLE_RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
if (namespace.global === 'console' || bindings.has(namespace.global)) {
// RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set:
// `console` is THIS backend's log-capture slot; the dunder entries exist
// for the Python side — its seeded/wrapped slots plus the `__debug__`
// compile-time constant — refused here too so the namespace list stays
// portable across backends. The seam declaration is the single home for
// why each entry is reserved.
if (RESERVED_BINDING_GLOBALS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(namespace.global)}`)
}
if (bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace)
@@ -341,13 +342,17 @@ export class WorkerCodeRuntime extends CodeRuntime {
for (const namespace of request.bindings) {
const descriptor = namespace.errorClass
if (!descriptor) continue
if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) {
if (!IDENTIFIER.test(descriptor.name) || PORTABLE_RESERVED_WORDS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`)
}
if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
if (RESERVED_BINDING_GLOBALS.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: reserved binding global ${JSON.stringify(descriptor.name)}`)
}
if (bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) {
throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`)
}
if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) {
const member = descriptor.memberNameProperty
if (member.length === 0 || RESERVED_ERROR_MEMBERS.has(member) || DUNDER_MEMBER.test(member)) {
throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`)
}
errorClassNames.add(descriptor.name)

View File

@@ -787,7 +787,17 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
const cases: [string, RegExp][] = [
['not valid!', /not a usable identifier/],
['await', /not a usable identifier/],
['console', /duplicate binding global/],
// `$tools` is legal JS but outside the seam's language-portable subset:
// the same namespace list must work against every backend's language.
['$tools', /not a usable identifier/],
// `a$b` pins the second character class too: the old identifier regex
// `[A-Za-z0-9_$]*` would have accepted a `$` after the first character.
['a$b', /not a usable identifier/],
// `lambda` is a Python keyword, refused here directly (not just
// transitively) so the worker's adoption of PORTABLE_RESERVED_WORDS is
// its own regression, symmetric with the `$tools` case.
['lambda', /not a usable identifier/],
['console', /reserved binding global/],
]
for (const [global, message] of cases) {
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
@@ -814,7 +824,7 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/reserved binding global/)
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
await expect(run([
namespace('tools', 'CallError'),
@@ -822,6 +832,14 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
])).rejects.toThrow(/duplicate injected global/)
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
// The shared exclusion set covers Python's exception-protocol members and
// dunders too, so the same errorClass is valid (or not) on every backend.
await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/)
await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/)
// The Python backend's owned globals are refused here too (shared
// RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable.
await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] }))
.rejects.toThrow(/reserved binding global/)
})
it('rejects config values that are not positive numbers', async () => {

View File

@@ -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/README.md
README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602
README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489
README.md: bb1c20d00a260f643f601c42c6e48722437d5aab
README.zh.md: 15fbcecf77b2318acf3b09101802cd032ae426d2

View File

@@ -11,7 +11,7 @@ This package is the interface third of the capability (the bash trio is the temp
| Member | Semantics |
|---|---|
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, invalid completion, output overflow, budget expiry, abort, or substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and a lossless JSON completion becomes `result.value`. |
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
| `language` | Readonly descriptor: the source language `run` expects. `'typescript'` and `'python'` are the well-known values — those `dsh-tools` presents; only `'typescript'` has a published backend. Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge complete lossless-JSON arguments and resolutions with no seam-level byte cap; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
@@ -20,6 +20,8 @@ Semantics every implementation must honor (contract details in the class JSDoc):
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets and outer-output cap) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions` + optional `errorClass`), each exposed to the program as one global object of async callables returning `CodeJsonValue`, the seam-local structural equivalent of canonical `JsonValue` that keeps this interface package independent of sessions. An `errorClass` descriptor names a real program-global constructor and the own property that receives the rejected member name; runtimes remain independent of consumer terms such as `ToolCallError`. `CodeRunResult` reports the lossless JSON completion `value?`, ordered `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
Binding-global and error-class names are **language-portable**: they must match the identifier subset `[A-Za-z_][A-Za-z0-9_]*` (no JS-only `$`) and clear the seam-exported exclusion sets, so one `bindings` list is valid against every backend regardless of its `language`. The package exports the contract every backend enforces — `PORTABLE_RESERVED_WORDS` (ECMAScript Python reserved words), `RESERVED_BINDING_GLOBALS` (backend-owned globals such as `console`), `RESERVED_ERROR_MEMBERS` and `DUNDER_MEMBER` (error-member exclusions) — so a name like `$tools`, `lambda`, or `__dsh_main__` makes `run()` reject as seam misuse on any backend, not just some. See `src/index.ts` for the exact sets and rationale.
## Model Experience
Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens.

View File

@@ -11,7 +11,7 @@
| 成员 | 语义 |
|---|---|
| `run(request)` | 针对请求的绑定执行一段程序。**所有程序失败结果都通过 resolve 结果中的 error 字段报告**:包括解析/转换失败、抛出异常、无效完成值、输出溢出、预算到期、中止或执行基底终止(由 `CodeRunFailure` 的正交 `kind` 分类表示);只有调用方误用 seam 本身时才 reject例如 dispose资源释放后仍提交运行。程序作为异步函数的函数体运行因此顶层 `await``return` 可用,无损 JSON 完成值会成为 `result.value`。 |
| `language` | 只读描述符:`run` 期望的源语言已知值为 `'typescript'`。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
| `language` | 只读描述符:`run` 期望的源语言已知值为 `'typescript'``'python'`——`dsh-tools` 能呈现的那些;其中只有 `'typescript'` 有已发布的后端。仅供参考,不作门禁;生成语言专用呈现的消费方会根据该值选择分支,遇到无法呈现的语言时明确失败。 |
| `isolation` | 只读描述符:执行基底(`'worker-thread'``'process'``'container'`)。供部署与诊断使用,**不构成安全声明**。 |
每个实现都必须遵守以下语义(完整契约见类 JSDoc绑定调用会桥接完整的无损 JSON 参数与 resolve 值seam 层不设字节上限程序被视为敌对对等方任意绑定名称都会成为自有属性格式错误的通信绝不能使宿主崩溃不同运行之间不保留任何状态dispose 会终止进行中的运行,并且在完成前等待其退出。
@@ -20,6 +20,8 @@
`CodeRunRequest``program``bindings``signal?`)携带运行时操作所需的全部内容;默认值解析(时间预算与外层输出上限)属于实现的已验证配置,绝不能是隐藏的 `??`,更不能藏在 `run()` 内部。`bindings``CodeBindingNamespace` 列表(`global` + `functions` + 可选 `errorClass`);每个命名空间会作为一个由异步可调用函数组成的全局对象公开给程序,这些函数返回 `CodeJsonValue`。后者是 seam 本地、与规范 `JsonValue` 结构等价的类型,使接口包保持独立于会话。`errorClass` 描述符点名真实的程序全局构造器,以及用于接收被拒绝成员名称的自有属性;运行时不依赖 `ToolCallError` 等消费方术语。`CodeRunResult` 报告无损 JSON 完成值 `value?`、有序的 `logs: string[]``error?``CodeRunFailure``kind` + 可反馈给模型的 `message`)。完整契约见 `src/types.ts`
binding-global 与 error-class 名称是**语言可移植**的:必须匹配标识符子集 `[A-Za-z_][A-Za-z0-9_]*`(不含 JS 专有的 `$`)并通过 seam 导出的排除集,因此同一份 `bindings` 列表对每个后端都有效,无论其 `language` 为何。本包导出每个后端都执行的契约——`PORTABLE_RESERVED_WORDS`ECMAScript Python 保留字)、`RESERVED_BINDING_GLOBALS`(如 `console` 等后端拥有的 global`RESERVED_ERROR_MEMBERS``DUNDER_MEMBER`error-member 排除)——因此 `$tools``lambda``__dsh_main__` 之类的名称会让 `run()` 在任何后端上作为 seam 误用而 reject而非只在某些后端。确切集合与理由见 `src/index.ts`
## 模型体验
通过 `dsh-tools` 中的 Code Mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。

View File

@@ -17,6 +17,77 @@ export type {
CodeRunResult,
} from './types.ts'
/**
* Binding globals EVERY backend refuses because SOME backend owns the slot in
* the program's namespace: `console` (the worker's log capture), and
* `__dsh_main__`/`__builtins__`/`__name__` (the Python backend's bootstrap
* wrapper and seeded module globals — that backend is a later PR in this
* stack, see the [portable-identifier Agent
* Note](../../../../.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md)),
* and `__debug__`. One shared set — rather than each backend refusing only its
* own slots — keeps the portability promise real: a namespace list valid on
* one backend is valid on all, so a caller cannot pick a name that works on
* the worker and collides on Python (or vice versa). `__name__` et al. ARE
* valid portable identifiers, so the identifier rule on
* `CodeBindingNamespace.global` never rejects them — hence this explicit set.
* (Error members differ: {@link DUNDER_MEMBER} refuses every dunder form
* wholesale; binding globals refuse only the names listed here.) `__debug__`
* is listed for a different reason than a collision: CPython compiles a bare
* `__debug__` reference to the constant `True` and rejects any assignment to
* the name at COMPILE time, so an injected global under that name is
* unreachable from the program — accepted by validation, unusable on the
* Python backend, which is exactly the split the shared set exists to prevent.
*/
export const RESERVED_BINDING_GLOBALS: ReadonlySet<string> = new Set([
'console',
'__dsh_main__', '__builtins__', '__name__', '__debug__',
])
/**
* `CodeBindingErrorClass.memberNameProperty` names EVERY backend refuses, as
* one shared contract so a request valid on one backend is valid on all. The
* JS `Error` exclusions (`name`, `message`, `stack`) and Python's
* exception-protocol members (`args`, `with_traceback`, `add_note`) are
* listed by name; dunder-form names (`__x__`, non-empty middle) are refused
* wholesale — several are constrained CPython descriptors whose `setattr`
* raises while constructing the rejection, and the exact set is an interpreter
* version detail. Any other non-empty own property name is accepted everywhere.
*/
export const RESERVED_ERROR_MEMBERS: ReadonlySet<string> = new Set([
'name', 'message', 'stack',
'args', 'with_traceback', 'add_note',
])
/**
* 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 = /^__.+__$/
/**
* Reserved words of every portable target language (ECMAScript Python),
* refused as {@link CodeBindingNamespace.global} / error-class names by all
* backends. Python is a portability target here even though only the
* TypeScript worker ships in this PR (the CPython backend is a later PR in the
* stack). The portable-identifier contract promises a namespace list valid
* on one backend is valid on every backend; a per-language check would let
* `lambda` pass the TypeScript backend and fail the Python one. Extending the
* seam with a new language means widening this union (a breaking review of
* existing binding names, by design).
*/
export const PORTABLE_RESERVED_WORDS: ReadonlySet<string> = new Set([
// ECMAScript reserved words and reserved-in-strict-mode names.
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
'private', 'protected', 'public', 'arguments', 'eval',
// Python 3.x keywords and soft keywords not already above ('type' and '_'
// are soft keywords: legal names in practice, reserved here for safety).
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'def', 'del', 'elif', 'except', 'from',
'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_',
])
declare module 'cordis' {
interface Context {
codeRuntime: CodeRuntime
@@ -36,7 +107,8 @@ export abstract class CodeRuntime extends Service {
* lowercase identifier. Informational, not gating — a consumer that
* generates language-specific presentation (typed SDK stubs, usage
* instructions) switches on it and fails loud on a language it cannot
* present. Well-known value: `'typescript'`.
* present. Well-known values: `'typescript'` and `'python'`, those
* `dsh-tools` presents; only `'typescript'` has a published backend.
*/
abstract readonly language: string

View File

@@ -28,9 +28,14 @@ export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] |
* of a particular consumer such as Code Mode.
*/
export interface CodeBindingErrorClass {
/** Constructor global and resulting `Error.name` (must be a usable JS identifier). */
/** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */
name: string
/** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */
/**
* Non-empty own property for the member name. The portable exclusion set is
* `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__x__`, non-empty
* middle), enforced identically by every backend; any other name —
* identifiers or not — is accepted everywhere.
*/
memberNameProperty: string
}
@@ -42,7 +47,16 @@ export interface CodeBindingErrorClass {
* collisions.
*/
export interface CodeBindingNamespace {
/** The global identifier the program sees (must be a valid JS identifier). */
/**
* The global identifier the program sees. Must match the LANGUAGE-PORTABLE
* identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved
* words, so the same namespace list works against every backend regardless
* of `language` — a JS-only spelling like `$tools` is rejected by design,
* not just by the Python backend. Names that satisfy the identifier rule but
* name a backend-owned slot (`RESERVED_BINDING_GLOBALS`, e.g. `console`,
* `__dsh_main__`) are also refused everywhere; see its declaration for the
* exact set and why each entry is reserved.
*/
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record<string, CodeBindingFunction>

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
DUNDER_MEMBER,
PORTABLE_RESERVED_WORDS,
RESERVED_BINDING_GLOBALS,
RESERVED_ERROR_MEMBERS,
} from '@deepseek-ai/dsh-code-runtime'
/**
* The seam owns the portable-identifier exclusion sets so every backend
* enforces one contract: a namespace list valid on one backend is valid on
* all. These assertions pin the shared membership backends import rather than
* re-declare.
*/
describe('seam-owned portable identifier exclusions', () => {
it('RESERVED_BINDING_GLOBALS covers each backend-owned slot', () => {
expect(RESERVED_BINDING_GLOBALS.has('console')).toBe(true)
expect(RESERVED_BINDING_GLOBALS.has('__dsh_main__')).toBe(true)
expect(RESERVED_BINDING_GLOBALS.has('__builtins__')).toBe(true)
expect(RESERVED_BINDING_GLOBALS.has('__name__')).toBe(true)
expect(RESERVED_BINDING_GLOBALS.has('__debug__')).toBe(true)
expect(RESERVED_BINDING_GLOBALS.has('tools')).toBe(false)
})
it('RESERVED_ERROR_MEMBERS covers the JS Error and Python exception-protocol members', () => {
for (const name of ['name', 'message', 'stack', 'args', 'with_traceback', 'add_note']) {
expect(RESERVED_ERROR_MEMBERS.has(name)).toBe(true)
}
expect(RESERVED_ERROR_MEMBERS.has('code')).toBe(false)
})
it('DUNDER_MEMBER matches dunder-form names only', () => {
expect(DUNDER_MEMBER.test('__dict__')).toBe(true)
expect(DUNDER_MEMBER.test('__init__')).toBe(true)
expect(DUNDER_MEMBER.test('_private')).toBe(false)
expect(DUNDER_MEMBER.test('name')).toBe(false)
expect(DUNDER_MEMBER.test('__mid')).toBe(false)
// `__` has an empty middle — not a real CPython dunder, so not matched.
expect(DUNDER_MEMBER.test('__')).toBe(false)
// `____` also has an empty middle between the two `__` pairs — not matched.
expect(DUNDER_MEMBER.test('____')).toBe(false)
// A single character between the pairs is the shortest real dunder form.
expect(DUNDER_MEMBER.test('__x__')).toBe(true)
})
it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => {
// ECMAScript-only keyword.
expect(PORTABLE_RESERVED_WORDS.has('function')).toBe(true)
// Python-only keyword — refused here so the list stays portable.
expect(PORTABLE_RESERVED_WORDS.has('lambda')).toBe(true)
expect(PORTABLE_RESERVED_WORDS.has('nonlocal')).toBe(true)
// Shared keyword.
expect(PORTABLE_RESERVED_WORDS.has('class')).toBe(true)
// Ordinary identifier is not reserved.
expect(PORTABLE_RESERVED_WORDS.has('tools')).toBe(false)
})
})

View File

@@ -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: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f
README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad
README.md: 81cc57983d83fd19468017b217d4db9978f4e228
README.zh.md: 9f875bd80a03d1d0f78625ee98eeaad9d118f871

View File

@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a TypeScript `ctx.codeRuntime`, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
`native` contributes visible tools as function definitions. `code` contributes the reserved `run_code` transport and generated `tools:sdk` section; `both` contributes both forms. The reserved transport cannot be registered, shadowed, restricted, or removed. Non-native modes require a `ctx.codeRuntime` whose `language` has a registered SDK renderer — TypeScript ships via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md); a Python renderer is built in and drives any runtime that reports `language: 'python'` (a first-party `dsh-code-runtime-python` backend is delivered separately). A runtime language with no renderer fails prompt assembly loudly, and a `systemPrompt.toolOrder` entry for a tool the mode does not contribute rejects prompt assembly. A `system-prompt/assemble` listener may replace the registry's contributions; its returned assembly is authoritative, so that listener owns preserving a usable Code Mode protocol.
### Public API
@@ -114,9 +114,9 @@ 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 TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, 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`. See 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`.
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`. See 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, at each assembly, `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. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
- **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 `<parent>:code:<n>`, 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.
- **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 boundary**: intermediate binding values cross the worker boundary 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 ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
@@ -145,7 +145,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat
#### What the model sees
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact `declare const tools` block. `both` exposes normal schemas and this Code Mode surface.
Code Mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language (the TypeScript `declare const tools` block, or the Python `tools` declaration). `both` exposes normal schemas and this Code Mode surface. The instructions and SDK block match the loaded runtime's language; the TypeScript flavor (via [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)) is shown below, and the Python flavor (for any runtime reporting `language: 'python'`) is the same shape with Python syntax (`await tools.name(args)`, subscript access for exotic names, `print(...)` and top-level `return`).
##### Code Mode SDK instructions
@@ -190,6 +190,6 @@ Append-only; newly visible content follows the reusable request prefix and does
- **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
- **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root.
- **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper.
- **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only.
- **Code Mode's SDK language follows the one loaded runtime and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer (`typescript` via the worker backend, `python` for any runtime reporting that language); scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only, and a single runtime fixes the language service-wide (the [language-dispatch Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) owns the lookup, and why the registry reads the loaded runtime instead of carrying a language field of its own).
- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)).
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md).

View File

@@ -13,7 +13,7 @@ tools:
mode: native # native (default) | code | both
```
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求存在 TypeScript `ctx.codeRuntime`;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
`native` 以函数定义的形式贡献可见工具。`code` 贡献保留的 `run_code` 传输和生成的 `tools:sdk` 段;`both` 同时贡献两种形式。不能注册、遮蔽、限制或移除该保留传输。非原生模式要求所加载 `ctx.codeRuntime``language` 有已注册的 SDK 渲染器——TypeScript 经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md) 交付Python 渲染器内置,驱动任何报告 `language: 'python'` 的运行时(第一方 `dsh-code-runtime-python` 后端另行交付)。没有渲染器的运行时语言会让提示词组装响亮失败;如果 `systemPrompt.toolOrder` 条目指向当前模式未贡献的工具,系统会拒绝组装提示词。`system-prompt/assemble` 监听器可以替换注册表贡献;它返回的组装结果具有权威性,因此该监听器负责保留可用的 Code Mode 协议。
### 公开 API
@@ -114,9 +114,9 @@ ctx.tools.register(defineTool({
### Code Mode
`code``both` 模式下,注册表为当前作用域公开保留的 `run_code` 传输和确定性的 TypeScript SDK只有程序的外层日志与返回值会重新进入模型上下文。SDK 为每个可见工具声明精确的 `ToolArgsMap``ToolOutputMap` 条目,每个绑定都会解析为该工具的规范 JSON 值。每个无损 JSON 绑定调用都会在原生调度契约下重新进入完整工具流水线(并发安全的调用最多可重叠 `maxParallelSubCalls` 个;独占调用单独运行并构成排序屏障),并在日志中与外层调用建立关联。拒绝及其他失败结果会以程序实际可见的 `ToolCallError` 形式拒绝,且只携带 `toolName``message`Native 内容和内部错误码留在 Code 契约之外。普通副作用不会回滚,子调用的 `additionalContexts` 会通过父结果延迟,以保持调用/结果相邻。运行结算会中止并排空尚未完成的绑定;运行时失败以 `CodeRunFailedError` 形式出现。参见 [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` 试用。
`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 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一个惰性提示词段每次组装时都会重新生成 `JsonValue`、精确的 `ToolArgsMap` / `ToolOutputMap``ToolName``ToolCallError` 声明、面向调用作用域可见最终能力的映射 `tools` 命名空间(特殊名称使用带引号的键),以及固定用法说明。其输出具有确定性:工具按字典序排列;工具集合不变时,文本逐字节相同(有利于前缀 cache导出的代码生成器 `jsonSchemaToTs` 处理统一 schema 的每种构造并将不受支持的原始构造降级为 `unknown`,绝不会在提示词组装期间抛出
- **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 `<parent>:code:<n>`,按提交顺序编号),并以一条携带完整模型可见 `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。
@@ -145,7 +145,7 @@ agent loop 将连续的 `parallel` 调用归入有界滚动池,并把每个 `e
#### 模型看到的内容
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及生成的精确 `declare const tools` 块。`both` 会同时公开普通 schema 与此 Code Mode 接口
Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块TypeScript 的 `declare const tools` 块,或 Python 的 `tools` 声明)。`both` 会同时公开普通 schema 与此 Code Mode 接口。说明与 SDK 块随所加载运行时的语言切换;下方展示 TypeScript 风格(经 [`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)Python 风格(用于任何报告 `language: 'python'` 的运行时)形状相同,只是换成 Python 语法(`await tools.name(args)`、特殊名称用下标访问、`print(...)` 与顶层 `return`
##### Code Mode SDK 说明
@@ -190,6 +190,6 @@ The available tools:
- **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录和呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)中。
- **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇和工具输出支持任意 JSON 根。
- **定义上的 `timeoutMs` 仅为声明**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-timeout-policy` 包装层。
- **Code Mode 只支持 TypeScript,且呈现模式在服务内统一**`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language === 'typescript'`;作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native,而另一个仅使用 Code。
- **Code Mode 的 SDK 语言跟随唯一加载的运行时,且呈现模式在服务内统一**`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器(`typescript` 经 worker 后端,`python` 用于任何报告该语言的运行时);作用域限制/遮蔽仍会选择每个 agent 的可见绑定,但不能让一个工具仅使用 Native另一个仅使用 Code,且单个运行时把语言固定为服务级([语言分发 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md) 负责这次查表,以及注册表为何读取所加载的运行时而不自带 language 字段)
- **Code Mode 中间值只存在于执行局部,且没有字节上限**:这些规范的类型化值无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。每个子调用的持久日志副本则确实有上限:`tools/code-dispatch-log` waterfall 允许 spill 策略把过大的 `tool/code-dispatch` 内容替换为预览加定位符([原理](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md))。
- **每次运行都会获得全新的 `run_code` 状态**MVP 不采用持久 REPL 风格内核(跨调用状态不会出现在日志中);参见 [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。

View File

@@ -11,7 +11,7 @@ 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'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { defineTool } from './schema.ts'
import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts'
import { TOOL_REGISTRY_SCHEDULER } from './index.ts'
import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts'
@@ -56,6 +56,111 @@ export const RUN_CODE_NAME = 'run_code'
/** The `tools:sdk` section order: inside the 100199 tool-guidance band, after per-tool guidance sections. */
export const SDK_SECTION_ORDER = 150
/**
* The language-specific `run_code` schema text: the tool `description` and its
* `code` parameter description, kept together so a language's two model-facing
* strings share one source of truth. Keyed by `CodeRuntime.language`, mirroring
* `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the
* semantics the same language's SDK instructions promise, so the model never
* receives a TypeScript-shaped schema beside a Python SDK (or vice versa).
*/
interface RunCodeFlavor {
/** The tool `description` the model sees for this language. */
readonly description: string
/** The `code` parameter's description for this language. */
readonly codeDescription: string
}
/**
* The TypeScript flavor: the historical default, and the fallback for a schema
* read with no runtime mounted ({@link resolveFlavor} owns which readers reach
* that). A real assembly always resolves a runtime first, so the model never
* sees this fallback outside its own language.
*/
const TYPESCRIPT_FLAVOR: RunCodeFlavor = {
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.',
codeDescription: 'The program: the body of an async TypeScript function.',
}
/**
* The Python flavor: the body of an async function, top-level `await` and
* `return`, answer via `print` and/or the returned value, matching
* {@link ./py-types.ts}'s SDK instructions.
*/
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 <value>` — only that comes back, so curate it.',
codeDescription: 'The program: the body of an async Python function.',
}
/**
* The languages Code Mode ships a presentation for. Both per-language tables —
* {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are
* checked against this union with `satisfies`, so a language added to one and
* not the other fails `typecheck` instead of waiting for a runtime that reports
* it. The tables stay declared `Record<string, …>` because `CodeRuntime.language`
* is an unconstrained `string`: this union pins what the harness ships, while the
* `Object.hasOwn` guards reject what a mounted runtime may report.
*/
export type CodeSdkLanguage = 'typescript' | 'python'
/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link CodeSdkLanguage}. */
const RUN_CODE_FLAVORS: Record<string, RunCodeFlavor> = {
typescript: TYPESCRIPT_FLAVOR,
python: PYTHON_FLAVOR,
} satisfies Record<CodeSdkLanguage, RunCodeFlavor>
/**
* The `description` parameter's model-facing description: language-independent
* (the UI label contract is the same for every runtime), shared between the
* static spec and the language-aware `parameters` getter so the two emissions
* can never drift.
*/
const RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION
= 'Clear, concise description of what this program does in active voice, '
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".'
/**
* Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at
* schema-emission time so the model-visible `run_code` schema always matches
* the SDK section's language. `peekRuntime` returns `undefined` only when no
* runtime is mounted, which reaches this function through definition readers
* and `schemas()` — the doc-catalog harvest is the only shipped one, and none
* of them feeds a model, because `wireSchemas` calls `requireCodeRuntime`
* before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A
* mounted runtime whose language has no flavor entry fails loud, exactly as
* `requireCodeRuntime` rejects it at assembly. Keeping this table in step with
* `SDK_RENDERERS` is the compiler's job ({@link CodeSdkLanguage}); what this
* guard owns is the runtime-supplied language neither table knows, which never
* yields a wrong-language schema for a real runtime.
*/
function resolveFlavor(peekRuntime: () => CodeRuntime | undefined): RunCodeFlavor {
const runtime = peekRuntime()
if (runtime === undefined) {
// No runtime mounted: reached by definition readers and `schemas()`, of
// which the doc-catalog harvest is the only shipped one. None feeds a
// model — `wireSchemas` calls `requireCodeRuntime` before projecting, so
// the assembly path never arrives here. Degrade to the TS default.
return TYPESCRIPT_FLAVOR
}
// Own-property read: a language like `toString`/`constructor` would otherwise
// resolve an inherited Object.prototype member as a flavor.
const flavor = RUN_CODE_FLAVORS[runtime.language]
if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) {
const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ')
throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
}
return flavor
}
/**
* Thrown by `run_code` when the program run itself failed — a program
* exception, a budget expiry, an abort, or substrate death. Extends
@@ -194,6 +299,13 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
export interface RunCodeBridgeOptions {
/** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */
requireRuntime: () => CodeRuntime
/**
* Reads `ctx.codeRuntime` without throwing: `undefined` when none is mounted.
* Lets schema emission tell "no runtime" (degrade to TS; the readers that
* reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail
* loud).
*/
peekRuntime: () => CodeRuntime | undefined
/** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */
maxParallel: number
/** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */
@@ -212,22 +324,22 @@ export interface RunCodeBridgeOptions {
* @returns the registry-ready definition.
*/
export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition {
const { requireRuntime, maxParallel, shapeDispatchLog } = options
return defineTool({
const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options
const definition = defineTool({
name: RUN_CODE_NAME,
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.',
// The description and `code` parameter description are placeholders here:
// the language-aware getters installed below replace both, resolving the
// loaded runtime's flavor at schema-emission time so the schema the MODEL
// sees matches the SDK section's language. Argument VALIDATION still keys
// off this static spec (defineTool closes over it), which is language-
// independent (one required string `code`).
description: TYPESCRIPT_FLAVOR.description,
parameters: {
code: { type: 'string', required: true, description: 'The program: the body of an async TypeScript function.' },
code: { type: 'string', required: true, description: TYPESCRIPT_FLAVOR.codeDescription },
description: {
type: 'string',
required: true,
description: 'Clear, concise description of what this program does in active voice, '
+ '5-10 words (shown in the UI). Examples: "Count TODO markers across packages"; '
+ '"Read failing test and its fixture"; "Rename config key in every cordis.yml".',
description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION,
},
},
output: {
@@ -569,4 +681,22 @@ export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridge
// title and reads durable result content without duplicating a large raw
// result into the host view payload.
})
// Resolve the language flavor lazily, at the moment the registry projects the
// schema (`schemaOf` destructures `description`/`parameters`). The definition
// is minted once at registration, before a runtime is known; deferring here
// is the least invasive point that still emits the loaded runtime's language.
Object.defineProperty(definition, 'description', {
enumerable: true,
get: () => resolveFlavor(peekRuntime).description,
})
Object.defineProperty(definition, 'parameters', {
enumerable: true,
// Recompile through the same spec→schema projection defineTool used, so
// the emitted shape can never drift from the validated one.
get: () => parameterSchemaSpecToJsonSchema({
code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription },
description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION },
}) as unknown as Record<string, unknown>,
})
return definition
}

View File

@@ -22,8 +22,31 @@ import type { ToolCallView, ToolResultView } from './presentation.ts'
import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode } from './json-schema.ts'
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
import type { CodeSdkLanguage } from './code-mode.ts'
import { renderToolsSdk } from './ts-types.ts'
import type { ToolSdkSchema } from './ts-types.ts'
import { renderToolsSdkPy } from './py-types.ts'
/**
* Language → SDK-section renderer. The registry looks up the loaded
* `ctx.codeRuntime.language` in this table when assembling the `tools:sdk`
* section under a non-native mode; a runtime whose language is not a key
* fails the assembly loudly (same idiom as `toolOrder` violations). Adding a
* new backend language is three parallel edits — a {@link CodeSdkLanguage}
* member, an entry here, and a `RUN_CODE_FLAVORS` entry in `code-mode.ts` for
* its `run_code` schema strings — plus the renderer function this table points
* at. The `satisfies` clause pins this table's key set to that union, which
* the flavor table is checked against too, so any of the three left out is a
* typecheck failure. What no check reaches is the prose that names the values
* instead of deriving them: the seam's `dsh-code-runtime` README pair, its
* `CodeRuntime.language` JSDoc, and `docs/core-data-structures/code-runtime.md`
* with its zh pair, plus this package's own README pair and the
* {@link Config.mode} JSDoc.
*/
const SDK_RENDERERS: Record<string, (schemas: ToolSdkSchema[]) => string> = {
typescript: renderToolsSdk,
python: renderToolsSdkPy,
} satisfies Record<CodeSdkLanguage, (schemas: ToolSdkSchema[]) => string>
export {
defineTool,
@@ -65,6 +88,7 @@ export type { JsonValue } from '@deepseek-ai/dsh-session'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'
export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts'
export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
@@ -593,8 +617,9 @@ export interface Config {
/**
* Model presentation. `native` (default) sends every visible schema; `code`
* sends only `run_code` plus a generated SDK prompt; `both` sends both forms.
* Code modes require a TypeScript runtime and fail prompt assembly when it is
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
* Code modes require a `ctx.codeRuntime` whose `language` has a registered
* SDK renderer (TypeScript or Python) and fail prompt assembly when it is
* absent or has no renderer. Under `code`, native names in `toolOrder` are invalid.
*/
mode?: ToolPresentationMode
/**
@@ -757,6 +782,7 @@ export class ToolRegistry extends Service {
? undefined
: createRunCodeTool(this, {
requireRuntime: () => this.requireCodeRuntime(),
peekRuntime: () => this.ctx.get('codeRuntime'),
maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls),
shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch),
})
@@ -765,10 +791,21 @@ export class ToolRegistry extends Service {
ctx.systemPrompt.section({
name: 'tools:sdk',
order: SDK_SECTION_ORDER,
// Regenerate from the calling scope's visible tools in stable order.
// Regenerate from the calling scope's visible tools in stable order,
// picking the renderer that matches the loaded runtime's language.
// `requireCodeRuntime` already validated the language is in the table,
// so the guard below is defense-in-depth against a caller that bypassed
// it (impossible under normal composition).
text: (context) => {
this.requireCodeRuntime()
return renderToolsSdk(this.sdkSchemas(context.scope))
const runtime = this.requireCodeRuntime()
// Own-property read: a language like `toString`/`constructor` would
// otherwise resolve an inherited Object.prototype member as a renderer.
const render = SDK_RENDERERS[runtime.language]
/* v8 ignore next 3 -- requireCodeRuntime rejects an unknown language before this ever runs. */
if (!Object.hasOwn(SDK_RENDERERS, runtime.language) || render === undefined) {
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')})`)
}
return render(this.sdkSchemas(context.scope))
},
})
}
@@ -780,11 +817,17 @@ export class ToolRegistry extends Service {
*/
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
const view = this.view(scope)
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
if (this.mode === 'native') {
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
return { schemas, knownNames: [...view.knownNames] }
}
// Validate the runtime language BEFORE projecting schemas: schemaOf reads
// run_code's language-aware description/parameters getters, whose own
// flavor-table guard would otherwise surface first. This keeps the
// renderer-table rejection the canonical assembly-time error for a
// language with no SDK renderer.
this.requireCodeRuntime()
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
if (this.mode === 'code') {
return {
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
@@ -801,14 +844,23 @@ export class ToolRegistry extends Service {
* behind it — hostage to a code runtime existing even under `mode:
* 'native'` (the loop's optional-backend idiom, same as
* `sessionPersistence`).
*
* Assembly and `run_code` execution read separately, so the language is not
* bound to a request. Harmless while one published backend exists — both
* reads return the same flavor — but a reload that swapped in a second
* language between them would hand a program written against one SDK to the
* other. Binding it belongs to the PR that publishes that backend, which is
* also the first point it can be tested; recorded in the
* [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md).
*/
private requireCodeRuntime(): CodeRuntime {
const runtime = this.ctx.get('codeRuntime')
if (!runtime) {
throw new Error(`dsh-tools: mode "${this.mode}" requires a code runtime — load a ctx.codeRuntime implementation (e.g. @deepseek-ai/dsh-code-runtime-worker) or set tools mode to "native"`)
}
if (runtime.language !== 'typescript') {
throw new Error(`dsh-tools: mode "${this.mode}" generates a TypeScript SDK, but the loaded code runtime's language is "${runtime.language}"`)
if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) {
const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ')
throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`)
}
return runtime
}

View File

@@ -0,0 +1,818 @@
/**
* Code Mode codegen — Python flavor. The pure projection from registered tool schemas to the
* Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of
* {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry
* store, keyed by the loaded {@link @deepseek-ai/dsh-code-runtime#CodeRuntime.language | code
* runtime's language}.
*
* Under `mode: 'code'` the native tool schemas are omitted from the request, so this generated
* SDK is the model's ONLY source for each tool's argument names, required fields, types,
* descriptions, and canonical output shapes; under `mode: 'both'` the native schemas ship
* alongside it and it is one of two. Object-shaped arguments and outputs therefore render as one
* named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the
* shape survives into the program under the mode that has nothing else to carry it.
* @module @deepseek-ai/dsh-tools/src/py-types
*/
import { assertSupportedJsonSchema } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
import type { ToolSdkSchema } from './ts-types.ts'
/**
* The reference grammar's `xid_start xid_continue*` — the set
* `str.isidentifier()` accepts on a CPython whose Unicode tables match the
* engine's. See {@link isBareIdentifier} for what a version skew does.
*/
const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u
/**
* Whether a name can be emitted as a bare Python identifier rather than
* routed to the subscript/`dict[str, Any]` path.
*
* Python identifiers are not ASCII: `路径` is as legal a field name as `path`,
* and rejecting it would degrade the whole enclosing object, dropping every
* field's name, requiredness, and type — information whose only source under
* `mode: 'code'` is this generated text.
*
* NFKC stability is a second and separate condition, because CPython
* normalizes identifiers at compile time while JSON keys are compared as
* written: `field` would be declared and reachable as `field`, so the SDK would
* advertise a key under a spelling the harness never accepts, and two keys
* that normalize together would collapse into one declaration. Those names
* take the subscript path, which carries their exact bytes.
*
* `IDENTIFIER`'s equivalence to `str.isidentifier()` was measured across 21
* samples with zero divergence, on Node 22.23.1 against CPython 3.9.6 — every
* sample sits inside the two versions' shared tables, and the skew characters
* below are exactly where that pair diverges. The predicate as a whole is
* deliberately stricter than `isidentifier()`, which does not test NFKC
* stability: `'field'.isidentifier()` is True and this returns false.
*
* Both conditions are evaluated against the ENGINE's Unicode tables, and the
* two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}`
* follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython
* follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython
* older than the engine is the dangerous direction: a character added to either
* property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; U+1E4D0
* Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per `DerivedAge.txt`; all
* four are NFKC-stable and accepted here, and all four are `Cn` on that 3.9.6,
* which rejects them) is emitted bare and its tokenizer refuses the character,
* taking the whole SDK block down — the same parseability invariant
* {@link UNPRINTABLE}, {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING}
* exist for. Both properties carry it: a character added only to `XID_Continue`
* passes the trailing `\p{XID_Continue}*` in a tail position and fails the same
* way — U+200C ZWNJ and U+200D ZWJ are that case, gaining `XID_Continue` in UCD
* 15.1 and absent from it in 13.0.0, 14.0.0 and 15.0.0, so `a\u{200C}b` is
* emitted bare here while `isidentifier()` is False on 3.9.6 and on 3.12.13
* (15.0.0). A CPython newer than the engine only routes a legal name to the
* subscript/`dict[str, Any]` path: less readable, still correct. The NFKC
* condition reduces to the same skew, since normalization stability guarantees
* an assigned character's normalization never changes afterwards.
*
* This predicate is not the only reader of engine tables. {@link camelCase}
* reads them at three further points — its split set, its head test, and its
* `toUpperCase()` case mapping — and this predicate's verdict gates none of
* them: a class name derived there reaches emitted text whenever any object
* shape in the tool's schema declares a `TypedDict`, including for a tool this
* predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never
* reaches the skew here (the `-` rejects it outright) yet emits `class
* Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari arrived two
* releases after its tables. The case mapping is a separate table rather than
* an XID membership test, and it fails on names both conditions above accept:
* `\u{019B}` is XID_Start and NFKC-stable, so this predicate accepts it and
* `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to
* `\u{A7DC}` — unassigned in that CPython, whose own `.upper()` is the identity
* here — and the declared `class \u{A7DC}Args` fails with `invalid
* non-printable character U+A7DC`. Closing the exposure therefore covers all
* four read points, not this predicate alone; it needs the target interpreter's
* version, which the backend reporting `language: 'python'` owns and which is
* unpublished on this base, so the note records it as that PR's decision.
*
* The `ts-types` sibling keeps its own ASCII rule rather than sharing this
* one: ECMAScript identifiers are a different set (`$`) and are never
* normalized, so one predicate cannot be correct for both. ZWJ/ZWNJ are not
* part of that difference — both sets carry them on the engine's tables; what
* separates the two there is the CPython table version above.
* @param name - the raw schema field or tool name.
* @returns whether the name can be emitted bare.
*/
function isBareIdentifier(name: string): boolean {
return IDENTIFIER.test(name) && name.normalize('NFKC') === name
}
/**
* Python hard keywords: reserved everywhere, so a tool or field named
* ``class`` or ``lambda`` is legal on the wire but not as an attribute
* (``tools.class`` would be a SyntaxError in the model program) and not as a
* class-syntax `TypedDict` field. Such a tool renders under subscript access
* and such an object degrades to ``dict[str, Any]`` — the model still reaches
* every tool and field without collisions.
* Soft keywords (``match``, ``case``, ``type``, ``_`` — the language
* reference's whole set) are deliberately ABSENT: each is special in exactly
* one syntactic position — a statement head (``match``, ``type``), a ``match``
* statement's clause head (``case``), or a pattern (``_``) — so ``match: str``
* as a field and ``async def match(...)`` as a method are both legal, and
* including them would needlessly degrade common search/regex tool fields to
* ``dict[str, Any]``. Underscore-leading names are handled separately, not
* here: a non-dunder ``__token`` name-mangles, a dunder present on
* ``object``/``type`` resolves before the proxy hook, and implicit
* special-method lookup bypasses the hook.
*/
const RESERVED = new Set([
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
'return', 'try', 'while', 'with', 'yield',
// Not a keyword, but CPython refuses to ASSIGN it at compile time
// (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict
// field, a parameter name, and a keyword argument all are.
'__debug__',
])
/** `typing` symbols this module may emit, in the deterministic import order. */
const TYPING_ORDER = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict'] as const
/** `indent`-deep line prefix (four spaces per level to match PEP 8 output). */
function pad(indent: number): string {
return ' '.repeat(indent)
}
/**
* Collector threaded through {@link renderType}: the emitted `TypedDict` class
* declarations (nested classes precede the parent that references them), the
* class names already taken (for collision suffixing), a per-base collision
* counter, and the `typing` symbols the render actually used.
*/
interface RenderState {
readonly classes: string[]
readonly usedClassNames: Set<string>
/** Next collision counter per capped base, so allocation is amortized O(1) instead of rescanning from `2`. */
readonly nextClassCounter: Map<string, number>
readonly typing: Set<string>
}
/**
* The `Cc` code points that survive the whitespace collapse in {@link describe}
* and have no printable form: the C0 controls, DEL, and the C1 controls. Only
* U+0009 to U+000D are absent, because ECMAScript `\s` already collapsed them —
* `\s` is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, so no C1 code point is
* in it and the whole U+0080 to U+009F block reaches this rule intact. Those
* are not hypothetical input: they are what Windows-1252 bytes 0x80 to 0x9F
* (smart quotes, em dash) become when decoded as Latin-1.
* CPython rejects source containing a NUL outright
* (`SyntaxError: source code string cannot contain null bytes`), whether it
* sits in a docstring or in a comment, so one such byte anywhere in a schema
* description would make the whole generated SDK unparseable — under
* `mode: 'code'`, the model's only declaration of the tools. The rest are
* legal but invisible; escaping them with the same rule keeps the emitted text
* readable and the treatment uniform.
*
* The boundary is the category, not per-code-point addressability: `\xNN`
* addresses U+0000 to U+00FF, so one escape form covers `Cc` exactly. The
* invisible `Cf` formatting characters pass through by design — of them only
* U+00AD soft hyphen would fit `\xNN` at all, and escaping that one while
* U+200B ZWSP, U+200E/U+200F bidi marks, and U+2060 word joiner passed through
* would leave a rule that is neither category- nor addressability-shaped. The
* whole family is legal in both consumers, since only LF and CR terminate a
* Python string literal or a `#` comment. That set is the tokenizer's, not
* `str.splitlines()`': NEL (U+0085), LS (U+2028), and PS (U+2029) split a
* string at run time but do not end a physical line in source — measured on
* CPython 3.9.6 and 3.12.13, each accepted in both positions with the value
* round-tripping — so they are safe raw wherever they reach emitted text
* unescaped, which for all three is `JSON.stringify`, at two call sites:
* {@link pyScalar}'s literal path, and the subscript tool-name comment's own
* call, which a name carrying any of them always reaches, none being
* `XID_Continue`. The `description` path escapes NEL under the class above and
* folds LS and PS in {@link describe}'s `\s+` collapse, both being `\s`.
*/
const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g
/**
* Unpaired surrogate code points, escaped by {@link describe} as `\uNNNN` —
* its own form, since `\xNN` stops at U+00FF. The `u` flag is what makes this
* the LONE ones: in Unicode mode a well-formed pair is a single astral code
* point outside D800 to DFFF, so an emoji in a description survives untouched.
*
* This is the NUL case from {@link UNPRINTABLE}, not the invisible-character
* case. Python source must be UTF-8-encodable and a lone surrogate is not, so
* `compile()` raises `UnicodeEncodeError: surrogates not allowed` for one
* anywhere in the text — measured on 3.9 for a string literal and for a `#`
* comment alike. A raw or MCP tool description reaches this: `JSON.parse` on a
* wire `"\ud800"` escape yields exactly such a code point.
*/
const LONE_SURROGATE = /[\ud800-\udfff]/gu
/**
* The collapsed one-line `description` of a schema node (byte-stable across
* formatting churn), or `undefined` when the node carries none. Every caller
* passes an object — a validated property node, the `ToolSdkSchema` itself, or
* the `{ description }` wrapper {@link docLines} synthesizes — so only the
* description field needs guarding. A description that collapses
* to nothing (empty, or whitespace only) is `undefined` too: it documents the
* node no better than an absent one, and emitting it would leave an empty
* `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace
* folds, so a description of whitespace plus one surviving control character is
* NOT absent: it collapses to that character's visible escape.
*
* Control characters left over after the whitespace collapse are rendered as
* their `\xNN` escapes (see {@link UNPRINTABLE}) and unpaired surrogates as
* their `\uNNNN` escapes (see {@link LONE_SURROGATE}); the escape's own backslash is
* emitted literally by both consumers, since {@link docLines} doubles it into a
* Python source escape and a `#` comment carries it verbatim.
*/
function describe(schema: object): string | undefined {
const description = (schema as Record<string, unknown>).description
if (typeof description !== 'string') return undefined
const collapsed = description
.replace(/\s+/g, ' ')
.replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.replace(LONE_SURROGATE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
.trim()
return collapsed.length === 0 ? undefined : collapsed
}
/**
* One-line docstring for a tool `description`, or no lines when there is none.
* Backslashes are doubled first, every quote is escaped, and a trailing
* backslash cannot survive: a description ending in `"` or an odd backslash
* would otherwise merge with (or escape) the closing triple quote and make
* the generated block — Code Mode's only SDK — syntactically invalid Python.
*/
function docLines(description: unknown, indent: number): string[] {
const collapsed = describe({ description })
if (collapsed === undefined) return []
const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', '\\"')
return [`${pad(indent)}"""${escaped}"""`]
}
/**
* CamelCase a name into a Python type identifier: non-identifier characters
* split words, `_` splits too (it is `XID_Continue`, so the split set names it
* explicitly), and a head that cannot start an identifier takes a `Tool`
* prefix. Unicode survives, so a `路径` field yields `路径`-based class names
* instead of collapsing to the bare prefix. A character that is not
* `XID_Continue` splits even when it is a letter, so a name whose NFKC folding
* would leave the identifier set is not carried through — the split set is the
* grammar's, not an ASCII approximation of it.
*
* The result is NFKC-normalized: these names are generated, never matched
* against a JSON key, so normalizing is free here and keeps what CPython
* compiles identical to what is emitted — unlike {@link isBareIdentifier},
* which must reject unstable names outright. Normalizing AFTER the prefix
* decision is what makes that hold at the seam the prefix creates: `Tool` +
* a combining-mark head composes there (`U+0301` gives `Tooĺ`, U+013A), so
* normalizing only the un-prefixed part would emit a name CPython compiles to
* a different symbol. The second call is idempotent on the un-prefixed arm.
*
* The split set, the head test, and `toUpperCase()` all read the engine's
* Unicode tables, so this function carries the same version skew
* {@link isBareIdentifier} documents, by paths independent of it: a class name
* derived here reaches emitted text whenever any object shape in the tool's
* schema declares a `TypedDict`, and the predicate's verdict on the tool name
* does not gate that. The case mapping is the one that can fail on a name the
* predicate accepted; the worked example is there.
* @param raw - the schema field or tool name to derive from.
* @returns a class-name segment safe to emit.
*/
function camelCase(raw: string): string {
const joined = raw
.split(/[^\p{XID_Continue}]+|_+/u)
.filter(part => part.length > 0)
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join('')
.normalize('NFKC')
return (/^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}`).normalize('NFKC')
}
/** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
const MAX_CLASS_NAME_BASE = 120
/**
* Deepest `list[…]` nesting emitted into one annotation before the item type
* degrades to `Any`. CPython's tokenizer rejects a logical line holding more
* than 200 simultaneously-open brackets (`MAXLEVEL`, `SyntaxError: too many
* nested parentheses`), so an array chain deeper than that would render an SDK
* block that is not valid Python at all — the same failure the docstring
* escaping in {@link docLines} exists to prevent. 180 leaves headroom for the
* few brackets an annotation can add around the chain, all of which count
* toward the same limit. Per emission site, counting brackets open at the
* chain's innermost point:
*
* - Return annotation, `async def f(self, args: X) -> chain:` — 180 `list[`
* plus an innermost `Literal[`. The parameter list's `(` closed at the `)`
* before the `->`, so it is NOT open here: 181.
* - TypedDict field, `field: NotRequired[chain]` — a class-body line with no
* other open bracket, and its children start at `listDepth: 1` to reserve
* the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. Required fields
* share that start for uniformity, spending one level of representable depth
* on a bracket they never emit.
* - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS
* still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the
* worst case. Reachable only through a raw `register()` whose `parameters`
* is an array reached from the root through `oneOf` arms alone — the root
* array itself, or one nested under any depth of unions, since an arm
* inherits the enclosing depth unchanged (`A | B` opens no bracket). An
* object ancestor takes it out of this case: its fields restart the chain at
* the 181 site. `defineTool` compiles an object root, so the annotation is a
* bare TypedDict class name or a one-bracket `dict[str, Any]` when that
* object degrades — never a chain.
*
* A CPython grammar limit, not a deployment choice, so it is fixed rather than
* configurable. The sibling `ts-types` renderer needs no counterpart: nothing
* in the TypeScript grammar bounds nesting, and its SDK block is never type-
* checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B`
* chain and nested objects render as separate `class` statements, so neither
* accumulates open brackets at any depth. The invariant this cap serves is
* grammatical validity; see the `oneOf` arm in {@link renderType} for the one
* interpreter limit deliberately left uncapped.
*/
const MAX_LIST_NESTING = 180
/**
* Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for
* why capping keeps the render linear). `slice` counts UTF-16 code units, so
* an astral character straddling the boundary would be cut in half and leave a
* lone surrogate — not an identifier character, and not even well-formed text;
* drop it rather than emit it.
*/
function capClassNameBase(base: string): string {
if (base.length <= MAX_CLASS_NAME_BASE) return base
const capped = base.slice(0, MAX_CLASS_NAME_BASE)
return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped
}
/**
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
* derive from their parent's allocated name (`ParentChild`), so an unbounded
* schema of single-field objects would otherwise grow each name by one field
* per level and the sum of all names to Θ(depth²). Capping the base keeps each
* name — and the total emitted text — linear in depth. Collisions resume from
* the per-base counter in `state.nextClassCounter` rather than rescanning from
* `2`, so a deep chain sharing one capped base stays O(1) per allocation
* (amortized) instead of Θ(depth²) in time.
*/
function allocateClassName(base: string, state: RenderState): string {
const capped = capClassNameBase(base)
let name = capped
if (state.usedClassNames.has(name)) {
let n = state.nextClassCounter.get(capped) ?? 2
while (state.usedClassNames.has(`${capped}${n}`)) n++
name = `${capped}${n}`
state.nextClassCounter.set(capped, n + 1)
}
state.usedClassNames.add(name)
return name
}
/**
* Append a child-name segment to a parent class-name base, capping the result
* at {@link MAX_CLASS_NAME_BASE}. Capping AT PROPAGATION (not only inside
* {@link allocateClassName}) keeps each level O(1): a deep `oneOf`- or
* object-chain would otherwise carry an ever-growing ConsString down the tree
* and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²).
* The bounded base plus the collision counter still yields unique names.
*
* The join is NFKC-normalized because both sides are separately normalized yet
* their concatenation need not be: a base ending in a Hangul L jamo or LV
* syllable composes with a following V or T jamo head (`가` + `ᆨ` gives `각`),
* so the emitted class name would differ from the symbol CPython compiles, and
* two byte-distinct names could fold onto one — `usedClassNames` dedupes by the
* raw bytes, so the collision counter would not see it. Normalizing costs
* O(cap + segment) per level, the same order as the `slice` it feeds. The other
* two join points need no counterpart: `Args`/`Output` start with `A`/`O` and
* {@link allocateClassName}'s suffix is digits, none of which compose backwards.
*/
function childClassName(base: string, segment: string): string {
return capClassNameBase(`${base}${segment}`.normalize('NFKC'))
}
/**
* Render one validated scalar as Python literal text (`True`/`False`,
* JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
* type renders directly as `None`, and the unified validator rejects a null
* `const`/`enum` entry on every other scalar type.
*
* A beyond-safe-range integral number takes `BigInt` digits rather than
* `String`: Python integers are arbitrary-precision, so the emitted digits ARE
* the value the model programs against, and `String` can give a different
* integer than the double holds (`2 ** 60` prints the rounded `...847000`, not
* the exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`).
* `String`'s rounding is not a bug in it: `Number::toString` emits the shortest
* decimal string that re-reads to the same double, then pads to the exponent
* with zeros (1 significant digit for `1e20`, 16 for `2 ** 60`) — and when the
* shortest string is shorter than the double's exact value, those padded digits
* name an integer no double holds. Passing one back would have to cross the
* argument boundary as a JSON number — a double again — so the SDK would
* document a value no program can pass. `BigInt` needs no case split: where
* `String` is already exact (`2 ** 53`, `1e20`) the two agree byte for byte,
* and where it is not, `BigInt` is the exact one. The TS flavor needs no
* counterpart at all: its literal is re-read by a JS parser back into the same
* double.
*
* `JSON.stringify` is also what keeps this path's output parseable, and it is
* the only thing that does. It covers both classes of hazard: the two kinds of
* code point CPython refuses anywhere in source — NUL among the C0 controls,
* and the whole D800DFFF unpaired-surrogate block, escaped under ES2019
* well-formed stringification, which the engines range guarantees — and the
* ones that break this line in particular, a bare `"` closing the literal
* early, a trailing odd backslash eating the closing quote, and a bare LF/CR
* ending it before its terminator. The `description` path carries
* {@link UNPRINTABLE} and {@link LONE_SURROGATE} because nothing quotes it,
* and folds newlines in {@link describe}.
*
* That leans on a coincidence worth naming: every escape `JSON.stringify` can
* emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python
* escape denoting the same character, so the emitted `Literal[...]` both
* parses and decodes back to the value the schema declared. DEL, the C1
* controls (NEL among them), and LS/PS (U+2028/U+2029) do reach it raw —
* legal but invisible, byte-for-byte as in the TS flavor; escaping them is a
* both-flavors change. Those last three are legal here for the reason
* {@link UNPRINTABLE} records: they are `str.splitlines()` boundaries, not
* tokenizer line terminators. The subscript tool-name comment quotes its name
* through its own call to the same `JSON.stringify`, never through this
* function, and inherits both halves — escapes and pass-throughs alike.
*/
function pyScalar(value: JsonSchemaScalar): string {
if (value === true) return 'True'
if (value === false) return 'False'
if (typeof value === 'string') return JSON.stringify(value)
if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
return BigInt(value).toString()
}
return String(value)
}
/**
* Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to
* the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
* parameters to int/bool/str/bytes/enum/None: a non-integral number
* `const`/`enum` emits a float literal (`Literal[1.5]`) a strict checker would
* reject. An integral one does not deviate — {@link pyScalar} emits int digits,
* including for the beyond-safe-range values it widens through `BigInt`, and
* PEP 586 admits int parameters. Harmless either way — the stub is advisory
* prompt text, only required to parse — and keeping the exact value
* communicates the constraint to the model.
*/
function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string {
if (node.const !== undefined) {
state.typing.add('Literal')
return `Literal[${pyScalar(node.const)}]`
}
if (node.enum !== undefined) {
state.typing.add('Literal')
return `Literal[${node.enum.map(pyScalar).join(', ')}]`
}
return broad
}
/**
* Map one JSON-Schema node to a Python type expression, threading `state` to
* collect the `TypedDict` declarations and `typing` symbols a full render
* needs. `className` is the name to give an object node with properties (and
* the prefix for its nested objects). Handles every unified schema construct —
* `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`),
* `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any`
* without throwing, the same trusted-after-validation stance as the sibling
* {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the
* context-free entry point; this is the collecting core.
*/
function renderType(schema: unknown, className: string, state: RenderState): string {
interface Frame {
// A validated JSON-schema node past the root `assertSupportedJsonSchema`
// (the root frame's schema is asserted before any frame is built), so the
// walk reads its fields without casts — the same typed-frame shape as the
// sibling ts-types renderer.
schema: JsonSchemaNode
className: string
phase: 'start' | 'children'
kind?: 'oneOf' | 'array' | 'typeddict'
node?: JsonSchemaNode
/** Open `list[` brackets enclosing this node in the annotation being built ({@link MAX_LIST_NESTING}). */
listDepth: number
children: { schema: JsonSchemaNode; className: string; listDepth: number }[]
childIndex: number
childTypes: string[]
entries: [string, JsonSchemaNode][]
allocated?: string
}
const newFrame = (schema: JsonSchemaNode, className: string, listDepth: number): Frame =>
({ schema, className, phase: 'start', listDepth, children: [], childIndex: 0, childTypes: [], entries: [] })
try {
// Validate the WHOLE tree once, then trust it — the same contract the
// sibling ts-types renderer follows at a typed same-process seam. Every
// node past this point is a validated JSON-schema node, so the walk reads
// its fields without re-checking. An unsupported or malformed schema throws
// here (before anything is emitted) and degrades to `Any`, the Python
// counterpart of the TS flavor's `unknown`.
assertSupportedJsonSchema(schema)
const frames: Frame[] = [newFrame(schema, className, 0)]
let result: string | undefined
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
const finish = (type: string): void => {
frames.pop()
const parent = frames.at(-1)
if (parent === undefined) result = type
else parent.childTypes.push(type)
}
while (frames.length > 0) {
const frame = frames.at(-1)
/* v8 ignore next -- the loop condition guarantees a current frame. */
if (frame === undefined) break
if (frame.phase === 'children') {
if (frame.childIndex < frame.children.length) {
const child = frame.children[frame.childIndex]
/* v8 ignore next -- childIndex is bounded by children.length. */
if (child === undefined) throw new Error('missing python render child')
frame.childIndex++
frames.push(newFrame(child.schema, child.className, child.listDepth))
continue
}
if (frame.kind === 'oneOf') {
// Concatenate incrementally (template literal, not `Array.join`): V8
// builds a lazy ConsString, so a deep oneOf chain materializes once
// at the root instead of re-materializing the accumulated string at
// every level (which `join` would, making it Θ(depth²)). This matches
// the array arm's template-literal laziness and ts-types' composable-
// document approach — the whole walk stays linear in schema depth.
let union = ''
for (const [index, childType] of frame.childTypes.entries()) {
union = index === 0 ? childType : `${union} | ${childType}`
}
finish(union)
continue
}
/* jscpd:ignore-end */
if (frame.kind === 'array') {
// `list[A | B]` needs no parentheses in Python. Array frames always
// schedule exactly one child, so its type is present.
/* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
finish(`list[${frame.childTypes[0] ?? 'Any'}]`)
continue
}
// typeddict: assemble AFTER the children so any nested class this one
// references is already declared (declaration order = reference order).
const node = frame.node
const name = frame.allocated
/* v8 ignore next -- typeddict frames always set node and allocated at start. */
if (node === undefined || name === undefined) throw new Error('missing typeddict frame state')
const required = new Set(node.required)
const lines = [`class ${name}(TypedDict):`]
for (let index = 0; index < frame.entries.length; index++) {
const entry = frame.entries[index]
const fieldType = frame.childTypes[index]
/* v8 ignore next -- entries and childTypes correspond one-to-one. */
if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type')
const [field, fieldSchema] = entry
// The parent node passed assertSupportedJsonSchema, so every property
// value is a validated schema node.
const description = describe(fieldSchema)
if (description !== undefined) lines.push(`${pad(1)}# ${description}`)
if (required.has(field)) {
lines.push(`${pad(1)}${field}: ${fieldType}`)
} else {
state.typing.add('NotRequired')
lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`)
}
}
// TypedDict syntax cannot express openness, so an open object states it
// in-band: the annotation is advisory either way, and `mode: 'code'`
// omits the native schemas, making this line the model's only signal
// that extra keys are accepted.
if (node.additionalProperties !== false) {
lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`)
}
// A closed empty object still needs a class body (`pass`) to be valid
// Python; the declared emptiness is the information.
if (lines.length === 1) lines.push(`${pad(1)}pass`)
state.classes.push(lines.join('\n'))
finish(name)
continue
}
frame.phase = 'children'
const node = frame.schema
if (node.oneOf !== undefined) {
frame.kind = 'oneOf'
// A union renders as `A | B` — no brackets of its own, so the branches
// inherit the enclosing depth unchanged.
//
// Union LENGTH is deliberately uncapped, unlike list nesting. The two
// limits are different in kind: >200 open brackets is a SyntaxError
// from the tokenizer, so the text is not Python; a long `A | B | …`
// chain is grammatically valid at any length and only defeats CPython's
// C-recursion when `compile()` walks the left-nested BinOp spine
// (measured: 1,000 branches compile, 5,000 raise RecursionError). This
// block is prompt text — nothing compiles it — so that limit costs
// nothing here, while capping would retire the deep-chain tests that
// pin the walk's linear time and the class-name propagation cap. The
// standard this renderer holds is grammatical validity, not
// compilability under one interpreter's stack.
frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth }))
continue
}
if (node.type === undefined) {
state.typing.add('Any')
finish('Any')
continue
}
switch (node.type) {
case 'string': finish(renderConstrainedScalar(node, 'str', state)); break
case 'number': finish(renderConstrainedScalar(node, 'float', state)); break
case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break
case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break
case 'null': finish('None'); break
case 'array': {
if (node.items === undefined) {
state.typing.add('Any')
finish('list[Any]')
break
}
// Past MAX_LIST_NESTING another `list[` would push the annotation
// beyond CPython's open-bracket limit and make the whole SDK block
// unparseable, so the chain degrades here instead — an unusable
// annotation either way, and this one is valid Python.
if (frame.listDepth >= MAX_LIST_NESTING) {
state.typing.add('Any')
finish('Any')
break
}
// An array of objects names its item type after the array field.
frame.kind = 'array'
frame.children = [{ schema: node.items, className: frame.className, listDepth: frame.listDepth + 1 }]
break
}
case 'object': {
// A missing `properties` is an empty property map, exactly as the
// unified validator and the TS renderer read it — NOT an unknown
// shape. The openness of the resulting empty object is decided below,
// so a closed empty object still declares an empty TypedDict rather
// than a permissive `dict[str, Any]`.
const entries = Object.entries(node.properties ?? {})
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
// there is no naming context to declare into, so degrade. This reads
// the CALL's className, not `frame.className`: the marker belongs to
// the whole walk, and frames propagate a derived name (a `oneOf`
// branch of the context-free root gets the index-derived name `1` —
// `childClassName` concatenates and caps, it does not go through
// `camelCase`), so a per-frame read would declare classes the caller
// has no way to receive, under a name that is not even a legal
// identifier: `class 1(TypedDict):`. A field
// name that is not a legal Python attribute is inexpressible as a
// class-syntax `TypedDict` field, so such an object degrades whole.
// A leading-double-underscore non-dunder field (`__token`) would be
// NAME-MANGLED inside class syntax (`_ClassName__token`), describing a
// different JSON key than the registered schema — degrade like any
// other inexpressible field name.
if (className === '' || !entries.every(([name]) => isBareIdentifier(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) {
state.typing.add('Any')
finish('dict[str, Any]')
break
}
// An OPEN empty object is any dict; a CLOSED empty object declares an
// empty TypedDict so "no keys accepted" survives into the SDK.
if (entries.length === 0 && node.additionalProperties !== false) {
state.typing.add('Any')
finish('dict[str, Any]')
break
}
frame.kind = 'typeddict'
frame.node = node
frame.allocated = allocateClassName(frame.className, state)
state.typing.add('TypedDict')
frame.entries = entries
// A field annotation is its own logical line, so nesting restarts —
// at 1, reserving the bracket an optional field's `NotRequired[…]`
// wraps around it. frame.allocated was assigned three statements up;
// the ?? arm is for the type system only.
/* v8 ignore next -- allocated is always set before children are built. */
frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)), listDepth: 1 }))
break
}
/* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */
default: {
state.typing.add('Any')
finish('Any')
}
}
}
/* v8 ignore next -- every root frame produces one expression. */
return result ?? 'Any'
} catch {
// An unsupported or malformed schema failed validation (before any
// emission), or an unreachable internal invariant tripped. Either degrades
// the node to `Any` rather than crashing prompt assembly — the Python
// counterpart of the TS flavor's `unknown` fallback.
state.typing.add('Any')
return 'Any'
}
}
/**
* Map one JSON-Schema node to a context-free Python type expression from the
* `typing` module. Handles every unified schema construct — `object` (degraded
* to `dict[str, Any]`: naming a `TypedDict` requires the render context that
* {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`),
* `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array`
* (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed
* schema, matching the TS flavor's `unknown` fallback. Type annotations in the
* emitted SDK are advisory: Python does not enforce them at runtime.
* @param schema - the JSON-Schema node.
* @returns the Python type text.
*/
export function jsonSchemaToPy(schema: unknown): string {
// A throwaway state whose class collector never escapes: an object with
// properties has nowhere to declare its TypedDict and degrades to
// dict[str, Any]. renderToolsSdkPy drives the named-TypedDict path.
return renderType(schema, '', { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set() })
}
/** The fixed model-facing usage contract rendered above the declarations. */
const SDK_INSTRUCTIONS = `## Writing code for run_code
Pass \`run_code\` the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing shapes — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program:
- 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 <value>\`; 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.
The available tools:`
/**
* Render the full `tools:sdk` prompt section under `runtime.language ===
* 'python'`: the Python-flavored usage instructions plus one named `TypedDict`
* per tool argument or output object (and per nested object) and one awaitable
* method per visible tool on a `Tools` protocol — typed args in, the tool's
* canonical output value out — with a `tools: Tools` singleton the model calls
* into. The `typing` import line lists exactly the symbols the render used.
* Deterministic — tools are emitted in lexicographic name order, and class
* declarations precede the protocol in that same order (nested classes before
* the parent that references them), so an unchanged tool set produces
* byte-identical text across assemblies. The sort is not a total order on
* byte-equal names, so two schemas sharing a name would render in argument
* order; the caller's visible-capability map is keyed by name, so the input
* never carries a duplicate.
* @param schemas - the tool schemas plus canonical output schemas to declare
* (the caller excludes `run_code` itself).
* @returns the complete section text.
*/
export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string {
const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) }
// ONE ordered member stream, matching the documented lexicographic contract
// and the TypeScript flavor (which quotes exotic keys in place rather than
// partitioning them out). Interleaving is free here: a comment line between
// two `async def` lines is not a statement, so it changes nothing about how
// the class body parses.
const members: string[] = []
let statements = 0
for (const schema of sorted) {
const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state)
const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state)
if (isBareIdentifier(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) {
// A docstring only documents its method when it is the FIRST statement
// of that method's body. Emitted before the `async def` it would instead
// become the `Tools` class docstring (for the first tool) or a dead
// expression (for every later one), leaving every method undocumented —
// and under `mode: 'code'` this SDK is the model's only description of
// what a tool does. A docstring is a complete body, so the `...` stub is
// only for the description-less case.
const doc = docLines(schema.description, 2)
members.push(doc.length > 0
? `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}:`
: `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`)
members.push(...doc)
statements += 1
} else {
// Not reachable as ``tools.name`` — the model reaches it via
// ``tools[name]``. Exotic names and hard keywords are not legal
// attributes at all; an underscore-leading name (``_foo``) IS a legal
// attribute and is routed here anyway, because the forms that break
// split three ways — a non-dunder ``__token`` name-mangles at the CALL
// site, a dunder that exists on ``object``/``type`` (``__class__``,
// ``__doc__``) resolves before ``__getattr__`` ever runs, and implicit
// special-method lookup skips the hook entirely — and one rule over the
// whole family costs nothing while a per-form rule would have to
// enumerate them (see {@link RESERVED}). The stub lists it as a subscript comment
// (referencing the named TypedDicts too) so a reader sees what is
// accessible; runtime resolution goes through the proxy's __getitem__.
members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`)
const description = describe(schema)
if (description !== undefined) members.push(`${pad(1)}# ${description}`)
}
}
// Subscript entries are COMMENTS, not statements: a class body of only
// comments fails to parse, so `pass` is required whenever no method was
// emitted — including the subscript-only tool set.
const bodyLines = statements > 0 ? members : [`${pad(1)}pass`, ...members]
const body = bodyLines.join('\n')
const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol))
const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : ''
const errorDeclaration = 'class ToolCallError(Exception):\n toolName: str'
const declaration = `from typing import ${imports.join(', ')}\n\n${errorDeclaration}\n\n${classBlock}class Tools(Protocol):\n${body}\n\ntools: Tools`
return `${SDK_INSTRUCTIONS}\n\n\`\`\`python\n${declaration}\n\`\`\``
}

View File

@@ -262,7 +262,10 @@ The available tools:`
* Render the full `tools:sdk` prompt section: the fixed usage instructions
* plus one `declare const tools` interface covering every given tool.
* Deterministic — tools are emitted in lexicographic name order, so an
* unchanged tool set produces byte-identical text across assemblies.
* unchanged tool set produces byte-identical text across assemblies. The sort
* is not a total order on byte-equal names, so two schemas sharing a name
* would render in argument order; the caller's visible-capability map is keyed
* by name, so the input never carries a duplicate.
* @param schemas - the tool schemas to declare (the caller excludes
* `run_code` itself).
* @returns the complete section text.

View File

@@ -335,9 +335,89 @@ describe('mode-aware wire contribution', () => {
await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/)
})
it("rejects every assembly when the runtime's language is not typescript", async () => {
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
await expect(systemPrompt.assemble()).rejects.toThrow(/language is "python"/)
it('rejects every assembly when the runtime language has no registered SDK renderer', async () => {
const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/)
})
it('assembles under a python runtime by picking the Python SDK renderer', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')
expect(sdk?.text).toContain('class Tools(Protocol):')
expect(sdk?.text).toContain('async def echo(self, args:')
expect(sdk?.text).toContain('top-level `await`')
})
it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => {
// `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code
// as `code`, so this pins the mode-by-language matrix rather than a
// separate path — including that the `wireSchemas` projection behind
// `assembly.tools` picks the Python flavor under `both` instead of hitting
// the flavor-table guard.
const { ctx, systemPrompt } = await setup({ mode: 'both', runtime: { language: 'python' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('class Tools(Protocol):')
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
expect(runCodeSchema?.description).toContain('Execute a Python program')
// `both` keeps the native tools alongside run_code; `code` does not.
expect(assembly.tools.map(tool => tool.name)).toContain('echo')
})
it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
expect(runCodeSchema?.description).toContain('Execute a TypeScript program')
expect(runCodeSchema?.description).toContain('BODY of an')
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
expect(codeParam.description).toBe('The program: the body of an async TypeScript function.')
})
it('emits a Python-flavored run_code schema under a python runtime (matches the SDK language)', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } })
registerEcho(ctx)
const assembly = await systemPrompt.assemble()
const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME)
expect(runCodeSchema?.description).toContain('Execute a Python program')
expect(runCodeSchema?.description).toContain('`return <value>`')
expect(runCodeSchema?.description).not.toContain('TypeScript')
const codeParam = (runCodeSchema?.parameters as { properties: { code: { description: string } } }).properties.code
expect(codeParam.description).toBe('The program: the body of an async Python function.')
})
it('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => {
// The flavor getter reads the runtime directly (peekRuntime), so it — not
// requireCodeRuntime — owns the flavor-table guard. Keeping
// RUN_CODE_FLAVORS in step with SDK_RENDERERS is the compiler's job (both
// are `satisfies`-checked against CodeSdkLanguage), so what the guard
// covers is a mounted runtime naming a language absent from both tables,
// which throws when the schema is projected. Assembly's
// requireCodeRuntime rejects such a language earlier; this reaches the
// guard on its own.
const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } })
const definition = ctx.tools.get(RUN_CODE_NAME)
// Names the known languages, symmetric with the SDK_RENDERERS guard: this
// is the reachable rejection, so it must be at least as diagnosable.
expect(() => definition?.description)
.toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/)
})
it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => {
// Any reader of the definition without a mounted runtime lands here; the
// shipped one is the tool-catalog generator, which boots the registry under
// `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime
// returns undefined there, so the flavor getter degrades to the TS default
// rather than throwing. None of those readers feeds a model: assembly goes
// through wireSchemas, which requires a runtime first.
const { ctx } = await setup({ mode: 'code', runtime: false })
const definition = ctx.tools.get(RUN_CODE_NAME)
expect(definition?.description).toContain('Execute a TypeScript program')
const params = definition?.parameters as { properties: { code: { description: string } } }
expect(params.properties.code.description).toBe('The program: the body of an async TypeScript function.')
})
it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 0cd265cadb2b2a619613761062ab2cef209bec83
README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f
README.md: b583ecadf23ec4d089bfc9473dc1165c3e70ae9a
README.zh.md: 42d38e913b98b9ed2cf1781fdc6f716a0050c905

View File

@@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy.

View File

@@ -53,7 +53,7 @@ harness LLM大语言模型seam 的 DeepSeek chat-completions 适配器:
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking推理强度组合则保留最后可用事实并记录失败entry 配置本身仍会使插件加载失败。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败并点名每个配置入口同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败并点名每个配置入口同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。

View File

@@ -58,7 +58,10 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
/**
* Trimmed literal API key; whitespace-only is absent. Prefer
* {@link apiKeyEnv} to keep secrets out of configuration files.
*/
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
@@ -153,6 +156,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
* @returns validated connection facts plus the credential reference.
*/
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
const apiKey = config.apiKey?.trim()
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
@@ -175,7 +179,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
)
}
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
...apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
defaults: {

View File

@@ -699,6 +699,13 @@ describe('plugin registration and config', () => {
})
})
it('normalizes a literal API key and treats whitespace as absent', () => {
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
expect(whitespace.apiKey).toBeUndefined()
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
})
it('uses the default model catalog when apply is called directly', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)

View File

@@ -176,7 +176,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
toolsConfig: { mode: 'code' },
async mount() {},
note:
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
},
{
pkg: '@deepseek-ai/dsh-plan-mode',