Browse Source

Merge branch 'master' into worktree/web-skill-tool-row

Yichen Jiang 2 months ago
parent
commit
a45beb1292
100 changed files with 4399 additions and 382 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml
  5. 44 0
      .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md
  6. 44 0
      .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md
  7. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml
  8. 3 3
      .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md
  9. 3 3
      .agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml
  11. 107 0
      .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md
  12. 107 0
      .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml
  14. 27 0
      .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md
  15. 27 0
      .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md
  16. 6 0
      .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml
  17. 37 0
      .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md
  18. 37 0
      .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml
  20. 8 8
      .agents/notes/implemented/feature/2026-06-15-code-mode.md
  21. 8 8
      .agents/notes/implemented/feature/2026-06-15-code-mode.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml
  23. 3 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
  24. 3 2
      .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md
  25. 6 0
      .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml
  26. 19 0
      .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
  27. 39 0
      .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md
  28. 6 0
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml
  29. 32 0
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
  30. 32 0
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md
  31. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  32. 0 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  33. 0 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  34. 145 0
      apps/web/tests/chat-scroll-contract.e2e.ts
  35. 86 24
      apps/web/tests/models-settings.e2e.ts
  36. 4 2
      apps/web/tests/snapshots/models-settings/configured.expected.md
  37. 4 4
      apps/web/tests/snapshots/models-settings/delete.expected.md
  38. 1 1
      apps/web/tests/snapshots/models-settings/empty.expected.md
  39. 7 0
      apps/web/tests/snapshots/models-settings/native-delete.expected.md
  40. 2 1
      apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md
  41. 17 6
      docs/config-catalog.md
  42. 8 8
      docs/cordis-catalog/events.md
  43. 3 3
      docs/cordis-catalog/services.md
  44. 2 2
      docs/core-data-structures/code-runtime.i18n.yaml
  45. 18 4
      docs/core-data-structures/code-runtime.md
  46. 18 4
      docs/core-data-structures/code-runtime.zh.md
  47. 8 8
      docs/event-producer-consumer.md
  48. 2 2
      docs/tool-catalog.md
  49. 1 1
      examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
  50. 40 0
      examples/headless-agent/tests/headless.snapshot.ts
  51. 12 0
      examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl
  52. 13 32
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  53. 12 10
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  54. 2 2
      packages/client/ui-models/README.i18n.yaml
  55. 0 1
      packages/client/ui-models/README.md
  56. 0 1
      packages/client/ui-models/README.zh.md
  57. 21 3
      packages/client/ui-models/src/client/CustomProviderCard.tsx
  58. 11 2
      packages/client/ui-models/src/client/ModelListEditor.tsx
  59. 31 0
      packages/client/ui-models/src/client/ModelsSection.module.css
  60. 166 55
      packages/client/ui-models/src/client/ModelsSection.tsx
  61. 51 21
      packages/client/ui-models/src/client/ProviderEditor.tsx
  62. 58 0
      packages/client/ui-models/src/client/apiKey.ts
  63. 28 8
      packages/client/ui-models/src/client/locales.ts
  64. 0 12
      packages/client/ui-models/src/client/store.ts
  65. 3 3
      packages/client/ui-models/tests/apply.spec.ts
  66. 235 35
      packages/client/ui-models/tests/components.spec.tsx
  67. 181 1
      packages/client/ui-models/tests/provider-form.spec.tsx
  68. 2 2
      packages/client/ui-settings-general/README.i18n.yaml
  69. 1 1
      packages/client/ui-settings-general/README.md
  70. 1 1
      packages/client/ui-settings-general/README.zh.md
  71. 2 2
      packages/code-runtime/README.i18n.yaml
  72. 1 1
      packages/code-runtime/README.md
  73. 1 1
      packages/code-runtime/README.zh.md
  74. 25 20
      packages/code-runtime/code-runtime-worker/src/index.ts
  75. 20 2
      packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts
  76. 2 2
      packages/code-runtime/code-runtime/README.i18n.yaml
  77. 3 1
      packages/code-runtime/code-runtime/README.md
  78. 3 1
      packages/code-runtime/code-runtime/README.zh.md
  79. 73 1
      packages/code-runtime/code-runtime/src/index.ts
  80. 17 3
      packages/code-runtime/code-runtime/src/types.ts
  81. 57 0
      packages/code-runtime/code-runtime/tests/reserved.spec.ts
  82. 2 2
      packages/core/tools/README.i18n.yaml
  83. 5 5
      packages/core/tools/README.md
  84. 5 5
      packages/core/tools/README.zh.md
  85. 142 12
      packages/core/tools/src/code-mode.ts
  86. 60 8
      packages/core/tools/src/index.ts
  87. 818 0
      packages/core/tools/src/py-types.ts
  88. 4 1
      packages/core/tools/src/ts-types.ts
  89. 83 3
      packages/core/tools/tests/code-mode.spec.ts
  90. 1163 0
      packages/core/tools/tests/py-types.spec.ts
  91. 2 2
      packages/fs/fs-policy/README.i18n.yaml
  92. 1 1
      packages/fs/fs-policy/README.md
  93. 1 1
      packages/fs/fs-policy/README.zh.md
  94. 2 2
      packages/fs/tool-fs/README.i18n.yaml
  95. 1 1
      packages/fs/tool-fs/README.md
  96. 1 1
      packages/fs/tool-fs/README.zh.md
  97. 10 4
      packages/fs/tool-fs/src/edit.ts
  98. 34 0
      packages/fs/tool-fs/src/error.ts
  99. 4 2
      packages/fs/tool-fs/src/write.ts
  100. 35 0
      packages/fs/tool-fs/tests/error.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-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: 0b18cee414df23a2ed8a8b43b76dc06403804691
+2026-07-30-web-config-plane.zh.md: e70c2a47970f943e49393b099c4fcea58dc0fbdc

File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md


File diff suppressed because it is too large
+ 1 - 1
.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md


+ 6 - 0
.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml

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

+ 44 - 0
.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md

@@ -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.

+ 44 - 0
.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md

@@ -0,0 +1,44 @@
+# Agent Note:code-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 侧证明共享集合被执行。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-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

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.md

@@ -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.

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-07-29-sticky-composer-conversation-scroll.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 会话统计挂在 `'conversation.composer.dock'`(位于 `'conversation.input.dock'` 之上)。InputBar 的 textarea 在宿主内以 `{ passive: false }` 链式处理 `wheel`:在限高 textarea 仍能沿该方向滚动时保留原生手势;仅在自身边缘才 `preventDefault` 并将 `deltaY` 施加到宿主。
 
-Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`ChatView` 记录第一个可见的 `data-chat-anchor-key` 及其相对滚动容器的顶部位置;请求在途期间,每次读者滚动都会重新选择当前可见的稳定锚点;页面到达后则按该行矩形的前后差值补偿。到达底部或追加读者自己的消息会取消分页锚点,因此迟到的页面不能把视图从最新内容拉走。贴底跟随采用存储状态,而不是原始滚动几何状态。passive wheel 监听器以最近一次由主线程交付或由程序写入的 `scrollTop` 作为输入前基线,因为 Chromium 可能先推进合成器几何状态,之后才交付事件;当前使用的非负下限不会将并发的布局钳制计入读者移动。没有对应滚轮/触控板输入位移的滚动,在跟随状态下会重新贴底,在阅读状态下则只刷新语义位置。`ChatView` 的单个 `ResizeObserver` 只会在贴底所有权仍保持时跟随流式输出、工具展开与草稿尺寸变化,且每个 chunk 不会触发第二次滚动写入。
+Chat 历史前插通过稳定的已渲染 node/call 身份跟随读者意图,而不是使用整个滚动容器的高度差。分页开始时,`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 历史前插通过稳定的已渲染 node/call 身份跟随读者意图
 
 **把 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 草稿。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml

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

+ 107 - 0
.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md

@@ -0,0 +1,107 @@
+# Agent Note: Validate API key format before it reaches an HTTP header
+
+Status: implemented
+
+English | [中文](2026-08-06-api-key-format-validation.zh.md)
+
+## Problem
+
+An API key holding characters no HTTP header value can carry was accepted by every configuration surface and failed only when a request was built, far from the field that caused it.
+
+Pasting a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page reported a successful save. The first turn then failed with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produced this because `fetch` builds the `Bearer` header inside the `try` in [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault was also retried three times.
+
+`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach <url>`, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored.
+
+Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
+
+Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210.
+
+## Decision
+
+One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded.
+
+This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes.
+
+A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do.
+
+### Invariants belong at every layer; heuristics belong where the human is
+
+The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement.
+
+The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through.
+
+### Absence is a configuration state, not a missing key
+
+"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller.
+
+**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated.
+
+**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key.
+
+**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer.
+
+`normalizeApiKey` therefore takes `string`, never `string | undefined`.
+
+### Where the rule lives
+
+`normalizeApiKey` is a module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`).
+
+Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the seam's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it.
+
+The client cannot import any of this: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate in its own `apiKey.ts` and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema. Each side names the other in a comment.
+
+### What each surface does
+
+| Surface | Behavior |
+|---|---|
+| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. |
+| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. |
+| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. |
+| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. |
+| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. |
+| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. |
+| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. |
+
+`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route.
+
+`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stands as it was.
+
+## Alternatives considered
+
+**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express.
+
+**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here.
+
+**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for.
+
+**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose request header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe.
+
+**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It lost because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it.
+
+**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open.
+
+**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one.
+
+## Consequences
+
+A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint.
+
+The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported.
+
+Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened.
+
+The charset predicate exists twice, once per source plane. The layout forbids sharing it; each side carries its own test and names its twin.
+
+Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value fails at resolution rather than at request time. The diagnosis improves, but the failure moves earlier for anyone currently holding one.
+
+The costliest way to get this wrong would have been to treat absence as invalidity: a rule applied to `undefined` breaks every route authenticating through ambient discovery or OAuth, and a blank field that blocked submit makes editing any other setting demand re-entering the key. Both are pinned by tests rather than left to care.
+
+## Testing
+
+`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key.
+
+`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header.
+
+`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all.
+
+The user-visible terminal state is pinned where it is actually assembled: `examples/headless-agent/tests/headless.snapshot.ts` runs the one-shot app against a stored key no header can carry, over the same keyless composition its missing-credential sibling uses, and records that the turn ends on `INVALID_CREDENTIAL` with an actionable message carrying neither the key nor the word `ByteString`. A package test could not have shown that, and the web e2e covers only the browser half.

+ 107 - 0
.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md

@@ -0,0 +1,107 @@
+# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式
+
+Status: implemented
+
+[English](2026-08-06-api-key-format-validation.md) | 中文
+
+## Problem
+
+一个含有 HTTP header value 无法承载的字符的 API Key,曾被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。
+
+把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。
+
+同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。
+
+空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。
+
+来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。
+
+## Decision
+
+一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。
+
+这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。
+
+第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。
+
+### 不变量属于每一层,启发式属于人所在的那一层
+
+字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。
+
+形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。
+
+### 「没有 Key」是一种配置状态,不是缺失
+
+在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。
+
+**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。
+
+**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。
+
+**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
+
+因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。
+
+### 规则住在哪里
+
+`normalizeApiKey` 是 `dsh-llm` seam 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。
+
+两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 seam 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。
+
+客户端无法引入其中任何一个:client 包只 reference client 包,因此 `packages/client/ui-models` 在自己的 `apiKey.ts` 中镜像这个断言并持有本地化文案,正如 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。
+
+### 各个界面各做什么
+
+| 界面 | 行为 |
+|---|---|
+| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 |
+| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 |
+| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 |
+| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 |
+| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 |
+| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 |
+| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 |
+
+`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。
+
+`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。
+
+## Alternatives considered
+
+**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。
+
+**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。
+
+**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
+
+**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。
+
+**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。
+
+**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。
+
+**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
+
+## Consequences
+
+格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。
+
+形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。
+
+限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。
+
+字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。
+
+早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。诊断变好了,但对当前正持有这类值的人而言,失败点提前了。
+
+把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。
+
+## Testing
+
+`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。
+
+`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。
+
+`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。
+
+用户可见的终态则钉在它真正被组装的位置:`examples/headless-agent/tests/headless.snapshot.ts` 让 one-shot 应用在一个 HTTP 标头无法承载的已存密钥下运行,复用其 missing-credential 兄弟场景的同一套无密钥 composition,并记录该轮以 `INVALID_CREDENTIAL` 结束、消息可操作且既不含密钥也不含 `ByteString` 字样。包级测试无法证明这一点,而 web e2e 只覆盖了浏览器那一半。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml

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

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md

@@ -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).

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md

@@ -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 应用语义。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml

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

+ 37 - 0
.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md

@@ -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.

+ 37 - 0
.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md

@@ -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 版本中开始可用,可以在不改变所断言契约的前提下,把快滑模拟替换为真实的触控划动。

+ 2 - 2
.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-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

+ 8 - 8
.agents/notes/implemented/feature/2026-06-15-code-mode.md

@@ -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.

+ 8 - 8
.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md

@@ -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 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 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` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md
-2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c
-2026-07-28-tool-call-file-open-in-os.zh.md: 725db61869383711042d85cc1508b00eb1b196b6
+2026-07-28-tool-call-file-open-in-os.md: 73f5091888ab2506eab50b827e74c5120394b127
+2026-07-28-tool-call-file-open-in-os.zh.md: c1bc93c472aae8be8cee3bab6cd02f556abca494

+ 3 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md

@@ -12,12 +12,13 @@ Chat tool rows treated the whole summary line as a click target that opened the
 
 File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them.
 
-`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links.
+`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links.
 
 ## Alternatives considered
 
 - Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link.
 - Open files inside an in-app preview — rejected; the ask is the OS default application.
+- Treat WSL as desktop Linux — rejected; a WSL process reports `linux`, but a Linux desktop association is optional while its ordinary operator desktop and browser live on Windows.
 - Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline.
 
 ## Consequences
@@ -26,5 +27,5 @@ Clicking a file path in a tool row opens that path on the host. Non-file tool ro
 
 ## Risks
 
-- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error.
+- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error.
 - Relative paths without a session cwd are forwarded verbatim and may fail on the host.

+ 3 - 2
.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md

@@ -12,12 +12,13 @@ Status: implemented
 
 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。
 
-`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
+`host.openPath` 是特权一元 RPC,仅接受来自回环地址且同源的浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。
 
 ## 考虑过的替代方案
 
 - 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。
 - 在应用内预览文件 — 否决;要求是操作系统默认应用。
+- 将 WSL 当作桌面 Linux — 否决;WSL 进程报告 `linux`,但 Linux 桌面文件关联并非必有,而其常规用户桌面和浏览器位于 Windows 上。
 - 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。
 
 ## 后果
@@ -26,5 +27,5 @@ Status: implemented
 
 ## 风险
 
-- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回内部错误。
+- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。
 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。

+ 6 - 0
.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-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

File diff suppressed because it is too large
+ 19 - 0
.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md


File diff suppressed because it is too large
+ 39 - 0
.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md


+ 6 - 0
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
+2026-08-03-fs-tool-error-remedy.md: f227c31365725652b130e097d70c79d3daab3684
+2026-08-03-fs-tool-error-remedy.zh.md: 11acd0cf48924833ced91591d5ea1424735969cd

+ 32 - 0
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md

@@ -0,0 +1,32 @@
+# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary
+
+Status: implemented
+
+English | [中文](2026-08-03-fs-tool-error-remedy.zh.md)
+
+## Problem
+
+Guarded `write` and `edit` failures reach the model with messages that state the condition but not the only correct recovery: `FS_STALE_VERSION` ("file changed since it was read") and `FS_NOT_OBSERVED` ("edit requires reading … first"). The model must guess that the recovery is a re-read (or a first read) followed by a retry, and the retry/permission/UI layers that route on the structured code see the same message text. The provider-owned messages are part of the storage seam's machine-oriented vocabulary ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)), so the remedy cannot live there without leaking model-facing wording into every consumer of `FsError`.
+
+## Decision
+
+`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched:
+
+- `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`.
+- `FS_NOT_OBSERVED` gains `— read the file, then retry`.
+
+The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged.
+
+In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording.
+
+## Alternatives considered
+
+- **Append the remedy to the provider messages in `dsh-fs` / `dsh-fs-local`.** Rejected because those messages are machine-oriented seam vocabulary consumed by retry, permission, and UI layers as well as the model surface; model-facing wording belongs at the model boundary, where `dsh-tool-fs` already owns result formatting ([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)).
+- **Add the recovery to prompt guidance instead.** Rejected because the failure arrives mid-task; a static instruction does not reliably reach the retry decision, while the error message is present exactly when the model must act.
+- **Signal the remedy with a new `FsError` code.** Rejected because the two failures are the same conditions retry layers already handle; splitting the code would fork routing on identical semantics.
+
+## Consequences
+
+Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes.
+
+The remedy is not a promise: a deleted observed target cannot be unblocked, because re-reading a missing file fails with `FS_NOT_FOUND` and records no observation. That dead end is pinned fail-closed in the integration tests — the retried mutation fails identically until the target exists again and is freshly observed.

+ 32 - 0
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md

@@ -0,0 +1,32 @@
+# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary
+
+Status: implemented
+
+[English](2026-08-03-fs-tool-error-remedy.md) | 中文
+
+## Problem
+
+受防护的 `write` 与 `edit` 失败以只陈述条件、不给出唯一正确恢复方式的消息到达模型:`FS_STALE_VERSION`("file changed since it was read")与 `FS_NOT_OBSERVED`("edit requires reading … first")。模型必须自行猜测恢复方式是重新读取(或首次读取)后重试,而基于结构化错误码路由的重试/权限/UI 层看到的也是同一段消息文本。提供方拥有的消息属于存储接缝的面向机器词汇([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md)),因此恢复指令不能放在那里,否则会把面向模型的措辞泄漏给 `FsError` 的每个消费者。
+
+## Decision
+
+`dsh-tool-fs` 拥有一个面向模型的错误包装 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传:
+
+- `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。
+- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。
+
+结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。
+
+在 `edit.ts` 中,`fs/edit-intent` waterfall 现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent 槽抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。
+
+## Alternatives considered
+
+- **在 `dsh-fs` / `dsh-fs-local` 的提供方消息中追加恢复指令。** 被拒绝:这些消息是面向机器的接缝词汇,除模型表面外还被重试、权限与 UI 层消费;面向模型的措辞应位于模型边界,即 `dsh-tool-fs` 已经拥有结果格式化之处([filesystem capability seam](../architecture/2026-06-17-filesystem-capability-seam.md))。
+- **改为在提示词引导中加入恢复方式。** 被拒绝:失败发生在任务中途;静态指令无法可靠地影响重试决策,而错误消息恰好在模型必须行动时出现。
+- **用新的 `FsError` 错误码表达恢复指令。** 被拒绝:这两种失败本就是重试层已处理的相同条件;拆分错误码会让语义相同的路由分叉。
+
+## Consequences
+
+两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装器(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
+
+恢复指令不是承诺:已删除的观察目标无法被解除阻塞,因为重新读取缺失文件会以 `FS_NOT_FOUND` 失败且不记录观察。这一死胡同在集成测试中以 fail-closed 方式固定——在目标重新存在并被新鲜观察之前,重试的变更以相同方式失败。

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-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

File diff suppressed because it is too large
+ 0 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md


File diff suppressed because it is too large
+ 0 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md


+ 145 - 0
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -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)
 })

+ 86 - 24
apps/web/tests/models-settings.e2e.ts

@@ -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'
@@ -25,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
 const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
 const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.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()
 
@@ -70,34 +73,90 @@ 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('refuses a key no HTTP header can carry before anything is written', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    const key = dialog.getByLabel('API 密钥')
+    const save = dialog.getByRole('button', { name: '保存', exact: true })
+
+    // The paste that used to save cleanly and then fail the first turn with a
+    // ByteString TypeError now names the field that holds it.
+    await key.fill('sk-\u{1F600}minimax')
+    await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
+    await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
+
+    // Clearing it restores submit: an empty field means "keep what is stored",
+    // never a refusal, or editing any other setting would demand the key.
+    await key.fill('')
+    await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
+    expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
+  }, 60_000)
+
+  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 })
@@ -106,6 +165,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')
@@ -114,32 +174,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: '设置' })
-    await settingsDialog.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 settingsDialog.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')
@@ -147,6 +207,8 @@ 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', 'delete.expected.md', 'empty.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
+    ])
   })
 })

+ 4 - 2
apps/web/tests/snapshots/models-settings/configured.expected.md

@@ -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: 添加提供方

+ 4 - 4
apps/web/tests/snapshots/models-settings/delete.expected.md

@@ -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"

+ 1 - 1
apps/web/tests/snapshots/models-settings/empty.expected.md

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

+ 7 - 0
apps/web/tests/snapshots/models-settings/native-delete.expected.md

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

+ 2 - 1
apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md

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

+ 17 - 6
docs/config-catalog.md

@@ -668,7 +668,13 @@ 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, so it resolves through
+   * {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
+   * secrets out of configuration files. {@link resolveAdapterOptions} also
+   * format-checks what remains: a value no HTTP header can carry fails there
+   * rather than inside `fetch`.
+   */
   apiKey?: string
   /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
   apiKeyEnv?: string
@@ -726,7 +732,11 @@ export interface Config {
 
 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
 export interface PiAiProviderProfile {
-  /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
+  /**
+   * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
+   * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
+   * value no HTTP header can carry fails there rather than inside `fetch`.
+   */
   apiKey?: string
   /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
   apiKeyEnv?: string
@@ -798,7 +808,7 @@ export interface PiAiModelProfile {
 
 Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
 
-Source: [`packages/llm/llm-pi-ai/src/config.ts:122`](../packages/llm/llm-pi-ai/src/config.ts)
+Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts)
 
 ## `@deepseek-ai/dsh-llm-replay`
 
@@ -2218,8 +2228,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
   /**
@@ -2236,7 +2247,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`
 

+ 8 - 8
docs/cordis-catalog/events.md

@@ -493,7 +493,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
 'llm/adapters-updated'(): void
 ```
 
-Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts)
 
 ### `llm/stream` — waterfall
 
@@ -517,7 +517,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
 
 Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts)
 
 ## `session/*`
 
@@ -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/*`
 

+ 3 - 3
docs/cordis-catalog/services.md

@@ -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`
 
@@ -959,7 +959,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 
 Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:255`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts)
 
 ## `ctx.permission` — `PermissionService`
 
@@ -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`
 

+ 2 - 2
docs/core-data-structures/code-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/core-data-structures/code-runtime.md
-code-runtime.md: 64de3c45d4f1d1d981daa6c6f074abb667e0aa52
-code-runtime.zh.md: daf07aaf613852a6c4a7b1aff152fcc61052fbca
+code-runtime.md: fbce7d812b7609716fb43ae01610253008e0a92c
+code-runtime.zh.md: 700146cfaa9cfab37ec4d85e550020acf1d9f294

+ 18 - 4
docs/core-data-structures/code-runtime.md

@@ -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.

+ 18 - 4
docs/core-data-structures/code-runtime.zh.md

@@ -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 要等到所有进行中的运行均已终止并结算后才完成。

+ 8 - 8
docs/event-producer-consumer.md

@@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
-| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
+| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
@@ -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) |

+ 2 - 2
docs/tool-catalog.md

@@ -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`
 

+ 1 - 1
examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl

@@ -18,7 +18,7 @@
 {"type":"assistant/chunk","seq":80,"time":1785730437884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":81,"time":1785730437884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fc73e1c1-7ff3-4722-9f4a-b245d8fdc040"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80],"surfaceOp":"append"}
 {"type":"tool/call","seq":82,"time":1785730437885,"data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}}
-{"type":"tool/result","seq":83,"time":1785730437894,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first"}],"isError":true}],"role":"user","id":"5d9bc635-9fc4-4810-a49d-a627b23122e4"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[82],"surfaceOp":"append"}
+{"type":"tool/result","seq":83,"time":1785730437894,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"5d9bc635-9fc4-4810-a49d-a627b23122e4"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[82],"surfaceOp":"append"}
 {"type":"step/end","seq":84,"time":1785730437894,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":85,"time":1785730437903,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":86,"time":1783611704931,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 40 - 0
examples/headless-agent/tests/headless.snapshot.ts

@@ -31,6 +31,10 @@ const retryScenarioDir = join(snapshotsDir, 'provider-retry')
 const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url))
 const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
 const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
+// Same keyless composition as the missing-credential scenario: the endpoint is
+// never dialed either way, because a supplied-but-unusable key fails credential
+// resolution exactly where an absent one does.
+const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential')
 const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
 const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
 const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
@@ -254,6 +258,42 @@ describe('headless stream-json snapshots', () => {
     expect(normalized).toContain('as a last resort')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
+  it('logs actionable invalid-credential guidance through the one-shot app', async () => {
+    const streamExpected = join(invalidCredentialScenarioDir, 'stream-json.expected.jsonl')
+    let runCwd = ''
+    const result = await runLoaderSmoke({
+      label: 'invalid-credential headless stream-json snapshot',
+      tempDirPrefix: 'headless-snapshot-invalid-credential-',
+      binScript,
+      configPath: credentialsConfigPath,
+      binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'],
+      tsconfigPath,
+      env: {
+        // A key that exists but no HTTP header can carry — the paste this
+        // change exists for. Before it, `fetch` refused to build the header
+        // and the turn ended on a retried ByteString TypeError.
+        DEEPSEEK_API_KEY: 'sk-\u{1F600}pasted-from-a-chat-window',
+        DEEPSEEK_BASE_URL: '',
+        NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+      },
+      prepare: (cwd) => { runCwd = cwd },
+    })
+
+    expect(result.stderr).toBe('')
+    const normalized = normalizeHeadlessStream(result.stdout, runCwd)
+    if (refreshing) await writeFile(streamExpected, normalized)
+    expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+    // The durable failure names the reference to correct and the writer that
+    // usually owns it, and stays true in a composition that mounts no Models
+    // page at all.
+    expect(normalized).toContain('the API key resolved from DEEPSEEK_API_KEY contains characters')
+    expect(normalized).toContain('the web Models page writes it')
+    // Neither the key nor the transport-level symptom it used to produce may
+    // reach the user: the code point of one character is still the key.
+    expect(normalized).not.toContain('pasted-from-a-chat-window')
+    expect(normalized).not.toContain('ByteString')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('logs the model default and a dynamic next-step reasoning effort', async () => {
     const result = await runLoaderSmoke({
       label: 'reasoning effort headless stream-json snapshot',

+ 12 - 0
examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl

@@ -0,0 +1,12 @@
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}
+{"type":"result","sessionId":"{{sessionId}}","output":""}

+ 13 - 32
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -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)
     }
   }, [])
 

+ 12 - 10
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -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()
   })

+ 2 - 2
packages/client/ui-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
-README.md: b55914197e472edec8a8b6d4d3e02036d1697728
-README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec
+README.md: 80ae642ec9d6f91c78af041dda0b201959309577
+README.zh.md: 4236c8fec4f6d5e51363095d790944af9c08092a

File diff suppressed because it is too large
+ 0 - 1
packages/client/ui-models/README.md


File diff suppressed because it is too large
+ 0 - 1
packages/client/ui-models/README.zh.md


+ 21 - 3
packages/client/ui-models/src/client/CustomProviderCard.tsx

@@ -18,6 +18,7 @@
 import { useState } from 'react'
 import type { ReactNode } from 'react'
 import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
+import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
 import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
@@ -80,12 +81,22 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
   // bad row is named by its position here too. Capacities have route-level
   // fallbacks; what a route cannot default is at least one model.
   const modelFailure = validateDeepSeekModels(models)
+  const keyFailure = apiKeyFailure(keyDraft)
+  // The typed key with paste whitespace removed. A blank field yields an empty
+  // string, which the create path reads as "no key supplied" — a route may
+  // legitimately authenticate through the provider's own ambient discovery.
+  const keyValue = keyDraft.trim()
   const ready = route.length > 0 && !routeInvalid && !routeTaken
     && baseURL.length > 0 && models.length > 0 && modelFailure === undefined
+    && keyFailure === undefined
   // The one blocked gate worth a line under the form. The route id is omitted
   // because its own field already explains itself, and a satisfied card says
   // nothing at all rather than printing an empty paragraph.
   const hint = failure !== undefined || ready
+    // The key field prints its own failure directly beneath itself, so a card
+    // blocked only by the key stays silent here rather than answering with the
+    // next unmet gate — which is satisfied, and reads as a second, false fault.
+    || keyFailure !== undefined
     ? undefined
     : baseURL.length === 0
       ? t('customNeedsBaseUrl')
@@ -112,8 +123,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
       expectedRevision: openedAt,
     })
     if (!response.result.ok) return response.result.error.message
-    if (keyDraft.length > 0) {
-      const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
+    if (keyValue.length > 0) {
+      const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
       // The profile landed; saying the key did not is the only honest report,
       // and the row is now editable so the key can be entered again there.
       if (!stored.result.ok) return stored.result.error.message
@@ -208,6 +219,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           disabled={disabled}
           onChange={(event) => { setKeyDraft(event.target.value) }}
         />
+        {/* A create card has no stored key to keep, so the blank case says
+            what a blank field means here instead: this route may authenticate
+            through the provider's own ambient discovery or OAuth. */}
+        {keyFailure === undefined
+          ? null
+          : <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>}
       </div>
       <ModelListEditor
         models={models}
@@ -216,8 +233,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           settingsNs: NS,
           baseURL,
           api: protocol,
-          ...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
+          ...keyValue.length === 0 ? {} : { apiKey: keyValue },
         }}
+        probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
         api={api}
         t={t}
         disabled={disabled}

+ 11 - 2
packages/client/ui-models/src/client/ModelListEditor.tsx

@@ -74,6 +74,13 @@ export interface ModelListEditorProps {
   onReset?: () => void
   /** Endpoint facts for the fetch action. */
   probe: ProbeTarget
+  /**
+   * Copy key naming why the fetch action is unavailable, or `undefined` when
+   * it is. The card owns this because the key it would send is judged there:
+   * asking with a key the form has already refused spends a round trip to be
+   * told what the field already says.
+   */
+  probeBlocked?: keyof typeof en | undefined
   /** Wire face the fetch action calls. */
   api: Pick<IApiClient, 'llm'>
   /** Section copy. */
@@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
         <button
           type="button"
           className={styles['linkButton']}
-          disabled={disabled || busy || !askable}
-          title={askable ? undefined : t('fetchNeedsBaseUrl')}
+          disabled={disabled || busy || !askable || props.probeBlocked !== undefined}
+          title={props.probeBlocked !== undefined
+            ? t(props.probeBlocked)
+            : askable ? undefined : t('fetchNeedsBaseUrl')}
           onClick={() => { void fetchModels() }}
         >
           {busy ? t('fetching') : t('fetchModels')}

+ 31 - 0
packages/client/ui-models/src/client/ModelsSection.module.css

@@ -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;
@@ -72,6 +86,23 @@
   color: var(--dsw-alias-label-primary);
 }
 
+.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;

+ 166 - 55
packages/client/ui-models/src/client/ModelsSection.tsx

@@ -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,29 +252,54 @@ 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>
+                <span className={styles['rowIdentity']}>
+                  <span className={styles['rowName']}>{row.entry.displayName}</span>
+                  {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.
@@ -225,8 +315,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>
@@ -235,18 +330,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>
           )
@@ -284,7 +375,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
                 api={api}
                 t={t}
                 readOnly={!state.writable}
-                onClose={closeEditor}
+                onClose={(changed) => { closeEditor(changed, addTarget) }}
               />
             </div>
           )
@@ -299,7 +390,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>
             )
@@ -317,6 +411,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))
@@ -330,7 +425,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')}
@@ -341,9 +441,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={(
           <>
@@ -356,11 +463,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>
   )
 }

+ 51 - 21
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -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
@@ -22,6 +24,7 @@ import {
 import {
   DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
 } from './DeepSeekModelsEditor.tsx'
+import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
 import { deriveKeyRef, messageOf } from './store.ts'
@@ -133,10 +136,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)
@@ -163,15 +169,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
 
   const stringAt = (source: unknown, key: string): string | undefined => {
     const value = getPath(source, [key])
-    return typeof value === 'string' && value.length > 0 ? value : undefined
+    return typeof value === 'string' && value.trim().length > 0 ? value : undefined
   }
   const setField = (key: string, next: string | undefined): void => {
-    setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
+    // A value of nothing but whitespace is cleared, not stored: `stringAt`
+    // already reports it as absent, so the field would otherwise render empty
+    // while the draft still carried the spaces into `settings.yaml`, where
+    // both adapters would accept that non-empty string as a real value.
+    const value = next === undefined || next.trim().length === 0 ? undefined : next
+    setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
   }
 
   // The model list is validated by the same per-row checker for both families,
   // so a bad row is named by its position rather than by a blanket message.
   const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
+  const keyFailure = apiKeyFailure(keyDraft)
+  // What a probe or a write must carry: the typed key with paste whitespace
+  // removed. A blank field yields an empty string, which both call sites read
+  // as "no key supplied" rather than as a key — that is how a card whose
+  // provider already has a stored key is edited without re-entering it.
+  const keyValue = keyDraft.trim()
   // What the form currently shows, which is what an interrogation must ask:
   // an edited-but-unsaved endpoint, and a key typed but not yet stored.
   const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
@@ -183,7 +200,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
     provider: props.provider,
     ...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
     ...probeApi === undefined ? {} : { api: probeApi },
-    ...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
+    ...keyValue.length === 0 ? {} : { apiKey: keyValue },
   }
   /**
    * The write for this card, or a failure message. Every edit travels as
@@ -194,11 +211,10 @@ 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.
+    // 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 && keyValue.length > 0
       ? setPath(draft, ['apiKeyEnv'], keyRef)
       : draft
     {
@@ -217,17 +233,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 (keyValue.length > 0) {
+      const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
       if (!stored.result.ok) return stored.result.error.message
     }
     setKeyDraft('')
@@ -286,6 +311,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,
@@ -306,13 +336,12 @@ 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) }}
           />
+          {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
         </div>
         <details className={styles['customized']}>
           <summary className={styles['customizedSummary']}>{t('customized')}</summary>
@@ -363,7 +392,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                   defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
                 />
               )
-              : <ModelListEditor {...catalogProps} probe={probe} api={api} />}
+              : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
           </div>
         </details>
       </>
@@ -396,7 +425,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       <EditorFooter
         t={t}
         busy={busy}
-        submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
+        submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
+          || keyFailure !== undefined}
         submitLabel="apply"
         submitBusyLabel="applying"
         onCancel={() => { props.onClose(false) }}

+ 58 - 0
packages/client/ui-models/src/client/apiKey.ts

@@ -0,0 +1,58 @@
+/**
+ * Browser-side judgement of a typed API key.
+ * @module @deepseek-ai/dsh-client-ui-models/apiKey
+ */
+
+/**
+ * Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space
+ * excluded. Client packages reference only client packages, so the charset
+ * rule is mirrored here rather than imported; keep the two in step, as
+ * `validateDeepSeekModels` is kept in step with the host's `catalogModel`.
+ */
+const LEGAL_API_KEY = /^[\x21-\x7E]+$/
+
+/**
+ * A pasted `NAME=value` environment line. Two narrowings keep real keys clear
+ * of it: the name must be upper-case, so `sk-` forms break at the hyphen, and
+ * the `=` must be followed by something other than another `=`, so base64
+ * padding on an all-upper-case key (`ABCD==`) is not mistaken for an
+ * assignment. This heuristic runs only here — a resolver applying it could
+ * lock a user out of a gateway whose key legitimately takes this shape, with
+ * the environment refusing it too and no way through.
+ */
+const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/
+
+/**
+ * Copy key naming why a typed key cannot be saved. A wrapped paste reports the
+ * same format failure as an illegal character: the reader's next move is the
+ * same either way — look at the key and paste it again — so naming the two
+ * causes apart would spend the field's one line on a distinction that changes
+ * nothing about what to do.
+ */
+export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters'
+
+/** Whether a value is wrapped in one matching pair of quotes. */
+function isQuoted(value: string): boolean {
+  const first = value[0]
+  if (first !== '"' && first !== '\'' && first !== '`') return false
+  return value.length > 1 && value.endsWith(first)
+}
+
+/**
+ * Judge the key input's current value.
+ *
+ * An empty field is not a failure: every card opens with it empty even when a
+ * key is already stored, where it means keep that one. A field holding only
+ * whitespace is a failure rather than an empty field, so typed input is never
+ * silently discarded.
+ * @param draft - the key input's current value, untrimmed.
+ * @returns the copy key for a field-level failure, or `undefined` to allow submit.
+ */
+export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined {
+  if (draft.length === 0) return undefined
+  const value = draft.trim()
+  if (value.length === 0) return 'keyBlank'
+  if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters'
+  if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
+  return undefined
+}

+ 28 - 8
packages/client/ui-models/src/client/locales.ts

@@ -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',
@@ -46,6 +53,9 @@ export const en = {
   addModel: 'Add model',
   removeModel: 'Delete model',
   modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
+  keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.',
+  keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.',
+  keyIllegalCharacters: 'This API key is not in a valid format. Please check it.',
   modelIdRequired: 'Model ID is required.',
   modelIdDuplicate: 'Model ID must be unique.',
   modelNameInvalid: 'Display name cannot be empty.',
@@ -90,23 +100,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: '自定义设置',
@@ -130,6 +147,9 @@ export const zh: typeof en = {
   addModel: '添加模型',
   removeModel: '删除模型',
   modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
+  keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
+  keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
+  keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
   modelIdRequired: '模型 ID 不能为空。',
   modelIdDuplicate: '模型 ID 不能重复。',
   modelNameInvalid: '显示名称不能为空。',

+ 0 - 12
packages/client/ui-models/src/client/store.ts

@@ -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

+ 3 - 3
packages/client/ui-models/tests/apply.spec.ts

@@ -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 () => {

+ 235 - 35
packages/client/ui-models/tests/components.spec.tsx

@@ -5,12 +5,15 @@ 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 {
   DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
 } from '../src/client/DeepSeekModelsEditor.tsx'
+import { apiKeyFailure } from '../src/client/apiKey.ts'
 import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
 import type { ProviderRow } from '../src/client/store.ts'
 import { en } from '../src/client/locales.ts'
@@ -18,6 +21,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 +141,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 +177,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 +196,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 +214,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 +288,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 +309,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 +823,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 +835,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 +976,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 +999,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 +1043,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 +1163,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 () => {
@@ -1080,3 +1229,54 @@ describe('ModelsSection', () => {
     expect(failure).toBe('connection lost')
   })
 })
+
+describe('apiKeyFailure', () => {
+  it('treats a blank field as no failure — it means keep the stored key', () => {
+    expect(apiKeyFailure('')).toBeUndefined()
+  })
+
+  it.each([
+    ['a printable-ASCII key', 'sk-0123456789'],
+    ['a padded key, which the caller trims', '  sk-abc  '],
+    ['the printable-ASCII boundary characters', '!~'],
+    ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'],
+    ['an all-upper-case key ending in base64 padding', 'ABCD=='],
+    ['an all-upper-case key ending in one padding character', 'MNOPQRST='],
+  ])('accepts %s', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBeUndefined()
+  })
+
+  it.each([
+    ['spaces', '   '],
+    ['a tab', '\t'],
+  ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBe('keyBlank')
+  })
+
+  it.each([
+    ['an emoji', 'sk-\u{1F600}'],
+    ['CJK text', 'sk-你好'],
+    ['full-width punctuation', 'sk-abc,'],
+    ['an interior space', 'sk-abc def'],
+    ['a C0 control character', 'sk-abc\x01'],
+    ['a latin-1 character', 'sk-café'],
+  ])('fails %s as illegal characters', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
+  })
+
+  it.each([
+    ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'],
+    ['double quotes', '"sk-abc"'],
+    ['single quotes', '\'sk-abc\''],
+    ['backticks', '`sk-abc`'],
+  ])('fails %s as a format failure', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
+  })
+
+  it('needs a matching closing quote before it calls a value wrapped', () => {
+    // A lone quote and an unbalanced one are legal printable ASCII, so the
+    // heuristic leaves them alone rather than guessing at a paste error.
+    expect(apiKeyFailure('"')).toBeUndefined()
+    expect(apiKeyFailure('"a')).toBeUndefined()
+  })
+})

+ 181 - 1
packages/client/ui-models/tests/provider-form.spec.tsx

@@ -142,7 +142,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. */
@@ -862,4 +862,184 @@ describe('hand-declared providers', () => {
     await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
     expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
   })
+
+  it('refuses an unusable key on the field and blocks creation', () => {
+    const { mutate, set } = mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    // A hand-declared route reaches the same judgement as an edited one, so a
+    // key that no header can carry never becomes a profile plus a bad secret.
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(buttonNamed(en.create).disabled).toBe(true)
+    expect(mutate).not.toHaveBeenCalled()
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('stays silent about the other gates when only the key is refused', () => {
+    mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    // Route, endpoint, and models are all satisfied, so answering with the
+    // next unmet gate would print a second, false fault beside the real one.
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(screen.queryByText(en.customNeedsModels)).toBeNull()
+    expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
+  })
+
+  it('tells a whitespace-only key what a blank field means on a create card', () => {
+    const { mutate } = mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '   ' } })
+
+    // There is no stored key to keep here, so the blank case says the thing
+    // that is true of a route being declared: it may authenticate elsewhere.
+    expect(screen.getByText(en.keyBlankNew)).toBeTruthy()
+    expect(screen.queryByText(en.keyBlank)).toBeNull()
+    expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew)
+    expect(buttonNamed(en.create).disabled).toBe(true)
+    expect(mutate).not.toHaveBeenCalled()
+  })
+
+  it('creates without a key when the route authenticates some other way', async () => {
+    const { set, onClose } = mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.click(screen.getByText(en.create))
+
+    await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
+    expect(set).not.toHaveBeenCalled()
+  })
+})
+
+describe('API key field', () => {
+  it('submits with a blank key field without writing a credential', async () => {
+    const { mutate, set } = await mountSection()
+    openEditor('openai')
+
+    // The field opens empty even for a provider whose key is stored, where it
+    // means "keep that one" — so editing anything else must not require it.
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } })
+    expect(buttonNamed(en.apply).disabled).toBe(false)
+    fireEvent.click(screen.getByText(en.apply))
+
+    await waitFor(() => { expect(mutate).toHaveBeenCalled() })
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('clears a whitespace-only base URL instead of writing the spaces', async () => {
+    const { mutate } = await mountSection()
+    openEditor('openai')
+
+    // The field renders this as empty, so the draft must agree: storing the
+    // spaces would hand both adapters a non-empty string they accept as a URL.
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: '   ' } })
+    fireEvent.click(screen.getByText(en.apply))
+
+    await waitFor(() => { expect(mutate).toHaveBeenCalled() })
+    const ops = firstMutate(mutate).ops
+    expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false)
+    expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true)
+  })
+
+  it('blocks submit and names the field when the key holds only whitespace', async () => {
+    const { mutate, set } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '   ' } })
+
+    expect(screen.getByText(en.keyBlank)).toBeTruthy()
+    expect(buttonNamed(en.apply).disabled).toBe(true)
+    expect(mutate).not.toHaveBeenCalled()
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('blocks submit when the key contains characters no header can carry', async () => {
+    const { set } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(buttonNamed(en.apply).disabled).toBe(true)
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('blocks submit when a whole NAME=value line was pasted', async () => {
+    await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } })
+
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(buttonNamed(en.apply).disabled).toBe(true)
+  })
+
+  it('trims a padded key before storing it', async () => {
+    const { set } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '  sk-abc  ' } })
+    expect(buttonNamed(en.apply).disabled).toBe(false)
+    fireEvent.click(screen.getByText(en.apply))
+
+    await waitFor(() => { expect(set).toHaveBeenCalled() })
+    expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc')
+  })
+
+  it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => {
+    const { discover } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    // The host would refuse this before building the header anyway; asking is
+    // a round trip to be told what the field already says.
+    expect(buttonNamed(en.fetchModels).disabled).toBe(true)
+    expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters)
+    expect(discover).not.toHaveBeenCalled()
+  })
+
+  it('carries the trimmed key into an interrogation, not the padded draft', async () => {
+    const { discover } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '  sk-abc  ' } })
+    fireEvent.click(screen.getByRole('button', { name: en.fetchModels }))
+
+    await waitFor(() => { expect(discover).toHaveBeenCalled() })
+    expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' })
+  })
+
+  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()
+  })
 })

+ 2 - 2
packages/client/ui-settings-general/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
-README.md: 29e48d193d24644f37d219b4df44a8fedf062e53
-README.zh.md: 17ebc9e8ab273aae0e7ea4c764da569da6d9f49f
+README.md: ab27e073dc76335efc619f56365d1705007f7ef2
+README.zh.md: 18bbecf67f51ae63bfacd4ba78437bea95b50bee

+ 1 - 1
packages/client/ui-settings-general/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
 
-A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
+A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
 
 `src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
 

+ 1 - 1
packages/client/ui-settings-general/README.zh.md

@@ -4,7 +4,7 @@
 
 设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
 
-回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
+回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权 settings 读取。
 
 `src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在 user-settings seam 中注册 `ui-onboarding`。loopback 浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非 loopback 浏览器不能访问受保护的 settings API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
 

+ 2 - 2
packages/code-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/code-runtime/README.md
-README.md: 2d32a05071efdfa05c336211196bb769ed5a5fc7
-README.zh.md: 62c9a395ac3cf2b5cd55455ab5a273a1e276f6b7
+README.md: f20a287419b94b1a9dc1d8da7303fc4d3032cfd3
+README.zh.md: f5cd4c9949f2bd7a7d6d7cd078144910712a3819

+ 1 - 1
packages/code-runtime/README.md

@@ -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 |
 |---|---|---|

+ 1 - 1
packages/code-runtime/README.zh.md

@@ -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 |
 |---|---|---|

+ 25 - 20
packages/code-runtime/code-runtime-worker/src/index.ts

@@ -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)

+ 20 - 2
packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts

@@ -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 () => {

+ 2 - 2
packages/code-runtime/code-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime/README.md
-README.md: c7a2d519e47d160f5ab123bfc887e7e9f24ec602
-README.zh.md: 22d0b120d7cea50b578a184b3e40d77707ebc489
+README.md: bb1c20d00a260f643f601c42c6e48722437d5aab
+README.zh.md: 15fbcecf77b2318acf3b09101802cd032ae426d2

+ 3 - 1
packages/code-runtime/code-runtime/README.md

@@ -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.

+ 3 - 1
packages/code-runtime/code-runtime/README.zh.md

@@ -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 返回。

+ 73 - 1
packages/code-runtime/code-runtime/src/index.ts

@@ -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
 

+ 17 - 3
packages/code-runtime/code-runtime/src/types.ts

@@ -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>

+ 57 - 0
packages/code-runtime/code-runtime/tests/reserved.spec.ts

@@ -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)
+  })
+})

+ 2 - 2
packages/core/tools/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/tools/README.md
-README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f
-README.zh.md: 691d2f2fcccdaa1bcab5343b2fce661d9c99e8ad
+README.md: 81cc57983d83fd19468017b217d4db9978f4e228
+README.zh.md: 9f875bd80a03d1d0f78625ee98eeaad9d118f871

+ 5 - 5
packages/core/tools/README.md

@@ -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).

+ 5 - 5
packages/core/tools/README.zh.md

@@ -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)。

+ 142 - 12
packages/core/tools/src/code-mode.ts

@@ -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 100–199 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
 }

+ 60 - 8
packages/core/tools/src/index.ts

@@ -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
   }

+ 818 - 0
packages/core/tools/src/py-types.ts

@@ -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 D800–DFFF 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\`\`\``
+}

+ 4 - 1
packages/core/tools/src/ts-types.ts

@@ -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.

+ 83 - 3
packages/core/tools/tests/code-mode.spec.ts

@@ -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 () => {

+ 1163 - 0
packages/core/tools/tests/py-types.spec.ts

@@ -0,0 +1,1163 @@
+import { describe, expect, it } from 'vitest'
+import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools/src/py-types.ts'
+import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
+import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
+
+describe('jsonSchemaToPy', () => {
+  it('maps the defineTool DSL subset', () => {
+    const cases: [unknown, string][] = [
+      [{ type: 'string' }, 'str'],
+      [{ type: 'number' }, 'float'],
+      [{ type: 'boolean' }, 'bool'],
+      [{ type: 'string', enum: ['a', 'b'] }, 'Literal["a", "b"]'],
+      [{ type: 'array', items: { type: 'number' } }, 'list[float]'],
+      [{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, 'list[Literal["x", "y"]]'],
+      [{ type: 'array' }, 'list[Any]'],
+      [{ type: 'object' }, 'dict[str, Any]'],
+      [{ type: 'object', properties: {} }, 'dict[str, Any]'],
+      [{ type: 'object', properties: { x: { type: 'string' } } }, 'dict[str, Any]'],
+    ]
+    for (const [schema, expected] of cases) {
+      expect(jsonSchemaToPy(schema), JSON.stringify(schema)).toBe(expected)
+    }
+  })
+
+  it('is total: unsupported or hostile constructs degrade to Any, never throw', () => {
+    const cases: unknown[] = [
+      undefined,
+      null,
+      42,
+      'string-schema',
+      {},
+      { oneOf: 7 },
+      { $ref: '#/defs/x' },
+      { type: 'object', properties: 7 },
+      { type: 'string', enum: [1, 2] },
+      { type: 'string', enum: [] },
+    ]
+    for (const schema of cases) {
+      expect(() => jsonSchemaToPy(schema), JSON.stringify(schema)).not.toThrow()
+    }
+    expect(jsonSchemaToPy({ type: 'integer' })).toBe('int')
+    expect(jsonSchemaToPy({ type: 'string', const: 'fixed' })).toBe('Literal["fixed"]')
+    expect(jsonSchemaToPy({ type: 'boolean', const: true })).toBe('Literal[True]')
+    expect(jsonSchemaToPy({ type: 'number', const: 1.5 })).toBe('Literal[1.5]')
+    expect(jsonSchemaToPy({ type: 'boolean', enum: [false] })).toBe('Literal[False]')
+    expect(jsonSchemaToPy({ type: 'null' })).toBe('None')
+    expect(jsonSchemaToPy({ oneOf: [{ type: 'string' }, { type: 'null' }] })).toBe('str | None')
+    expect(jsonSchemaToPy({ oneOf: [] })).toBe('Any')
+    expect(jsonSchemaToPy({ type: 'object', properties: 7 })).toBe('Any')
+    expect(jsonSchemaToPy({ type: 'string', enum: [1, 2] })).toBe('Any')
+    expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
+  })
+
+  it('leans on JSON.stringify to keep a Literal parseable', () => {
+    // Nothing here escapes anything itself; `JSON.stringify` carries both
+    // classes of hazard. The two kinds of code point CPython refuses anywhere
+    // in source: NUL, and the D800–DFFF unpaired-surrogate block under ES2019
+    // well-formed stringification.
+    expect(jsonSchemaToPy({ type: 'string', const: 'a\u0000b' })).toBe(String.raw`Literal["a\u0000b"]`)
+    expect(jsonSchemaToPy({ type: 'string', enum: ['a\ud800b'] })).toBe(String.raw`Literal["a\ud800b"]`)
+    // And the ones that break this line in particular: a bare quote closing
+    // the literal early, a trailing ODD backslash eating the closing quote (an
+    // even run does not), a bare newline ending it before its terminator.
+    // Every escape it emits is also a Python escape for the same character, so
+    // the value round-trips.
+    expect(jsonSchemaToPy({ type: 'string', const: 'say "hi"\n' })).toBe(String.raw`Literal["say \"hi\"\n"]`)
+    expect(jsonSchemaToPy({ type: 'string', const: 'ends\\' })).toBe(String.raw`Literal["ends\\"]`)
+  })
+
+  it('passes NEL and the line/paragraph separators through raw, which CPython does not treat as line terminators', () => {
+    // `JSON.stringify` escapes LF and CR but not NEL (U+0085), LS (U+2028), or
+    // PS (U+2029), which is safe here and not by accident: those three are
+    // `str.splitlines()` boundaries, not tokenizer line terminators, so they
+    // end neither a string literal nor a `#` comment — measured on CPython
+    // 3.9.6 and 3.12.13. Pinning the raw form keeps a later "escape them for
+    // symmetry with LF" change from landing as a silent both-flavors
+    // divergence from `ts-types`. Escapes below — the two forms denote the
+    // same bytes, and none of the three has a visible width.
+    expect(jsonSchemaToPy({ type: 'string', const: 'a\u2028b' })).toBe('Literal["a\u2028b"]')
+    expect(jsonSchemaToPy({ type: 'string', enum: ['a\u2029b'] })).toBe('Literal["a\u2029b"]')
+    // NEL is inside `UNPRINTABLE`'s class, so the description path escapes it.
+    // This is one of the two routes that carry it raw; the other is the
+    // subscript tool-name comment's own `JSON.stringify` call.
+    expect(jsonSchemaToPy({ type: 'string', const: 'a\u0085b' })).toBe('Literal["a\u0085b"]')
+  })
+
+  it('emits exact digits for a beyond-safe-range integer literal', () => {
+    // Python integers are arbitrary-precision, so the emitted digits ARE the
+    // value the model programs against. `String(2 ** 60)` prints the rounded
+    // ...847000, which is a DIFFERENT integer from the double's exact
+    // ...846976: `Number::toString` is shortest round-trip, so it emits the 16
+    // digits that re-read to the same double and pads with zeros, and those
+    // padded digits name an integer no double holds. Passing one back would
+    // have to cross the argument boundary as a JSON number, so the SDK would
+    // document a value no program can pass. This assertion is what separates
+    // the two spellings; the 1e21 case below separates them again on the other
+    // failure mode, where `String` gives no integer literal at all.
+    expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 60 })).toBe('Literal[1152921504606846976]')
+    expect(jsonSchemaToPy({ type: 'integer', enum: [2 ** 60, -(2 ** 60)] }))
+      .toBe('Literal[1152921504606846976, -1152921504606846976]')
+    // `String(1e21)` prints `1e+21`, not a Python integer literal at all. The
+    // rule keys off the VALUE, not the declared type, so a `number` const that
+    // happens to be an integral double is spelled the same exact way (both
+    // spellings denote the same double, and only the digits also denote the
+    // same Python integer).
+    expect(jsonSchemaToPy({ type: 'integer', const: 1e21 })).toBe('Literal[1000000000000000000000]')
+    expect(jsonSchemaToPy({ type: 'number', const: 1e21 })).toBe('Literal[1000000000000000000000]')
+    // Within the safe range, and for non-integral numbers, the plain spelling
+    // is already exact and stays unchanged.
+    expect(jsonSchemaToPy({ type: 'integer', const: 2 ** 53 - 1 })).toBe('Literal[9007199254740991]')
+    expect(jsonSchemaToPy({ type: 'number', const: 1e-7 })).toBe('Literal[1e-7]')
+  })
+})
+
+describe('renderToolsSdkPy', () => {
+  const bash: ToolSdkSchema = {
+    name: 'bash',
+    description: 'Run a shell command.',
+    parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
+    output: { type: 'string' },
+  }
+  const exotic: ToolSdkSchema = {
+    name: 'my-mcp.tool',
+    description: 'Exotic name.',
+    parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+    output: { type: 'string' },
+  }
+  const reserved: ToolSdkSchema = {
+    name: 'class',
+    description: 'Uses a reserved Python word.',
+    parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+    output: { type: 'string' },
+  }
+  /** One tool carrying `description` at both emission sites: the method docstring and the field comment. */
+  const described = (description: string): ToolSdkSchema => ({
+    name: 'weird',
+    description,
+    parameters: parameterSchemaSpecToJsonSchema({
+      field: { type: 'string', required: true, description },
+    }) as unknown as Record<string, unknown>,
+    output: { type: 'string' },
+  })
+
+  it('declares identifier tools as async methods and lists exotic/reserved names as subscript comments', () => {
+    const text = renderToolsSdkPy([exotic, bash, reserved])
+    expect(text).toContain('class Tools(Protocol):')
+    // The argument object is a named TypedDict, not an opaque dict.
+    expect(text).toContain('class BashArgs(TypedDict):')
+    expect(text).toContain('async def bash(self, args: BashArgs) -> str:')
+    // Empty-property tools keep the opaque dict (nothing to name).
+    expect(text).toContain('# tools["my-mcp.tool"](args: dict[str, Any]) -> str')
+    expect(text).toContain('# tools["class"](args: dict[str, Any]) -> str')
+    // Fixed instruction lines the model relies on.
+    expect(text).toContain('top-level `await`')
+    // The binding boundary: `tools`/`ToolCallError` are bound, the TypedDicts
+    // are not. Both halves are pinned — dropping either one turns a correct
+    // contract into a wrong one (a model that reads only "STATIC STUB" would
+    // stop catching `ToolCallError`).
+    expect(text).toContain('exactly two of the names declared below are bound: `tools` and `ToolCallError`')
+    expect(text).toContain('never `FooArgs(field=1)`, which raises `NameError`')
+    expect(text).toContain('ToolCallError')
+    expect(text).toContain('class ToolCallError(Exception):')
+    expect(text).toContain('MAY overlap under `asyncio.gather`')
+    expect(text).toContain('lossless JSON')
+    expect(text).toContain('```python')
+    expect(text).toContain('tools: Tools')
+  })
+
+  it('renders required as plain fields and optional as NotRequired, with per-field description comments', () => {
+    const tool: ToolSdkSchema = {
+      name: 'search',
+      description: 'Search for text.',
+      parameters: parameterSchemaSpecToJsonSchema({
+        query: { type: 'string', required: true, description: 'What to search for.' },
+        limit: { type: 'number', description: 'Max results.' },
+      }) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('class SearchArgs(TypedDict):')
+    expect(text).toContain('    # What to search for.')
+    expect(text).toContain('    query: str')
+    expect(text).toContain('    # Max results.')
+    expect(text).toContain('    limit: NotRequired[float]')
+    expect(text).toContain('async def search(self, args: SearchArgs) -> str:')
+    // NotRequired is imported because an optional field used it; Any is NOT,
+    // since every type here is concrete — the import line lists only what ran.
+    expect(text).toContain('from typing import NotRequired, Protocol, TypedDict')
+  })
+
+  it('prefixes Tool when a name CamelCases to a non-letter head, and degrades a malformed schema to Any', () => {
+    const tool: ToolSdkSchema = {
+      name: '1st-tool', // subscript path; CamelCases to "1stTool" → prefixed "Tool1stTool"
+      description: 'Hostile-shape probe.',
+      // Malformed node: the unified schema validator rejects it whole, so the
+      // args position degrades to Any (registration would refuse this schema;
+      // the renderer just must not throw on it).
+      parameters: { type: 'object', properties: { field: { type: 'string', description: 42 } } },
+      output: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('# tools["1st-tool"](args: Any) -> Tool1stToolOutput')
+    expect(text).toContain('class Tool1stToolOutput(TypedDict):')
+    expect(text).toContain('    ok: bool')
+  })
+
+  it('treats every field as optional when the object carries no required array', () => {
+    const tool: ToolSdkSchema = {
+      name: 'all_optional',
+      description: 'No required array.',
+      parameters: { type: 'object', properties: { flag: { type: 'boolean' } } },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('    flag: NotRequired[bool]')
+  })
+
+  it('renders an enum inside an object property as a Literal field', () => {
+    const tool: ToolSdkSchema = {
+      name: 'mode_tool',
+      description: 'Pick a mode.',
+      parameters: parameterSchemaSpecToJsonSchema({
+        mode: { type: 'string', required: true, enum: ['fast', 'slow'] },
+      }) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('class ModeToolArgs(TypedDict):')
+    expect(text).toContain('    mode: Literal["fast", "slow"]')
+    expect(text).toContain('from typing import Literal, Protocol, TypedDict')
+  })
+
+  it('renders one level of nested object as its own named TypedDict declared before the parent', () => {
+    const tool: ToolSdkSchema = {
+      name: 'workflow',
+      description: 'Run a workflow.',
+      parameters: parameterSchemaSpecToJsonSchema({
+        meta: {
+          type: 'object',
+          required: true,
+          additionalProperties: false,
+          description: 'Identity block.',
+          properties: {
+            name: { type: 'string', required: true, description: 'Short name.' },
+            phases: {
+              type: 'array',
+              items: {
+                type: 'object',
+                additionalProperties: false,
+                properties: { title: { type: 'string', required: true, description: 'Phase title.' } },
+              },
+            },
+          },
+        },
+      }) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    // Nested class for the `meta` object, and a further nested class for the
+    // array item object, each named after its field path.
+    expect(text).toContain('class WorkflowArgsMeta(TypedDict):')
+    expect(text).toContain('class WorkflowArgsMetaPhases(TypedDict):')
+    expect(text).toContain('    meta: WorkflowArgsMeta')
+    expect(text).toContain('    phases: NotRequired[list[WorkflowArgsMetaPhases]]')
+    // Dependency-before-dependent: the item class precedes its container,
+    // which precedes the top-level args class, which precedes the protocol.
+    expect(text.indexOf('class WorkflowArgsMetaPhases')).toBeLessThan(text.indexOf('class WorkflowArgsMeta(TypedDict):'))
+    expect(text.indexOf('class WorkflowArgsMeta(TypedDict):')).toBeLessThan(text.indexOf('class WorkflowArgs(TypedDict):'))
+    expect(text.indexOf('class WorkflowArgs(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
+  })
+
+  it('renders a oneOf of object branches as a union of named TypedDicts declared before the parent', () => {
+    const tool: ToolSdkSchema = {
+      name: 'act',
+      description: 'Union output.',
+      parameters: { type: 'object', additionalProperties: false, properties: {} },
+      output: {
+        oneOf: [
+          { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
+          { type: 'object', additionalProperties: false, properties: { err: { type: 'string' } }, required: ['err'] },
+        ],
+      },
+    }
+    const text = renderToolsSdkPy([tool])
+    // Each object branch becomes its own named class (`${base}Output1/2`),
+    // declared before the protocol references the union.
+    expect(text).toContain('class ActOutput1(TypedDict):')
+    expect(text).toContain('class ActOutput2(TypedDict):')
+    expect(text).toContain('-> ActOutput1 | ActOutput2')
+    expect(text.indexOf('class ActOutput1(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
+    expect(text.indexOf('class ActOutput2(TypedDict):')).toBeLessThan(text.indexOf('class Tools(Protocol):'))
+  })
+
+  it('degrades a context-free oneOf of object branches to a union of dict[str, Any]', () => {
+    // jsonSchemaToPy has no naming context, so each object branch degrades
+    // rather than declaring a class.
+    const type = jsonSchemaToPy({
+      oneOf: [
+        { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] },
+        { type: 'string' },
+      ],
+    })
+    expect(type).toBe('dict[str, Any] | str')
+    // Both branches objects, and the same shape reached through an array: the
+    // marker is the CALL's className, so a propagated frame name (`1`, the
+    // index-derived branch name) does not revive class declaration on a walk
+    // that has nowhere to declare into.
+    const object = { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] }
+    expect(jsonSchemaToPy({ oneOf: [object, object] })).toBe('dict[str, Any] | dict[str, Any]')
+    expect(jsonSchemaToPy({ type: 'array', items: { oneOf: [object, { type: 'string' }] } })).toBe('list[dict[str, Any] | str]')
+  })
+
+  it('suffixes a counter when two tools CamelCase to the same class base', () => {
+    const a: ToolSdkSchema = {
+      name: 'my-tool',
+      description: 'Dash form.',
+      parameters: parameterSchemaSpecToJsonSchema({ x: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const b: ToolSdkSchema = {
+      name: 'my.tool',
+      description: 'Dot form.',
+      parameters: parameterSchemaSpecToJsonSchema({ y: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([a, b])
+    // Both sanitize to `MyToolArgs`; the second collides and gets a suffix.
+    expect(text).toContain('class MyToolArgs(TypedDict):')
+    expect(text).toContain('class MyToolArgs2(TypedDict):')
+  })
+
+  it('caps class-name length so a deep single-field chain stays linear', () => {
+    // Child class names derive from their parent's, so without a cap the sum of
+    // names would be Theta(depth^2). MAX_CLASS_NAME_BASE (120) bounds each name.
+    const depth = 4000
+    let schema: Record<string, unknown> = { type: 'string' }
+    for (let i = 0; i < depth; i++) {
+      schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] }
+    }
+    const tool: ToolSdkSchema = { name: 'deep', description: 'Deep chain.', parameters: schema, output: { type: 'string' } }
+    const text = renderToolsSdkPy([tool])
+    const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
+    expect(longestClassName).toBeLessThanOrEqual(140)
+    expect(text.length).toBeLessThan(depth * 400)
+  })
+
+  it('skips an already-taken counter suffix when a sibling object occupies it', () => {
+    // `phase` and `Phase` both CamelCase to base `FooArgsPhase`; `phase2`
+    // independently takes `FooArgsPhase2`, so `Phase`'s collision scan must
+    // advance to `FooArgsPhase3` (exercises the collision-skip loop).
+    const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } })
+    const tool: ToolSdkSchema = {
+      name: 'foo',
+      description: 'Sibling objects with colliding class bases.',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') },
+        required: ['phase', 'phase2', 'Phase'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('class FooArgsPhase(TypedDict):')
+    expect(text).toContain('class FooArgsPhase2(TypedDict):')
+    expect(text).toContain('class FooArgsPhase3(TypedDict):')
+  })
+
+  it('references the named TypedDict from a reserved/subscript tool too', () => {
+    const tool: ToolSdkSchema = {
+      name: 'class',
+      description: 'Reserved word tool.',
+      parameters: parameterSchemaSpecToJsonSchema({ value: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('class ClassArgs(TypedDict):')
+    expect(text).toContain('# tools["class"](args: ClassArgs) -> str')
+  })
+
+  it('degrades an object to dict[str, Any] when a field name is not a legal Python attribute', () => {
+    const tool: ToolSdkSchema = {
+      name: 'weird_fields',
+      description: 'Has an illegal field name.',
+      parameters: { type: 'object', properties: { 'a-b': { type: 'string' } } },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('async def weird_fields(self, args: dict[str, Any]) -> str:')
+    expect(text).not.toContain('WeirdFieldsArgs')
+  })
+
+  it('keeps soft-keyword field names as TypedDict fields (each is special in exactly one syntactic position)', () => {
+    const tool: ToolSdkSchema = {
+      name: 'search',
+      description: 'Soft keywords as fields.',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          match: { type: 'string' },
+          case: { type: 'boolean' },
+          type: { type: 'string' },
+        },
+        required: ['match'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    // The object keeps its shape rather than degrading to dict[str, Any].
+    expect(text).toContain('class SearchArgs(TypedDict):')
+    expect(text).toContain('match: str')
+    expect(text).toContain('case: NotRequired[bool]')
+    expect(text).toContain('type: NotRequired[str]')
+    expect(text).not.toContain('dict[str, Any]')
+  })
+
+  it('keeps a non-ASCII field name as a TypedDict field and derives its class name from it', () => {
+    // `路径` satisfies `xid_start xid_continue*`, so CPython accepts it as an
+    // attribute and as the `TypedDict` key. Rejecting it would degrade the
+    // whole object, dropping every SIBLING field's name, requiredness and type
+    // too — and under `mode: 'code'` the native schemas are omitted, so
+    // nothing else carries them. The nested class name is from the field, so
+    // `camelCase` has to pass the same characters through instead of splitting
+    // on them.
+    const tool: ToolSdkSchema = {
+      name: '搜索',
+      description: 'Unicode identifiers.',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          路径: { type: 'string' },
+          opts: { type: 'object', additionalProperties: false, properties: { 深度: { type: 'number' } } },
+        },
+        required: ['路径'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toContain('async def 搜索(self, args: 搜索Args) -> str:')
+    expect(text).toContain('class 搜索Args(TypedDict):')
+    expect(text).toContain('    路径: str')
+    expect(text).toContain('class 搜索ArgsOpts(TypedDict):')
+    expect(text).toContain('    深度: NotRequired[float]')
+    expect(text).not.toContain('dict[str, Any]')
+  })
+
+  it('degrades a field name that NFKC-normalizes to something else, which would be declared under another spelling', () => {
+    // U+FB01 LATIN SMALL LIGATURE FI passes the identifier grammar, but CPython
+    // normalizes identifiers at compile time while the harness compares the
+    // JSON key as written: `field: str` would declare and be reachable as
+    // `field`, a key the tool never accepts. Two keys that normalize together
+    // would additionally collapse into one declaration. The subscript path
+    // carries the exact bytes instead.
+    const text = renderToolsSdkPy([
+      {
+        name: 'ligature',
+        description: 'Normalizing field name.',
+        parameters: { type: 'object', additionalProperties: false, properties: { field: { type: 'string' } } },
+        output: { type: 'string' },
+      },
+    ])
+    expect(text).toContain('async def ligature(self, args: dict[str, Any]) -> str:')
+    expect(text).not.toContain('field:')
+    expect(text).not.toContain('field:')
+  })
+
+  it('keeps U+200C in a name tail while rejecting it at a name head, per the two XID properties', () => {
+    // ZWNJ carries `XID_Continue` and not `XID_Start`, so the predicate splits
+    // on position: bare in a tail, subscripted at a head. Both verdicts are
+    // stable across the supported engines — the property arrives in Unicode
+    // 15.1 and the floor (Node 22.19.0, Unicode 16.0) is past it.
+    //
+    // The interpreter side is where this one skews, and it is the same skew the
+    // docstring's four other characters record, reached in a tail position
+    // instead of at a head: CPython reads XID_Continue out of the
+    // `DerivedCoreProperties.txt` of the UCD it was built against (13.0.0 on
+    // 3.9.6 and 15.0.0 on 3.12.13 both lack the row, and
+    // `'a\u200Cb'.isidentifier()` is False on both, measured). What then needs
+    // 15.1 tables or newer is the bare field, once in each class, and the
+    // `Tool\u200CbArgs` class name. The subscript comment quoting the tool name
+    // is not one of them: it is not parsed as an identifier.
+    const of = (name: string): ToolSdkSchema => ({
+      name,
+      description: `Tool ${name}.`,
+      parameters: { type: 'object', additionalProperties: false, properties: { 'a\u200Cb': { type: 'string' } } },
+      output: { type: 'string' },
+    })
+    const text = renderToolsSdkPy([of('ping'), of('\u200Cb')])
+    expect(text).toContain('async def ping(self, args: PingArgs) -> str:')
+    expect(text).toContain('    a\u200Cb: NotRequired[str]')
+    // A head that is XID_Continue but not XID_Start takes the subscript path,
+    // and `camelCase` prefixes `Tool` to make the class name start legally.
+    expect(text).toContain('# tools["\u200Cb"](args: Tool\u200CbArgs) -> str')
+    expect(text).toContain('class Tool\u200CbArgs(TypedDict):')
+    expect(text).not.toContain('async def \u200Cb')
+  })
+
+  it('subscripts a tool name that NFKC-normalizes to something else, while declaring a plain Unicode one', () => {
+    // Same split at the tool-name site: `路径` becomes an `async def`, the
+    // ligature name cannot, because `async def find` would define `find`. The
+    // subscript comment quotes the name, so its exact bytes survive, and its
+    // TypedDict is still named and referenced — the name is only unusable as a
+    // method, not as a class-name source. The `FInd` spelling comes from `fi`'s
+    // multi-character full case mapping (`'fi'.toUpperCase()` is `'FI'`), not
+    // from `camelCase`'s NFKC step, which is the identity on `FInd`: the
+    // ligature is XID_Start, so the split set keeps it and only the
+    // capitalization of the head transforms it.
+    const of = (name: string): ToolSdkSchema => ({
+      name,
+      description: `Tool ${name}.`,
+      parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] },
+      output: { type: 'string' },
+    })
+    const text = renderToolsSdkPy([of('路径'), of('find')])
+    expect(text).toContain('async def 路径(self, args: 路径Args) -> str:')
+    expect(text).toContain('# tools["find"](args: FIndArgs) -> str')
+    expect(text).toContain('class FIndArgs(TypedDict):')
+    expect(text).not.toContain('async def find')
+    expect(text).not.toContain('async def find')
+  })
+
+  it('derives a class name through the case-mapping table, independently of the bare-name predicate', () => {
+    // The head capitalization reads a table `isBareIdentifier` never consults,
+    // so the class-name path can carry a character the predicate cleared. ƛ
+    // (U+019B) is XID_Start and NFKC-stable, so the method is emitted bare;
+    // the head maps to Ƛ (U+A7DC), a code point the engine's tables assign and
+    // an older interpreter's do not. This pins which table produced the name,
+    // so a change to the mapping step shows up here rather than only in a
+    // downstream Python parse.
+    //
+    // Unlike the other Unicode cases in this file, the table row is recent:
+    // U+A7DC and the U+019B uppercase mapping to it both arrive in Unicode
+    // 16.0 (`DerivedAge.txt`; CPython 3.12.13's 15.0.0 has neither). The
+    // engines floor sits exactly there with no margin — Node 22.19.0 reports
+    // Unicode 16.0 (ICU 77.1) and maps U+019B to U+A7DC, measured — so an
+    // engine below the floor fails here as a renderer regression whose real
+    // cause is the table version.
+    const text = renderToolsSdkPy([
+      {
+        name: 'ƛ',
+        description: 'Lambda with stroke.',
+        parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] },
+        output: { type: 'string' },
+      },
+    ])
+    expect(text).toContain('async def ƛ(self, args: ꟜArgs) -> str:')
+    expect(text).toContain('class ꟜArgs(TypedDict):')
+    expect(text).not.toContain('class ƛArgs')
+  })
+
+  it('drops a surrogate half rather than cutting a pair when capping an astral class-name base', () => {
+    // Class-name bases are capped by `slice`, which counts UTF-16 code units,
+    // so a boundary landing inside an astral pair would leave a lone high
+    // surrogate — not an identifier character, and not encodable text. Padding
+    // with one ASCII character shifts the boundary onto the pair.
+    // U+10330 GOTHIC LETTER AHSA: XID_Start and NFKC-stable, unlike `𝕏`, which
+    // NFKC-folds to ASCII `X` and so never reaches the boundary at all.
+    const AHSA = String.fromCodePoint(0x10330)
+    const className = (pad: string): string => {
+      const text = renderToolsSdkPy([
+        {
+          name: `${pad}${AHSA.repeat(200)}`,
+          description: 'Astral name.',
+          parameters: { type: 'object', additionalProperties: false, properties: { a: { type: 'string' } } },
+          output: { type: 'string' },
+        },
+      ])
+      // The base is `${camelCase(name)}Args` capped to 120 code units, so the
+      // `Args` suffix itself is cut off here; match the declaration instead.
+      return /^class (.+)\(TypedDict\):$/mu.exec(text)![1]!
+    }
+    // Each character is 2 code units, so an unpadded name fills the cap with 60
+    // whole characters; one ASCII character of padding puts the boundary inside
+    // the 60th pair, and that half is dropped rather than emitted.
+    expect(className('')).toBe(AHSA.repeat(60))
+    const padded = className('x')
+    expect(padded).toBe(`X${AHSA.repeat(59)}`)
+    expect(padded).toHaveLength(119)
+  })
+
+  it('normalizes the seam the Tool prefix creates, which the prefixed part alone does not cover', () => {
+    // U+0301 COMBINING ACUTE ACCENT is XID_Continue but not XID_Start, so a name
+    // headed by it takes the `Tool` prefix — and `Tool` ends in `l`, which
+    // composes with it. Normalizing only the part being prefixed would emit
+    // `Tool` + U+0301, which CPython compiles as `Too` + U+013A: the class
+    // the SDK declares would not be the class the interpreter defines. Every
+    // code point below is an escape — the two forms render identically.
+    const text = renderToolsSdkPy([
+      {
+        name: '\u0301abc',
+        description: 'Combining-mark head.',
+        parameters: { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } }, required: ['q'] },
+        output: { type: 'string' },
+      },
+    ])
+    expect(text).toContain('class Too\u013AabcArgs(TypedDict):')
+    expect(text).toContain('# tools["\u0301abc"](args: Too\u013AabcArgs) -> str')
+    expect(text).not.toContain('Tool\u0301')
+  })
+
+  it('normalizes a class-name join where two separately stable segments compose', () => {
+    // Hangul jamo compose ACROSS the join `childClassName` makes: the parent
+    // base ends in U+1100 (L jamo) and the child segment starts with U+1161 (V
+    // jamo), each NFKC-stable alone, together U+AC00. Unnormalized, the declared
+    // name differs from the compiled symbol, and two byte-distinct names can
+    // fold onto one — `usedClassNames` dedupes by raw bytes, so the collision
+    // counter never sees it and the later declaration shadows the earlier one
+    // under CPython. Escapes again, for the same reason as above.
+    const text = renderToolsSdkPy([
+      {
+        name: 'x',
+        description: 'Jamo field names.',
+        parameters: {
+          type: 'object',
+          additionalProperties: false,
+          required: ['\uAC00\u1100'],
+          properties: {
+            '\uAC00\u1100': {
+              type: 'object',
+              additionalProperties: false,
+              required: ['\u1161x'],
+              properties: {
+                '\u1161x': { type: 'object', additionalProperties: false, properties: { q: { type: 'string' } } },
+              },
+            },
+          },
+        },
+        output: { type: 'string' },
+      },
+    ])
+    // The join is `XArgs` + U+AC00 U+1100 followed by U+1161 `x`, whose
+    // trailing L+V pair composes into a second U+AC00.
+    expect(text).toContain('class XArgs\uAC00\uAC00x(TypedDict):')
+    expect(text).toContain('    \u1161x: XArgs\uAC00\uAC00x')
+    expect(text).not.toContain('\u1100\u1161')
+    // The level above it is a join that composes nothing (LV + L), so it stays
+    // byte-identical — normalizing is not silently rewriting every name.
+    expect(text).toContain('class XArgs\uAC00\u1100(TypedDict):')
+  })
+
+  it('routes a fold collision through the counter that raw-byte dedup would miss', () => {
+    // The other half of the `childClassName` normalization: two joins that are
+    // byte-distinct before NFKC and identical after. Field `\uAC00` allocates
+    // `XArgs\uAC00`; the sibling `\u1100` allocates `XArgs\u1100`, and ITS child
+    // `\u1161` joins to `XArgs\u1100\u1161` — the same `XArgs\uAC00` once composed.
+    // Normalizing at the join is what lets `usedClassNames`, which dedupes by raw
+    // bytes, see the collision at all; unnormalized, both would be declared and
+    // CPython would compile the second as a shadow of the first.
+    const text = renderToolsSdkPy([
+      {
+        name: 'x',
+        description: 'Colliding jamo joins.',
+        parameters: {
+          type: 'object',
+          additionalProperties: false,
+          required: ['\uAC00', '\u1100'],
+          properties: {
+            '\uAC00': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } },
+            '\u1100': {
+              type: 'object',
+              additionalProperties: false,
+              required: ['\u1161'],
+              properties: {
+                '\u1161': { type: 'object', additionalProperties: false, required: ['q'], properties: { q: { type: 'string' } } },
+              },
+            },
+          },
+        },
+        output: { type: 'string' },
+      },
+    ])
+    expect(text).toContain('class XArgs\uAC00(TypedDict):')
+    expect(text).toContain('class XArgs\uAC002(TypedDict):')
+    expect(text).toContain('    \u1161: XArgs\uAC002')
+  })
+
+  it('names both branches of a oneOf of objects on the argument side', () => {
+    // The output side is pinned elsewhere; arguments reach the same
+    // `childClassName(frame.className, index + 1)` path, and the annotation is
+    // the union of the two derived names rather than a degraded dict.
+    const text = renderToolsSdkPy([
+      {
+        name: 'x',
+        description: 'Union arguments.',
+        parameters: {
+          oneOf: [
+            { type: 'object', additionalProperties: false, required: ['a'], properties: { a: { type: 'string' } } },
+            { type: 'object', additionalProperties: false, required: ['b'], properties: { b: { type: 'number' } } },
+          ],
+        },
+        output: { type: 'string' },
+      },
+    ])
+    expect(text).toContain('class XArgs1(TypedDict):')
+    expect(text).toContain('class XArgs2(TypedDict):')
+    expect(text).toContain('async def x(self, args: XArgs1 | XArgs2) -> str:')
+  })
+
+  it('declares a closed empty object with omitted properties as an empty TypedDict, not dict[str, Any]', () => {
+    // `{ type: 'object', additionalProperties: false }` with no `properties`
+    // is a closed empty object — no key accepted — exactly as the validator
+    // and the TS renderer read it. It must not degrade to a permissive dict.
+    const tool: ToolSdkSchema = {
+      name: 'closed',
+      description: 'Closed empty object with omitted properties.',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: { inner: { type: 'object', additionalProperties: false } },
+        required: ['inner'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    expect(text).toMatch(/class ClosedArgsInner\(TypedDict\):\n    pass/)
+    expect(text).toContain('inner: ClosedArgsInner')
+    expect(text).not.toContain('dict[str, Any]')
+  })
+
+  it('degrades an open object with omitted properties to dict[str, Any]', () => {
+    // An OPEN empty object (default additionalProperties) is any dict.
+    const type = jsonSchemaToPy({ type: 'object', properties: {} })
+    expect(type).toBe('dict[str, Any]')
+    expect(jsonSchemaToPy({ type: 'object' })).toBe('dict[str, Any]')
+  })
+
+  it('renders docstrings for descriptions and orders emissions lexicographically', () => {
+    const text = renderToolsSdkPy([bash, exotic])
+    expect(text).toContain('"""Run a shell command."""')
+    // Descriptions on subscript names ride as a comment beside their entry.
+    expect(text).toContain('# tools["my-mcp.tool"]')
+    expect(text).toContain('#   Exotic name.')
+    // Lexicographic: `bash` before `my-mcp.tool`.
+    expect(text.indexOf('async def bash')).toBeLessThan(text.indexOf('# tools["my-mcp.tool"]'))
+  })
+
+  it('places a docstring as the first statement of its own method body', () => {
+    // Python attaches a docstring to a function only when it is that
+    // function's first statement. Above the `async def` the first one would
+    // document the `Tools` class and every later one would be a dead
+    // expression, so each method must open its body with its own docstring.
+    const second: ToolSdkSchema = {
+      name: 'zzz',
+      description: 'Second by name.',
+      parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const lines = renderToolsSdkPy([bash, second]).split('\n')
+    for (const [name, doc] of [['bash', 'Run a shell command.'], ['zzz', 'Second by name.']]) {
+      const signature = lines.findIndex(line => line.startsWith(`${' '.repeat(4)}async def ${name}(`))
+      expect(signature).toBeGreaterThan(-1)
+      // Ends in `:`, not the `: ...` stub — a docstring IS the whole body.
+      expect(lines[signature]?.endsWith(':')).toBe(true)
+      expect(lines[signature + 1]).toBe(`${' '.repeat(8)}"""${doc}"""`)
+    }
+    // No docstring is left floating at class-body indentation.
+    expect(lines.filter(line => line.startsWith(`${' '.repeat(4)}"""`))).toEqual([])
+  })
+
+  it('orders subscript entries against methods by name, not by member kind', () => {
+    // `a-tool` sorts before `z`, so the subscript comment must precede the
+    // method: one ordered stream, not methods-then-comments.
+    const noArgs = parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>
+    const text = renderToolsSdkPy([
+      { name: 'z', description: 'Last by name.', parameters: noArgs, output: { type: 'string' } },
+      { name: 'a-tool', description: 'First by name.', parameters: noArgs, output: { type: 'string' } },
+    ])
+    expect(text.indexOf('# tools["a-tool"]')).toBeLessThan(text.indexOf('async def z'))
+    // The interleaved comment does not disturb the class body: `z` still parses
+    // as the statement that keeps `pass` out.
+    expect(text).not.toContain(`${' '.repeat(4)}pass`)
+  })
+
+  it('is deterministic: byte-identical output regardless of input order or duplication', () => {
+    expect(renderToolsSdkPy([bash, exotic])).toBe(renderToolsSdkPy([exotic, bash]))
+    expect(renderToolsSdkPy([bash, bash])).toBe(renderToolsSdkPy([bash, bash]))
+  })
+
+  it('renders a pass body and a minimal import for an empty tool set', () => {
+    const text = renderToolsSdkPy([])
+    expect(text).toContain('class Tools(Protocol):')
+    expect(text).toContain('    pass')
+    // Nothing but the protocol is used, so the import line is just Protocol.
+    expect(text).toContain('from typing import Protocol')
+  })
+
+  it('omits the docstring/comment when a schema has no description', () => {
+    const undescribedIdentifier: ToolSdkSchema = {
+      name: 'plain',
+      description: '',
+      parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const undescribedExotic: ToolSdkSchema = {
+      name: 'weird-name',
+      description: '',
+      parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic])
+    // Identifier method appears without a docstring in its body — hence the
+    // `: ...` stub, which a documented method replaces with the docstring.
+    expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...')
+    expect(text).not.toContain('"""')
+    // Subscript entry appears without the "#   ..." description follow-up.
+    expect(text).toContain('# tools["weird-name"]')
+    expect(text.split('\n').every(line => !line.startsWith('    #   '))).toBe(true)
+    // A whitespace-only description collapses to nothing and is treated as
+    // absent: no empty `""""""` docstring, no bare `#   ` line.
+    const blank = renderToolsSdkPy([
+      { ...undescribedIdentifier, description: ' \t\n ' },
+      { ...undescribedExotic, description: '   ' },
+    ])
+    expect(blank).toBe(text)
+  })
+
+  it('marks an open object TypedDict and declares a closed empty object', () => {
+    const t: ToolSdkSchema = {
+      name: 'openness',
+      description: '',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: {
+          open: { type: 'object', additionalProperties: true, properties: { x: { type: 'string' } }, required: ['x'] },
+          closedEmpty: { type: 'object', additionalProperties: false, properties: {} },
+        },
+        required: ['open', 'closedEmpty'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([t])
+    // The open nested object carries the in-band openness note...
+    expect(text).toContain('class OpennessArgsOpen(TypedDict):')
+    expect(text).toMatch(/class OpennessArgsOpen\(TypedDict\):\n    x: str\n    # Additional keys beyond those declared are allowed\./)
+    // ...the closed root does not...
+    expect(text).toMatch(/class OpennessArgs\(TypedDict\):\n    open: OpennessArgsOpen\n    closedEmpty: OpennessArgsClosedEmpty\n\n/)
+    // ...and a closed EMPTY object declares an empty TypedDict rather than
+    // degrading to dict[str, Any] (which would falsely accept any keys).
+    expect(text).toMatch(/class OpennessArgsClosedEmpty\(TypedDict\):\n    pass/)
+    expect(text).toContain('closedEmpty: OpennessArgsClosedEmpty')
+  })
+
+  it('renders a deeply nested array schema without exhausting the call stack, capped at CPython\'s bracket limit', () => {
+    // The registry supports depth-unbounded schemas; the renderer must not
+    // reintroduce a recursion limit during prompt assembly. It must also not
+    // emit more open brackets than CPython's tokenizer accepts (200), so the
+    // chain degrades to `Any` at MAX_LIST_NESTING instead of rendering an SDK
+    // block that is not valid Python.
+    let deep: Record<string, unknown> = { type: 'string' }
+    for (let i = 0; i < 20000; i++) deep = { type: 'array', items: deep }
+    const type = jsonSchemaToPy(deep)
+    expect(type.startsWith('list[list[')).toBe(true)
+    expect(type.endsWith(']]')).toBe(true)
+    // 180 `list[` levels around `Any`, not 20000 around `str`.
+    expect(type).toBe(`${'list['.repeat(180)}Any${']'.repeat(180)}`)
+    expect(type.split('[').length - 1).toBeLessThan(200)
+  })
+
+  it('keeps a chain just under the nesting cap exact, and restarts nesting per TypedDict field', () => {
+    // 179 levels still render the real item type: the cap degrades only what
+    // would not parse.
+    let under: Record<string, unknown> = { type: 'string' }
+    for (let i = 0; i < 179; i++) under = { type: 'array', items: under }
+    expect(jsonSchemaToPy(under)).toBe(`${'list['.repeat(179)}str${']'.repeat(179)}`)
+    // A field annotation is a fresh logical line, so a 179-deep chain reached
+    // THROUGH an object field is unaffected by the depth spent on the object.
+    const tool: ToolSdkSchema = {
+      name: 'deep_field',
+      description: 'Deep array under a field.',
+      parameters: { type: 'object', additionalProperties: false, properties: { rows: under }, required: ['rows'] },
+      output: { type: 'string' },
+    }
+    expect(renderToolsSdkPy([tool])).toContain(`    rows: ${'list['.repeat(179)}str${']'.repeat(179)}`)
+  })
+
+  it('caps the argument annotation, the site whose enclosing paren stays open', () => {
+    // The worst of the three emission sites: the parameter list's `(` is still
+    // open around this annotation, so 180 `list[` plus the innermost bracket
+    // plus that paren is 182 of CPython's 200. Only a raw `register()` whose
+    // `parameters` is an array reached from the root through `oneOf` arms
+    // alone gets there — the root array itself, or one under any depth of
+    // unions, since an arm inherits the enclosing depth unchanged. An object
+    // ancestor takes it out of this case: its fields restart at the 181 site.
+    // `defineTool` compiles an object root, whose annotation is a bare
+    // TypedDict name or a one-bracket `dict[str, Any]`, never a chain.
+    const rooted = (depth: number): ToolSdkSchema => {
+      let schema: Record<string, unknown> = { type: 'string', const: 'x' }
+      for (let i = 0; i < depth; i++) schema = { type: 'array', items: schema }
+      return { name: 'rooted', description: 'Array-rooted parameters.', parameters: schema, output: { type: 'string' } }
+    }
+    // Exactly at the cap with a scalar underneath is the worst case itself: the
+    // chain's root frame starts at `listDepth: 0` here, so all 180 `list[`
+    // still emit and the innermost `Literal[` is reached rather than degraded.
+    const worst = renderToolsSdkPy([rooted(180)])
+    expect(worst).toContain(`async def rooted(self, args: ${'list['.repeat(180)}Literal["x"]${']'.repeat(180)}) -> str:`)
+    const annotation = worst.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]!
+    // 181 brackets on the annotation plus the still-open parameter-list paren,
+    // the 182 the cap is chosen against.
+    expect(annotation.split('[').length - 1).toBe(181)
+    // One array deeper is where the degradation lands, and it lands on the item
+    // rather than on another `list[`, so the count cannot grow past that.
+    expect(renderToolsSdkPy([rooted(181)]))
+      .toContain(`async def rooted(self, args: ${'list['.repeat(180)}Any${']'.repeat(180)}) -> str:`)
+    // A union spine reaches the same 182, at any number of arms deep: each arm
+    // inherits the enclosing depth because `A | B` opens nothing, so the chain
+    // under the innermost one still starts at 0. Three unions here, to pin that
+    // it is the whole `oneOf`-only path and not just a single root union.
+    let spine: Record<string, unknown> = rooted(180).parameters
+    for (let i = 0; i < 3; i++) spine = { oneOf: [spine, { type: 'string' }] }
+    const text = renderToolsSdkPy([{ ...rooted(180), parameters: spine }])
+    const chain = `${'list['.repeat(180)}Literal["x"]${']'.repeat(180)}`
+    expect(text).toContain(`args: ${chain} | str | str | str) -> str:`)
+    expect(text.split('async def rooted(self, args: ')[1]!.split(') -> str:')[0]!.split('[').length - 1).toBe(181)
+    // An object ancestor is the boundary of that path: the field it declares is
+    // a class-body line, so the same chain lands on the 181 site instead.
+    const boxed = renderToolsSdkPy([
+      {
+        ...rooted(180),
+        parameters: { type: 'object', properties: { rows: rooted(180).parameters }, required: ['rows'] },
+      },
+    ])
+    expect(boxed).toContain(`    rows: ${'list['.repeat(179)}Any${']'.repeat(179)}`)
+  })
+
+  it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => {
+    // Each level is a two-branch oneOf whose first branch recurses; joining the
+    // accumulated union string at every level would be Theta(depth^2). At this
+    // depth the quadratic path (~100,000^2 char copies) blows past vitest's 5s
+    // default, so this fails loud on a regression; the `+`/ConsString path is
+    // milliseconds. (Guard the depth explicitly so the assertions stay exact.)
+    // The resulting chain is intentionally uncapped, unlike list nesting: it is
+    // grammatically valid Python at any length, and only CPython's `compile()`
+    // recursion would reject it — see the `oneOf` arm in py-types.ts.
+    const depth = 100000
+    let deep: Record<string, unknown> = { type: 'string' }
+    for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] }
+    const type = jsonSchemaToPy(deep)
+    expect(type.startsWith('str | None')).toBe(true)
+    expect(type.endsWith(' | None')).toBe(true)
+    expect(type.length).toBe('str'.length + ' | None'.length * depth)
+  })
+
+  it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => {
+    // Every level is a oneOf whose SECOND branch is a named object (a closed
+    // empty TypedDict) and whose first branch recurses — so every level has an
+    // object node, each propagating a class name one segment longer. Without a
+    // propagation cap, allocateClassName slices an ever-longer rope at every
+    // level → Theta(depth^2) (~9.5s at this depth, past the 5s default);
+    // childClassName caps the base so it stays linear (~ms). Assertions are
+    // shape-based but the depth is the tripwire: a regression times out.
+    const depth = 60000
+    let deep: Record<string, unknown> = { type: 'object', additionalProperties: false, properties: {} }
+    for (let i = 0; i < depth; i++) {
+      deep = { oneOf: [deep, { type: 'object', additionalProperties: false, properties: {} }] }
+    }
+    const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } }
+    const text = renderToolsSdkPy([tool])
+    // No emitted class name exceeds the cap (plus a short collision suffix).
+    const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
+    expect(longest).toBeLessThanOrEqual(140)
+    expect(text).toContain('class Tools(Protocol):')
+  })
+
+  it('caps the class name for a tool whose name exceeds the base length limit', () => {
+    // The root class base is `${CamelCase(name)}Args`; a very long tool name
+    // makes it exceed MAX_CLASS_NAME_BASE, so allocateClassName caps it.
+    const longName = `x_${'a'.repeat(200)}`
+    const tool: ToolSdkSchema = {
+      name: longName,
+      description: 'Long name.',
+      parameters: { type: 'object', additionalProperties: false, properties: { f: { type: 'string' } }, required: ['f'] },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([tool])
+    const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
+    expect(longest).toBeLessThanOrEqual(140)
+    expect(text).toContain('class Tools(Protocol):')
+  })
+
+  it('emits pass for a subscript-only tool set (comments are not statements)', () => {
+    const t: ToolSdkSchema = {
+      name: 'my-exotic.tool',
+      description: '',
+      parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([t])
+    // The class body must contain a statement before the subscript comments.
+    expect(text).toMatch(/class Tools\(Protocol\):\n    pass\n    # tools\["my-exotic\.tool"\]/)
+  })
+
+  it('degrades an object whose field would be name-mangled (__token) to dict[str, Any]', () => {
+    // Class-syntax TypedDict mangles a leading-double-underscore non-dunder
+    // annotation to _ClassName__token — a different JSON key than the schema.
+    const t: ToolSdkSchema = {
+      name: 'mangler',
+      description: '',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: { __token: { type: 'string' } },
+        required: ['__token'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([t])
+    expect(text).toContain('async def mangler(self, args: dict[str, Any]) -> str: ...')
+    expect(text).not.toContain('__token:')
+    // Dunder-form fields (__meta__) are NOT mangled and stay expressible.
+    const dunder: ToolSdkSchema = {
+      name: 'dunder',
+      description: '',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: { __meta__: { type: 'string' } },
+        required: ['__meta__'],
+      },
+      output: { type: 'string' },
+    }
+    expect(renderToolsSdkPy([dunder])).toContain('__meta__: str')
+  })
+
+  it('degrades an object with a __debug__ field, which CPython refuses to assign', () => {
+    // `__debug__` is a legal identifier and dunder-form, so it clears both the
+    // identifier rule and the name-mangling rule, but CPython rejects the
+    // annotation at COMPILE time (`SyntaxError: cannot assign to __debug__`) —
+    // and this block is Code Mode's only SDK, so it must always parse.
+    const t: ToolSdkSchema = {
+      name: 'debugger',
+      description: '',
+      parameters: {
+        type: 'object',
+        additionalProperties: false,
+        properties: { __debug__: { type: 'string' } },
+        required: ['__debug__'],
+      },
+      output: { type: 'string' },
+    }
+    const text = renderToolsSdkPy([t])
+    expect(text).toContain('async def debugger(self, args: dict[str, Any]) -> str: ...')
+    expect(text).not.toContain('__debug__')
+  })
+
+  it('routes every underscore-leading tool name to subscript access', () => {
+    // `_foo` and `__meta__` are both legal Python attributes, unlike an exotic
+    // name or a hard keyword, yet the whole underscore family goes to
+    // `tools[name]` under one rule. Only some forms actually break — `__token`
+    // name-mangles at the CALL SITE inside the model's own class, and a dunder
+    // that exists on `object` (`__class__`) resolves before the proxy's
+    // __getattr__ runs — so the family rule is what routes `_foo` and
+    // `__meta__`, not a defect in those two names.
+    const make = (name: string): ToolSdkSchema => ({
+      name,
+      description: 'Leading underscore.',
+      parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+      output: { type: 'string' },
+    })
+    const text = renderToolsSdkPy([make('_foo'), make('__meta__'), make('__token')])
+    for (const name of ['_foo', '__meta__', '__token']) {
+      expect(text).toContain(`# tools[${JSON.stringify(name)}](args: dict[str, Any]) -> str`)
+      expect(text).not.toContain(`async def ${name}(`)
+    }
+    // No method emitted at all, so the class body needs the explicit `pass`.
+    expect(text).toContain('    pass\n')
+  })
+
+  it('quotes a tool name through the same JSON.stringify the Literal path depends on', () => {
+    // A lone surrogate is reachable in a name — `"\ud800"` survives
+    // `JSON.parse` of MCP wire JSON — and this path has no UNPRINTABLE /
+    // LONE_SURROGATE fallback behind it, only ES2019 well-formed
+    // stringification. Raw, it would make the whole SDK block uncompilable,
+    // exactly as on the `Literal[...]` path.
+    const text = renderToolsSdkPy([
+      {
+        name: 'a\ud800b',
+        description: 'Lone surrogate in the name.',
+        parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
+        output: { type: 'string' },
+      },
+    ])
+    expect(text).toContain(String.raw`# tools["a\ud800b"](args: dict[str, Any]) -> str`)
+    expect(text).not.toContain('\ud800')
+  })
+
+  it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => {
+    // A description ending in `"` or an odd backslash would otherwise merge
+    // with (or escape) the closing triple quote — and this block is Code
+    // Mode's only SDK, so it must always parse.
+    const trailingQuote = renderToolsSdkPy([described('ends in a quote"')])
+    expect(trailingQuote).toContain(String.raw`"""ends in a quote\""""`)
+    const trailingBackslash = renderToolsSdkPy([described('ends in a backslash\\')])
+    expect(trailingBackslash).toContain(String.raw`"""ends in a backslash\\"""`)
+    const tripleQuote = renderToolsSdkPy([described('contains """ triple quote')])
+    expect(tripleQuote).toContain(String.raw`"""contains \"\"\" triple quote"""`)
+  })
+
+  it('escapes unprintable control characters, which CPython refuses inside source at all', () => {
+    // `compile()` raises `SyntaxError: source code string cannot contain null
+    // bytes` for a NUL ANYWHERE in the source text, including inside a string
+    // literal or a comment, so a NUL that survives normalization into a
+    // docstring or a `#` field comment stops this block — Code Mode's only SDK —
+    // from parsing at all. The whitespace collapse does not remove it (a NUL is
+    // not whitespace). Rendering it as a visible escape keeps the source
+    // parseable and still shows the model what the schema said.
+    const nul = renderToolsSdkPy([described('before\u0000after')])
+    // Both emission sites: the method docstring and the `#` field comment. The
+    // docstring's backslash is doubled by the same escaping that keeps a literal
+    // backslash from escaping the closing triple quote, so Python parses it back
+    // to the visible `\x00` the comment shows directly. Neither carries the byte.
+    expect(nul).not.toContain('\u0000')
+    expect(nul).toContain(String.raw`"""before\\x00after"""`)
+    expect(nul).toContain(String.raw`# before\x00after`)
+    // The other C0 controls and DEL escape on the same path. Tab, newline and
+    // carriage return never reach it: the whitespace collapse folds them to a
+    // space first.
+    const others = renderToolsSdkPy([described('bell\u0007esc\u001bdel\u007f')])
+    expect(others).toContain(String.raw`bell\x07esc\x1bdel\x7f`)
+    expect(renderToolsSdkPy([described('tab\tnewline\ncr\r')])).toContain('"""tab newline cr"""')
+    // No C1 control is ECMAScript whitespace (TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus
+    // LF/CR/LS/PS), so the collapse folds none of U+0080 to U+009F and the
+    // escape is what keeps them out of the docstring, where they would be
+    // invisible. NBSP, which IS whitespace, folds instead. Windows-1252 bytes
+    // 0x80 to 0x9F decoded as Latin-1 land exactly here.
+    const nel = renderToolsSdkPy([described('a\u0085b')])
+    expect(nel).not.toContain('\u0085')
+    expect(nel).toContain(String.raw`# a\x85b`)
+    const c1 = renderToolsSdkPy([described('csi\u009bst\u009cend\u009f')])
+    expect(c1).toContain(String.raw`csi\x9bst\x9cend\x9f`)
+    expect(renderToolsSdkPy([described('nb\u00a0sp')])).toContain('"""nb sp"""')
+    // `Cf` formatting characters pass through by category, not by
+    // addressability — U+00AD would fit `\xNN`, the rest would need a second
+    // form. They terminate neither a Python string literal nor a `#` comment,
+    // so the block stays parseable with the code point intact.
+    expect(renderToolsSdkPy([described('zero\u200bwidth')])).toContain('"""zero\u200bwidth"""')
+    // Whitespace around a surviving control character is not an absent
+    // description. The escape's output is non-whitespace ASCII and the escaped
+    // sets are disjoint from what `trim()` strips, so the two operations touch
+    // different characters and their order is unobservable.
+    expect(renderToolsSdkPy([described(' \u0085 ')])).toContain(String.raw`# \x85`)
+  })
+
+  it('escapes unpaired surrogates, which make the source impossible to encode', () => {
+    // This is the NUL case, not the invisible-character case: Python source
+    // must be UTF-8-encodable, and `compile()` raises `UnicodeEncodeError:
+    // surrogates not allowed` for a lone surrogate in a string literal and in a
+    // `#` comment alike, so one would stop this block — Code Mode's only SDK —
+    // from parsing. A wire description reaches it: `JSON.parse` on a `"\ud800"`
+    // escape yields exactly this code point.
+    const high = renderToolsSdkPy([described('a\ud800b')])
+    expect(high).not.toContain('\ud800')
+    expect(high).toContain(String.raw`# a\ud800b`)
+    expect(high).toContain(String.raw`"""a\\ud800b"""`)
+    // A lone LOW surrogate is just as unencodable, and `\xNN` reaches neither.
+    expect(renderToolsSdkPy([described('a\udfffb')])).toContain(String.raw`# a\udfffb`)
+    // A well-formed pair is ONE astral code point, not two surrogates — the
+    // regex's `u` flag is what draws that line, so an emoji survives intact.
+    expect(renderToolsSdkPy([described('emoji \u{1f600} ok')])).toContain('"""emoji \u{1f600} ok"""')
+  })
+})

+ 2 - 2
packages/fs/fs-policy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/fs-policy/README.md
-README.md: dc4e9377793570c80b8d71ec84196bebe7fe583a
-README.zh.md: 499192d2623765f214af7556d23beabbe2129ea1
+README.md: f6b3292bdc6e5565df0393a59c50d4e594921401
+README.zh.md: 2ebd2f054ece0472c7147f6f9e740987b11c6031

+ 1 - 1
packages/fs/fs-policy/README.md

@@ -55,7 +55,7 @@ Because the plugin influences the world only through events, removing it does no
 
 #### What the model sees
 
-This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper; observation state is never shown.
+This plugin adds no prompt or schema. It rejects an edit without a prior read with code `FS_NOT_OBSERVED` and exact message `edit requires reading "<path>" first`. Guarded mutations whose observed version is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code; observation state is never shown.
 
 #### Token effect
 

+ 1 - 1
packages/fs/fs-policy/README.zh.md

@@ -55,7 +55,7 @@ await ctx.plugin(FsPolicy)
 
 #### 模型看到的内容
 
-该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装;观察状态绝不会显示。
+该插件不添加提示词或 schema。编辑前未读取时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "<path>" first` 拒绝。观察版本陈旧的防护变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.md)拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码;观察状态绝不会显示。
 
 #### Token 影响
 

+ 2 - 2
packages/fs/tool-fs/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md
-README.md: 1ecfa0d013e1208b7d9058b4a254990f16f118e0
-README.zh.md: 9d4de4c219e7818c83c5ac326d2f2d385a30ece7
+README.md: 28880860dc6c89745eb21fbf732d04f596f9b07f
+README.zh.md: 8af0aec51e71681211bbd5a4f582be8b8b0271b8

+ 1 - 1
packages/fs/tool-fs/README.md

@@ -136,7 +136,7 @@ Append-only; newly visible content follows the reusable request prefix and does
 
 #### What the model sees
 
-Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs.
+Failures are normalized as `Error: <message>`. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to <max>`, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "<path>": not found`, `cannot read "<path>": not a regular file`, and `offset <offset> is out of range for "<path>" (<total> lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` (including a missing edit target) gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved.
 
 #### Token effect
 

+ 1 - 1
packages/fs/tool-fs/README.zh.md

@@ -136,7 +136,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces
 
 #### 模型看到的内容
 
-失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。
+失败会规范化为 `Error: <message>`。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to <max>`、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "<path>": not found`、`cannot read "<path>": not a regular file` 和 `offset <offset> is out of range for "<path>" (<total> lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION`(包括编辑目标缺失)追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。
 
 #### Token 影响
 

+ 10 - 4
packages/fs/tool-fs/src/edit.ts

@@ -11,6 +11,7 @@ import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-
 import type {} from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
+import { remediateFsError } from './error.ts'
 import { sessionResolveOptions } from './session-cwd.ts'
 import type { FsSandboxSurface } from './sandbox.ts'
 
@@ -116,10 +117,13 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
       const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))
       // Single-slot decision: the policy plugin returns { version: vObserved } or
       // throws FS_NOT_OBSERVED; the bare default is undefined (unconditional edit).
-      // No stat — the bare default never manufactures a version basis.
-      const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
+      // No stat — the bare default never manufactures a version basis. The intent
+      // slot itself can throw FS_NOT_OBSERVED for an unread target, so it sits
+      // inside the try: both that refusal and the provider's guarded-mutation
+      // failure get the model-facing remedy below.
       let outcome
       try {
+        const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
         outcome = await ctx.fs.editText(
           target,
           { oldString: input.oldString, newString: input.newString, replaceAll: input.replaceAll },
@@ -128,8 +132,10 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxSurface): void {
           sandboxPolicy,
         )
       } catch (error: unknown) {
-        // A sandbox denial becomes the shared [sandbox: …] marker; any other error passes through.
-        throw sandbox.mapError(error, sandboxPolicy)
+        // A sandbox denial becomes the shared [sandbox: …] marker (the model
+        // recognizes it from bash); stale/not-observed failures gain their
+        // model-facing remedy; anything else passes through.
+        throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
       }
       // Record the observed version (a no-op when no policy plugin listens).
       ctx.emit('fs/observed', target, outcome.version, exec)

+ 34 - 0
packages/fs/tool-fs/src/error.ts

@@ -0,0 +1,34 @@
+/**
+ * Model-facing remediation for guarded-mutation failures. The provider's
+ * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but
+ * not the only correct recovery (re-read / read the file), so this package
+ * appends the remedy at the model boundary; provider messages stay
+ * machine-oriented and unchanged.
+ * @module @deepseek-ai/dsh-tool-fs/src/error
+ */
+
+import { FsError } from '@deepseek-ai/dsh-fs'
+import type { FsErrorCode } from '@deepseek-ai/dsh-fs'
+
+/** The remedy appended to each remediable failure code's message. */
+const REMEDIES: Partial<Record<FsErrorCode, string>> = {
+  FS_STALE_VERSION: 're-read the file, then retry',
+  FS_NOT_OBSERVED: 'read the file, then retry',
+}
+
+/**
+ * Append the correct recovery instruction to a guarded-mutation failure's
+ * message. `FS_STALE_VERSION` (the file changed since this session's last
+ * observation, including a missing target) recovers only by re-reading;
+ * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError`
+ * code is preserved so retry/permission/UI layers keep routing on it, and the
+ * original error chains as `cause`. Anything else passes through untouched.
+ * @param error - the caught value from a write/edit execution.
+ * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value.
+ */
+export function remediateFsError(error: unknown): unknown {
+  if (!(error instanceof FsError)) return error
+  const remedy = REMEDIES[error.code]
+  if (!remedy) return error
+  return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error })
+}

+ 4 - 2
packages/fs/tool-fs/src/write.ts

@@ -12,6 +12,7 @@ import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import { computeHunkDiffs, diffsFromMeta } from './diff.ts'
+import { remediateFsError } from './error.ts'
 import { sessionResolveOptions } from './session-cwd.ts'
 import type { FsSandboxSurface } from './sandbox.ts'
 
@@ -113,8 +114,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
         outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
       } catch (error: unknown) {
         // A sandbox denial becomes the shared [sandbox: …] marker (the model
-        // recognizes it from bash); any other error passes through.
-        throw sandbox.mapError(error, sandboxPolicy)
+        // recognizes it from bash); stale/not-observed failures gain their
+        // model-facing remedy; anything else passes through.
+        throw remediateFsError(sandbox.mapError(error, sandboxPolicy))
       }
       // Record the observed version (a no-op when no policy plugin listens).
       ctx.emit('fs/observed', target, outcome.version, exec)

+ 35 - 0
packages/fs/tool-fs/tests/error.spec.ts

@@ -0,0 +1,35 @@
+/**
+ * Unit tests for the model-facing error remediation: the remedy appended to
+ * guarded-mutation failures, code preservation, and passthrough behavior.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { FsError } from '@deepseek-ai/dsh-fs'
+import { remediateFsError } from '../src/error.ts'
+
+describe('remediateFsError', () => {
+  it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => {
+    const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION')
+    const remedied = remediateFsError(original) as FsError
+    expect(remedied).toBeInstanceOf(FsError)
+    expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry')
+    expect(remedied.code).toBe('FS_STALE_VERSION')
+    expect(remedied.cause).toBe(original)
+  })
+
+  it('appends the read remedy to FS_NOT_OBSERVED', () => {
+    const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError
+    expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry')
+    expect(remedied.code).toBe('FS_NOT_OBSERVED')
+  })
+
+  it('leaves other FsError codes untouched', () => {
+    const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND')
+    expect(remediateFsError(original)).toBe(original)
+  })
+
+  it('leaves non-FsError values untouched', () => {
+    const original = new Error('boom')
+    expect(remediateFsError(original)).toBe(original)
+  })
+})

Some files were not shown because too many files changed in this diff