Merge branch 'worktree/forge-change-request-bindings' into worktree/forge-pr-worktree-panel

This commit is contained in:
Yichen Jiang
2026-10-20 20:39:17 +08:00
810 changed files with 16917 additions and 2984 deletions
@@ -0,0 +1,2 @@
2026-10-14-built-in-plugin-feature-previews.md: ae9b32340d9b1e77a99465445a05ccee05df0e79
2026-10-14-built-in-plugin-feature-previews.zh.md: 9229b0835a23a0eefffa1bd157efece333aebfa9
@@ -1,6 +1,7 @@
# Agent Note: Built-in plugin feature previews
Status: implemented
Archived: 2026-10-19
English | [中文](2026-10-14-built-in-plugin-feature-previews.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 内置插件功能配图
Status: implemented
Archived: 2026-10-19
[English](2026-10-14-built-in-plugin-feature-previews.md) | 中文
+3
View File
@@ -2020,6 +2020,9 @@
"feature/2026-09-28-desktop-login-shell-environment.i18n.yaml": "sha256:6129edeb0a7ecf087911193ca5a15223a730ad01e30d2ead7739d01f589fafa7",
"feature/2026-09-28-desktop-login-shell-environment.md": "sha256:cd133b1ef1a178828d30b9db176a91597eedf9e67802551356f34d3e0f80206d",
"feature/2026-09-28-desktop-login-shell-environment.zh.md": "sha256:1422667a44aacc51736f31b328a8e171a39cb6b5168d6993e980e4324b23b98f",
"feature/2026-10-14-built-in-plugin-feature-previews.i18n.yaml": "sha256:4769fd7fa4881a38fa8e2682b2ea62ed5a5ec6588e87f225c2b106e1bd308200",
"feature/2026-10-14-built-in-plugin-feature-previews.md": "sha256:093e00da4cebf732861c1e5c47465fb00359eb89a86dc212f3f90b43f9b80cec",
"feature/2026-10-14-built-in-plugin-feature-previews.zh.md": "sha256:56adb70945f68b073357c437bbec9e69f110587cf302291f2f135d9fcb5537e7",
"process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
"process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
"process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",
@@ -24,8 +24,8 @@
en: ec3703b7e7c6b23d
zh: 325755e6e90092da
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema:
en: f734fdc57a6546c9
zh: 027c28cbc584a8c2
en: 6dba3b9a51c28b6d
zh: 58a93f6ba2d269ea
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/tool-consumer-behavior:
en: 62127883453dd79c
zh: 01503a8177a0ddab
@@ -55,19 +55,16 @@
zh: 64032ea6ab61b836
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/mirror-claude-code-s-url-prompt-webfetch-shape:
en: 56a080de39e96056
zh: ae4d070aa7657503
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/validate-dns-and-then-call-an-ordinary-fetch:
en: aa6c1e7e6374a875
zh: 045c8af6cd44c3e1
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/block-private-looking-hostname-strings-without-pinning-resolved-addresses:
en: 3d906f3056733479
zh: d652723eb5372007
zh: 937336a73de70518
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/restrict-fetch-to-public-addresses:
en: af3014c0de7ff66f
zh: 509040c9183327fe
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/require-per-call-approval-before-public-fetches:
en: f8d1fc8e9c9659cf
zh: da5792520e35cba0
en: 3ea94ffb142d144a
zh: 89645f68412f6769
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences:
en: 12fd20ba54818215
zh: c1ca000b13666701
en: 9774448fd6b21c41
zh: 8411c590ad70d5c4
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work:
en: 48b7b8e7310cd56b
zh: d406cd6a8a649e58
@@ -202,7 +202,7 @@ Full page retrieval remains the job of `web_fetch(url)`. Search snippets are dis
## Fetch request and result schema
The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, resolves and pins public destinations, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services.
The `web_fetch` implementation is an anonymous HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, or internal auth tokens. It does not restrict destination addresses.
The seam request stays smaller than OpenCode's model-facing tool:
@@ -238,14 +238,12 @@ The provider owns safe resource retrieval: URL validation, HTTP transport, redir
The fetch provider's resource controls:
- Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected.
- A literal address or the complete result of one hostname lookup must contain only globally reachable unicast IPv4 or IPv6 destinations. IPv6 resolution also discovers the active DNS64 prefix and rejects NAT64 addresses that translate to non-public IPv4. Loopback, private, link-local, carrier-grade NAT, multicast, reserved, transition, translation, and private IPv4-mapped IPv6 addresses are rejected.
- The request retains that validated address set in an Undici lookup callback instead of resolving the hostname again. The original hostname remains the HTTP Host and TLS SNI value, while DNS rebinding cannot replace the connection destination after validation.
- Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced.
- Abort signals propagate through network fetches and expensive decoding.
- Only same-origin redirects are followed automatically; each followed hop performs a fresh public-address lookup and pins its own connection. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and fresh public-address validation. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
- Only same-origin redirects are followed automatically; each followed hop repeats URL validation. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.)
- Requests carry an explicit product user agent rather than silently impersonating a browser.
The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy.
Requests use the process-wide dispatcher, so the installed outbound proxy policy and the system resolver decide the connection. Loopback, private-network, and cloud metadata destinations are reachable.
## Tool consumer behavior
@@ -313,17 +311,13 @@ Rejected for the first version. Those providers often return extracted or summar
Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`.
### Validate DNS and then call an ordinary fetch
### Restrict fetch to public addresses
Rejected because an ordinary fetch resolves the hostname again when it opens the connection. An attacker can return a public address during validation and a private address during the second lookup. Passing the validated answer set through the connection's lookup callback closes that rebinding interval while preserving hostname-based HTTP and TLS behavior.
### Block private-looking hostname strings without pinning resolved addresses
Rejected because hostname syntax does not establish the connection destination: an arbitrary public-looking name can resolve to loopback, a private range, or a cloud metadata address. Address classification belongs after resolution, and every address available to connection fallback must pass it.
Rejected. Resolving each hostname, rejecting any non-public answer, and pinning the connection to the validated set does not confine the model, which reaches the same networks through mounted shell tools, and it refuses every page on hosts whose DNS answers with non-public addresses, such as TUN-mode proxies with fake-IP DNS. Deployments that must keep the model away from private destinations need network confinement, a `tools/pre-execute` policy, or no fetch tool.
### Require per-call approval before public fetches
Rejected for the shipped presets. Public-address validation blocks SSRF destinations, while per-call confirmation would interrupt ordinary browsing without controlling public data egress reliably: a model can reach the same public network through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
Rejected for the shipped presets. Per-call confirmation would interrupt ordinary browsing without controlling data egress reliably: a model can reach the same networks through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`.
## Consequences
@@ -335,7 +329,7 @@ Rejected for the shipped presets. Public-address validation blocks SSRF destinat
**Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error.
**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
**Fetch is a network boundary, not just a read-only tool.** `web_fetch` reaches any destination the host can, including loopback and private-network services, a model can disclose data through a URL, and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation.
**Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance.
@@ -202,7 +202,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`
## Fetch 请求与结果 schema
`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。
`web_fetch` 的实现是一个匿名 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证或内部认证令牌。它不限制目的地址。
seam 请求比 OpenCode 的面向模型工具更小:
@@ -238,14 +238,12 @@ export type WebFetchBody =
fetch 提供方的资源控制:
- 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。
- 字面 IP 地址或 hostname 一次解析得到的完整结果只能包含全球可达的单播 IPv4 或 IPv6 目的地址。IPv6 解析还会发现当前 DNS64 前缀,并拒绝转换到非公开 IPv4 的 NAT64 地址。loopback、私有、link-local、运营商级 NAT、多播、保留、过渡、转换和映射到私有 IPv4 的 IPv6 地址都会被拒绝。
- 请求通过 Undici lookup 回调保留这一组已验证地址,不会再次解析 hostname。原 hostname 仍作为 HTTP Host 与 TLS SNI 值,而 DNS rebinding 无法在验证后替换连接目的地址。
- 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。
- Abort 信号传播到网络获取和高开销解码。
- 仅自动跟随同源重定向;每个跟随的跳转都会重新解析公开地址,并把自己的连接固定到解析结果。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用和新的公开地址校验。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 仅自动跟随同源重定向;每个跟随的跳转都会重复 URL 校验。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。)
- 请求携带显式的产品 User-Agent,而非静默伪装浏览器。
只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。
请求使用进程级 dispatcher,因此由已安装的出站代理策略与系统解析器决定连接。loopback、内网与云 metadata 目的地址都可访问。
## 工具消费方行为
@@ -313,17 +311,14 @@ fetch 提供方的资源控制:
在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。
### 验证 DNS 后调用普通 fetch
<a id="restrict-fetch-to-public-addresses"></a>
### 只允许抓取公开地址
否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。
### 只阻断看起来像私网的 hostname 字符串,不固定解析地址
否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。
否决。解析每个 hostname、拒绝任何非公开解析结果并把连接固定到已验证地址,并不能约束模型,因为模型可以通过已挂载的 shell 工具访问相同网络;同时在 DNS 返回非公开地址的主机上(例如使用 fake-IP DNS 的 TUN 模式代理),这会拒绝所有页面。需要阻止模型访问私有目的地址的部署应使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。
### 在公开抓取前要求逐次审批
已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
已交付的 preset 不采用这一方案。逐次确认会打断普通浏览,却不能可靠控制数据出站:模型可以通过已挂载的 shell 工具访问相同网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。
## 后果
@@ -335,7 +330,7 @@ fetch 提供方的资源控制:
**提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。
**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 可以访问主机能访问的任何目的地址,包括 loopback 与内网服务;模型可以通过 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。
**大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。
@@ -21,8 +21,8 @@
en: 0a4240d88cca240a
zh: 8f5f1527caf571cd
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end:
en: 7dac236d6add3c3c
zh: 2a43a923a0646bb0
en: 15fb56befd328339
zh: 0ffb10ada7e52805
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation:
en: 37e2fa1fe5cb2b95
zh: 80e513db87709cb4
@@ -33,8 +33,8 @@
en: 94ca0d09e6fd1e3a
zh: d5949a2bdec795ba
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences:
en: 6a96d9ac06c222e5
zh: b2814ab58348e831
en: 16e1f2467c559315
zh: a714ec6ff2722cf1
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered:
en: b8f036f50cff7cd8
zh: 6d623c4a1f4314b1
@@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, rereading the package manifest and bundle metadata on each reconciliation, so an entry restart publishes a changed declaration or bundle and an unchanged one publishes nothing; a bundle rebuilt without an entry restart is published through `rebuilt(id)` ([package asset decision](2026-10-19-client-plugin-package-assets.md)). The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
@@ -102,7 +102,7 @@ The current package inventory and build forms live in the [client shell layering
## Consequences
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,每次对账都重新读取包 manifest 与 bundle 元数据,因此条目重启会发布变化后的声明或 bundle,未变化时不发布;没有伴随条目重启的 bundle 重建经 `rebuilt(id)` 发布([包资源决策](2026-10-19-client-plugin-package-assets.zh.md))。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
@@ -102,7 +102,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始
## Consequences
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
@@ -9,14 +9,14 @@
en: 6379f0a2522f4c46
zh: c45ffeb5089be274
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision:
en: 92526818ff4870c2
zh: 8c54ff3f4941d5ad
en: 6435c336ab53ca0f
zh: 57070799a7061f85
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered:
en: df6c078f239a11f5
zh: 914da26836aa373b
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/consequences:
en: 6a4acbab915f1bd1
zh: 2c8ffd62ba74ff96
en: ff1898a64e5db79c
zh: 0a77d02385fb3506
/agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/testing:
en: 0d7eaa666c94a5cd
zh: 210fa098f154d137
en: fddb0be6abbd809e
zh: 88843f0f3ea60555
@@ -36,7 +36,7 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values.
**Rejection is quiet, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme is reported on stderr and skipped — the variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The environment is the only source, so no configuration surface exists where `AGENTS.md`'s fail-loud rule would apply instead.
**Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently.
**`web_fetch` uses the installed dispatcher.** The provider sends every request through the process-wide dispatcher, so a proxied hop goes through the proxy, which performs the origin's DNS, and a hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, connects directly. The provider does not restrict destination addresses ([web capability seam](2026-06-24-web-capability-seam.md#restrict-fetch-to-public-addresses)).
The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop.
@@ -54,7 +54,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the
**Every call site carries an egress test.** Each owning package's `egress.spec.ts` drives its actual transport through a fake proxy and checks the observed route. These tests cover search backends, pi-ai discovery, MCP over HTTP, child Node processes, worker threads and telemetry's direct-route exception. They detect dependency transport changes that a static call-site check cannot observe.
**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and a call site that genuinely owns its transport says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
## Alternatives considered
@@ -78,8 +78,6 @@ A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied
Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness.
`web_fetch`'s safety story now has two shapes, and its README says so: direct hops keep address validation and pinning, proxied hops delegate destination selection to a proxy the operator configured. This is the one outward-facing security promise the change alters.
Reaching Node's built-in `fetch` from a userland undici depends on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling rather than a contract — corepack#834 records it breaking — so `tests/install.spec.ts` drives a real request through a loopback proxy. A version bump that breaks the coupling fails there instead of in the field.
The suite is hermetic against the developer's own environment: every Vitest configuration runs `scripts/test-proxy-environment.ts`, which clears all eight proxy names in both casings before any test, and `install.spec.ts` restores the machine's values around each case that sets its own. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first.
@@ -88,7 +86,7 @@ The suite is hermetic against the developer's own environment: every Vitest conf
`packages/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce.
`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path.
`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts that a proxied hop reaches the proxy, a bypassed hop connects directly, and the cross-origin redirect refusal survives on the proxied path.
`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree.
@@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
**拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。
**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。
**`web_fetch` 使用已安装的 dispatcher。** 该提供方的每个请求都经过进程级 dispatcher,因此代理转发的一跳经过代理,由代理执行 origin 的 DNS;被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,则直接连接。该提供方不限制目的地址([web capability seam](2026-06-24-web-capability-seam.zh.md#restrict-fetch-to-public-addresses))。
URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。
@@ -54,7 +54,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
**每个出网点都配有出网测试。** 各所属包中的 `egress.spec.ts` 通过假代理驱动实际传输,并检查观察到的路由。这些测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、子 Node 进程、worker 线程和遥测的直连例外。它们可发现静态调用点检查无法观察到的依赖传输变化。
**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;确实自有传输的调用点用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
## Alternatives considered
@@ -78,8 +78,6 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。
`web_fetch` 的安全叙述现在有两种形态,其 README 已如实说明:直连的一跳保留地址校验与固定,代理转发的一跳把目的地选择交给运维方配置的代理。这是本次变更唯一改动的对外安全承诺。
userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。
测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。
@@ -88,7 +86,7 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
`packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。
`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。
`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言代理转发的一跳抵达代理、被绕过的一跳直接连接,且跨域重定向拒绝在代理路径上依然成立。
`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。
@@ -9,14 +9,14 @@
en: 6aa396e12264ee48
zh: 56849a18acaf830a
/agent-note-initial-draft-content-and-structured-reference-restoration/decision:
en: 9cf2afd9d5e663e9
zh: ad59b1bec6ec4666
en: 1a370f23ee32b6a4
zh: fb969ec929a6c7c9
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-model:
en: 7f33895364a9f5cc
zh: f1c79ea15f5c151e
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/classes-and-methods:
en: ca81423a57f898a0
zh: 98962cc1c6bce113
en: e57394545d9d93ff
zh: 5ab142e661750b82
/agent-note-initial-draft-content-and-structured-reference-restoration/decision/data-flow:
en: dd57e639e9328f1d
zh: 5e16cb2e3a73597c
@@ -12,7 +12,7 @@ New-task entry points need to place unsent content in the composer. A draft cont
Keep the existing `SessionInputShell`, `DraftEditorRuntime`, Conversation store, and Session reference lifetime. Plain text and structured drafts enter through the input model. Initial import finishes before `InputHub.shellFor()` returns, without React effects restoring text. Existing input objects are not restored again. React mounts the same editor and binds the existing persistence callback.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt` and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. Without clearing, existing content is preserved and only an empty draft adopts the prompt. Explicit clearing replaces the target Session's text document, without deleting attachments or changing other Sessions. Prefilling does not send, execute commands, or change `blank`.
The second parameter of `startSession(workspaceId?, options?)` contains `prompt`, `whenDraftExists`, and `clearPreviousDraft`. `prompt` accepts only a string; structured drafts remain internal to restoration and Workspace transfer. Omitting options preserves the existing flow. An empty draft adopts the prompt. When the target already has text or attachments, `whenDraftExists` decides: `keep` (the default) preserves it, `replace` replaces the target Session's text document with the prompt or clears it without one, and `append` adds the prompt as new paragraphs after the existing text and references. Neither deletes attachments or changes other Sessions. `clearPreviousDraft: true` acts as `replace` only when `whenDraftExists` is omitted. Prefilling does not send, execute commands, or change `blank`.
### Data model
@@ -41,7 +41,7 @@ The existing `dsh.conversation.<sessionId>` key remains. Readers accept both leg
There is no new draft service, storage key, general task framework, or Session creation entry point. SlotRenderer, Host Session logs, command execution, and file-discovery RPCs are unchanged. `InputActions` remains the shared input-action entry; components receive no separate content-initialization callback.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` aliases `DraftInitializationOptions`, whose fields are `prompt?: string` and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
The call parameters are `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`, `requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`, `SessionInputShell.requestDraftInitialization(options)`, and the internal `SessionInputShell.setDraft(input: string | DraftSnapshot)`. `StartSessionOptions` extends `DraftInitializationOptions`, whose fields are `prompt?: string`, `whenDraftExists?: 'replace' | 'append' | 'keep'`, and `clearPreviousDraft?: boolean`. A shallow copy captures these values before asynchronous creation, and the result addresses only the retained target binding. Initialization distinguishes applied, preserved, and submission-blocked outcomes. Refusal does not clear first; superseded navigation does not write a draft.
### Data flow
@@ -12,7 +12,7 @@ Status: implemented
保留现有 `SessionInputShell`、`DraftEditorRuntime`、Conversation store 和 Session 引用生命周期。普通文字与结构化草稿统一经过输入模型导入;初始导入在 `InputHub.shellFor()` 返回对象前完成,不由 React effect 恢复文字。已存在的输入对象不重新恢复。React 只挂载同一编辑器,并绑定现有持久化回调。
`startSession(workspaceId?, options?)` 的第二参数保留 `prompt` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。不清空时保留已有内容,空稿才采用 prompt;显式清空作用于目标 Session 的文字文档,不删除附件,也不修改其他 Session。预填不自动发送、执行命令或改变 `blank`。
`startSession(workspaceId?, options?)` 的第二参数包含 `prompt`、`whenDraftExists` 与 `clearPreviousDraft`。`prompt` 仅接受字符串;结构化草稿只用于内部恢复和工作区携带。无选项时维持原流程。空稿直接采用 prompt。目标已有文字或附件时由 `whenDraftExists` 决定:`keep`(默认)保留原稿,`replace` 用 prompt 替换目标 Session 的文字文档、无 prompt 时清空,`append` 在原有文字与引用之后另起段落追加 prompt。三者都不删除附件,也不修改其他 Session。只有未传 `whenDraftExists` 时,`clearPreviousDraft: true` 才等同 `replace`。预填不自动发送、执行命令或改变 `blank`。
### 数据模型
@@ -41,7 +41,7 @@ Status: implemented
不新增草稿服务、存储 key、通用任务框架或另一套 Session 创建入口;不改 SlotRenderer、Host Session 日志、命令执行和文件发现 RPC。`InputActions` 保留统一输入操作入口,组件不单独注入另一条内容初始化回调。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 直接引用 `DraftInitializationOptions`,字段为 `prompt?: string` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
调用参数依次为 `startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions)`、`requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions)`、`SessionInputShell.requestDraftInitialization(options)`,以及内部的 `SessionInputShell.setDraft(input: string | DraftSnapshot)`。`StartSessionOptions` 扩展 `DraftInitializationOptions`,字段为 `prompt?: string`、`whenDraftExists?: 'replace' | 'append' | 'keep'` 和 `clearPreviousDraft?: boolean`;进入异步创建前浅拷贝这些值,结果只作用于最终保留的 binding。初始化结果区分已应用、保留原稿和提交期间拒绝;拒绝不先清空,导航被后续操作取消时不写稿。
### 数据流
@@ -9,11 +9,11 @@
en: 47ba548b869be190
zh: c2e5038a56fe741c
/agent-note-asynchronous-projection-registration-and-retained-readers/decision:
en: 9e56d4bcbdc3a291
zh: bebaaab338a15e40
en: be0d17bfbd1b59d4
zh: 7a23bccdf95c21ff
/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered:
en: ee5b0cd715ed1fef
zh: 6b1478dcde21916d
en: e7c66403fab991d2
zh: 1580ec29116c7d42
/agent-note-asynchronous-projection-registration-and-retained-readers/consequences:
en: 6f0e04ef7924e2c6
zh: e3730273e323440d
en: 31ce6f02dd3bf39d
zh: bcd29b2146f689b5
@@ -10,12 +10,14 @@ Host readers coupled to registry key lookup lose access when a plugin unloads, a
## Decision
`SessionProjectionRegistry.register` returns a promise for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
`SessionProjectionRegistry.register` returns an awaitable registration for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state.
Plugins compiled against the synchronous API keep running during migration. The awaitable registration is also the original Cordis effect disposer, so calling it releases the registration and a composing effect adopts its ownership; that call is deprecated. Registry-level `stateOf(session, key)` remains a deprecated read returning `unknown`; it loses access after the final release and logs one `ctx.logger.warn` per key per registry. Registration itself does not warn, because an ignored result is indistinguishable from one awaited later. A legacy plugin may never observe the registration, so the registry attaches its own rejection handler: once the current macrotask and its microtasks finish, a rejection nobody observed through `then`, `catch`, or `finally` is logged as an error while the plugin stays active, and one whose owner is no longer loading or active is dropped. TypeScript consumers migrate their declarations; the host state map is not restored.
## Alternatives considered
**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order.
@@ -26,6 +28,12 @@ AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. Ag
**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break.
**Require every external plugin to migrate immediately.** Compiled plugins fail at runtime on the missing disposer and keyed read, although the projection keys and state fields they read are unchanged.
**Return a new wrapper around the disposer.** Cordis composition adopts effects by their returned identity. A wrapper leaves the original registration owned by the fiber, so owner disposal can release it before the composed cleanup finishes.
**Dispose the plugin after an ignored rejection.** This approximates the old failed activation, but it needs a startup observer, runs only after the plugin's services became visible, and ends in `DISPOSED` rather than a retryable `FAILED`. Logging keeps the failure visible without a second lifecycle path.
## Consequences
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown.
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. Compatibility tests and a real Loader subprocess verify the deprecated calls.
@@ -10,12 +10,14 @@ Status: implemented
## Decision
`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
`SessionProjectionRegistry.register` 返回可等待的注册结果,用于获取类型化句柄。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。
按同步 API 编译的插件在迁移期间继续运行。可等待的注册结果同时就是原有的 Cordis effect 释放函数:调用它会释放注册,组合 effect 也会接管其所有权;该调用已弃用。注册表级 `stateOf(session, key)` 作为已弃用的读取保留,返回 `unknown`;最后一次释放后它失去访问,并且每个 registry 对每个键输出一次 `ctx.logger.warn`。注册本身不产生警告,因为被忽略的结果与稍后才等待的结果无法区分。旧插件可能从不观察注册结果,因此 registry 挂上自己的拒绝处理函数:当前宏任务及其微任务结束时,没有任何调用方通过 `then`、`catch` 或 `finally` 观察的拒绝会被记录为错误,插件保持激活;owner 已不在加载或激活状态的拒绝会被丢弃。TypeScript 消费方仍需迁移声明;宿主状态映射不再恢复。
## Alternatives considered
**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。
@@ -26,6 +28,12 @@ AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`
**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。
**要求所有外部插件立即迁移。** 已编译的插件会因缺失的释放函数和按键读取在运行时失败,尽管它们读取的投影键与状态字段都没有变化。
**返回包装释放函数的新函数。** Cordis 组合按返回值身份接管 effect。包装函数会让原注册继续归 fiber 所有,因此 owner 卸载可能在组合清理结束前释放它。
**被忽略的拒绝出现后卸载插件。** 这近似于旧 API 的激活失败,但需要启动观察器,只能在插件服务已经可见后执行,并以 `DISPOSED` 而非可重试的 `FAILED` 结束。记录日志能让失败可见,而无需第二条生命周期路径。
## Consequences
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。兼容测试与真实 Loader 子进程验证这些已弃用调用。
@@ -12,8 +12,8 @@
en: 972bbe45fe8ac28e
zh: 593b851c142894e1
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
en: f44b784545c6d9a2
zh: 3eb6fcb9f348dec4
en: 913d1dceab36d4a1
zh: 049521c5127f3478
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
en: b8278678170f1507
zh: 113a51ba5cf27de1
@@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su
- `finish`: install the final room.
Content added during the batch updates the remaining movement by starting a new segment.
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation, and so does a row whose ancestor finished closing, because `hidden="until-found"` keeps that row's own size while removing it from the flow.
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
@@ -38,7 +38,7 @@ Status: implemented
- `finish`:安装最终补白。
批次中途新增的内容按分段规则更新剩余运动。
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画,祖先已收起完成的行同样如此,因为 `hidden="until-found"` 让这类行保留自身尺寸,却已不占文档流。
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-14-plugin-entry-customization.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-14-plugin-entry-customization.md
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus:
en: 2fdf5b95d5603955
zh: bfdc61e7db5f5f5b
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/problem:
en: 896876f139383cba
zh: 42a4aae7e5885780
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision:
en: a6cfc29cb9caa83c
zh: 21bbeb6a7ab20018
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/user-visible-behavior:
en: f1456281aa937626
zh: 6fef46b8d72ddd2b
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/structure-and-ownership:
en: d6a2816ca55b1c38
zh: 53217016f1595755
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/package-changes-relative-to-release-dsh-0-2-1:
en: 18ed81fee1ca7d41
zh: fc298457e54b6488
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/actions-slots-and-menu-content:
en: c87e2fdd911714b5
zh: 074426fdc7abafa9
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/decision/the-three-regions:
en: 770c6150e84742ba
zh: 1d619388c8f38646
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/alternatives-considered:
en: 79355028387c4094
zh: 5b518166e4803ebe
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/verification:
en: 9c6a9ec155bbbfaa
zh: 8bd6e7afc8c43d21
/agent-note-plugin-entry-customization-region-owned-pins-and-more-menus/consequences:
en: a2459a3316f3ee60
zh: 713b51329995fe46
@@ -0,0 +1,136 @@
# Agent Note: Plugin entry customization: region-owned pins and More menus
Status: implemented
English | [中文](2026-10-14-plugin-entry-customization.zh.md)
## Problem
The `release/dsh-0.2.1` Web app shows plugin entries in three regions: the left sidebar's panel buttons (`sidebar.panellist`), the Conversation header's View tabs (`conversation.view`), and the right sidebar guide's tool cards (guide entries from the right-sidebar tab registry). Each region shows every entry in registration order, so users cannot hide entries they rarely use, and every installed plugin makes the regions more crowded. Users also cannot browse more plugins or start creating a plugin from these positions.
The feature ships in `release/dsh-0.2.1` and is later merged back to master with the release, so it changes as little as possible relative to the release: no changes to the slot framework (`ui-slots`, `ui-renderer`), no new Cordis service, `ui-layout` unchanged, and client plugin packages share only types ([Client package export rules](../../../../packages/client/AGENTS.md)). Persisted data must be readable without migration by a later slot-level store for manual order and pin state.
## Decision
Each region keeps and persists its own list of unpinned entries and filters its own entry buttons with it. Each region draws its own More button, opens a menu with the shared `Menu` component, and renders its actions slot inside that menu. The default-loaded plugin package `ui-entry-customization` registers the same menu-content component into the three actions slots: the entry list with pin buttons, Browse other plugins, and Create your plugin. This follows the Plugins page's Add plugin menu, where the page draws the button and menu and `ui-agent-preset` adds Create plugin through `plugins.add.actions`. Entries cannot be reordered.
### User-visible behavior
- The left sidebar, the Conversation header, and the right sidebar guide each have a More button. Its menu lists the region's customizable entries. Selecting an entry name opens the entry; its pin button shows or hides the entry in the region; the bottom of the menu has Browse other plugins (opens the Plugins main list) and Create your plugin (opens a new or reused blank Session with the Creator preset and fills in the region's prompt without sending it).
- Entries appear in registration order, as in the release; they cannot be reordered.
- Default entries always show and never appear in the menu: Plugins in the left sidebar, Chat in the Conversation header, and Files in the right sidebar.
- Pin state survives page reloads and is shared by every Session.
- Hiding the selected entry does not navigate away from its content; the left-sidebar row or View tab of the selected entry stays visible, as the current item, until another entry is selected.
- The left-sidebar `<nav>` and the Conversation header tab bar keep their release visibility conditions: `<nav>` shows while any panel is registered, and the tab bar shows while more than one View is registered. Both conditions count registered entries, not entries left visible by pin state. With only Chat registered the tab bar stays hidden; with other Views registered, the tab bar and its More button stay visible even when every other View is hidden, so users can pin them again.
- While an actions slot has no registrations, for example when `ui-entry-customization` is not loaded, its region shows no More button.
### Structure and ownership
```text
ui-sidebar / ui-conversation / ui-sidebar-right (each region)
apply: the region's unpinned list (createSnapshotStore + persist) and setPinned
whether the actions slot has registrations (entriesOfSlot + subscribe)
root component: draws only fixed or pinned entries
while the actions slot has registrations, draws the region's More button and Menu:
<Menu anchor={region's More button} …>
renderSlot('<region>.actions', { entries, onOpen, onPinnedChange, onDismiss })
</Menu>
ui-entry-customization (default-loaded)
registers one menu-content component into each actions slot (id 'entry-customization')
content: non-fixed entries with pin buttons, Browse other plugins, Create your plugin; keeps no state
```
Dependency direction: the three regions do not depend on the plugin package and only add uses of `client-store` and `ui-primitives` they already depend on. `ui-entry-customization` depends on the three regions (slot types) and on `ui-plugin-manager` and `ui-workspace` (service types), and calls `ctx.pluginNavigation` and `ctx.uiWorkspace` at runtime. No package gains a new runtime value import from another package.
### Package changes relative to release/dsh-0.2.1
| Package | Change |
|---|---|
| `ui-sidebar` | Adds `sidebar.panellist.actions`; apply adds the unpinned list, `setPinned`, and the actions-slot registration state; `SidebarRoot` filters entries and draws the More button and menu at the end of `<nav>`; expanded panel rows use a 2px gap instead of 4px |
| `ui-conversation` | Adds `conversation.session.header.views.actions`; apply adds the same three parts; the Session header filters View tabs and draws the More button and menu inside the tab bar after the tablist; View tabs use a 32px gap instead of 36px, with 20px before More |
| `ui-sidebar-right` | Adds `sidebar.right.tab.guide.actions`; apply adds the same three parts; the guide filters cards and draws the More card and menu after the cards; the guide registration declares the `sidebarRight` locale namespace |
| `ui-entry-customization` (new package) | Registers the menu-content component into the three actions slots; owns the `entryCustomization` locale namespace; keeps no state |
| `ui-plugin-manager` | Adds `openList()` to `pluginNavigation`: selects the Installed tab of the Plugins main list without opening a dialog or changing the current Session |
| `ui-primitives` | Adds `minWidthFromAnchor` to `Menu` so the right-sidebar menu is never narrower than its card; adds `IconAppsAddOutlineRegular` / `IconAppsAddOutlineMedium` (`compact` for card size), shared by the left-sidebar and right-sidebar buttons |
| `@deepseek-ai/dsh-web-app` | Loads `ui-entry-customization` by default |
### Actions slots and menu content
The three actions slots are root-scoped list slots declared by their regions and rendered inside the region's More menu. Other plugins may register menu items into them; items follow their registration `order`. Each region declares the owner props in its own contract (`SidebarPanelActionsOwnerProps`, `ConversationViewActionsOwnerProps`, `SidebarRightGuideActionsOwnerProps`) with the same fields; the left sidebar's type is:
```ts
interface SidebarPanelActionsOwnerProps {
/** The region's customizable entries in registration order; fixed entries are excluded. */
readonly entries: readonly { readonly id: string; readonly label: string; readonly pinned: boolean }[]
/** Open an entry without changing its pin state. */
readonly onOpen: (id: string) => void
/** Show (true) or hide (false) an entry. */
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
```
The `ui-entry-customization` menu-content component renders those fields: one row per entry (`EntryPickerRow`, whose name and pin button are separately focusable). Selecting the name calls `onDismiss` and then `onOpen`; the pin button calls `onPinnedChange`. Browse other plugins and Create your plugin follow and call `onDismiss` before acting. Each registration receives its region (used only to choose pin copy and the Creator prompt) and the two footer actions through its registration `inject`. Browse other plugins calls `ctx.pluginNavigation.openList()`; Create your plugin calls `ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt. The call does not go through `ui-agent-preset`, so the preset chip plays no introduction animation. When no Creator preset exists, Session creation fails, Workspace shows its creation-failure notice, and no draft is created.
The region decides its More button's styling, accessible name ("More", "More conversation views", and "More tools", from the regions' `panels.more`, `session.moreViews`, and `tab.guide.more` copy), Tooltip, and the menu's placement and width. Every menu uses the `ui-primitives` `Menu` component.
### The three regions
| | Left sidebar (`ui-sidebar`) | Conversation header (`ui-conversation`) | Right sidebar guide (`ui-sidebar-right`) |
|---|---|---|---|
| Entry source | Existing `panels` (from `sidebar.panellist`) | Existing View list (from `conversation.view`; Trajectory is removed while developer tools are off) | Existing tab registry `guide()` |
| Entry id | Panel id | View id | `kind:entryId` |
| Fixed | `plugins` | `chat` | Entries of kind `files` |
| localStorage key | `dsh.slot.manual-order.sidebar.panellist.v1` | `dsh.slot.manual-order.conversation.view.v1` | `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` |
| Actions slot | `sidebar.panellist.actions` | `conversation.session.header.views.actions` | `sidebar.right.tab.guide.actions` |
| More button | Panel-button styling; shows its label when expanded and a Tooltip when collapsed | Small header button with a Tooltip | Card styling; the menu is at least as wide as the card and aligned to its end |
| `onOpen` | `selectPanel(id)` | The Session's `selectView(id)` | Finds the entry by id, then `openTab(kind, { replaceTab: true })` |
Each region defines its fixed entry in a module constant. The unpinned list is created in apply with client-store's `createSnapshotStore(…, { persist })`, is independent of Sessions, and stores `{ "unpinned": string[] }`. It reaches the region's root component through the inject `hooks.entryPins`; `setPinned` is an inject callback and ignores the fixed entry. Reads accept only an array of strings and treat any other value as an empty list, so corrupted localStorage content cannot break rendering. Ids that are no longer registered stay in `unpinned`, so an entry registered again stays hidden. Whether the actions slot has registrations reaches the root component through `hooks.entryActions`.
Every localStorage key has the form `dsh.slot.manual-order.<list slot key>.v1`, named after the list slot that holds the entries rather than after the region package: the left sidebar and the Conversation header use the existing `sidebar.panellist` and `conversation.view`. The right-sidebar guide entries come from the tab registry and have no list slot in the release, so their key uses the name reserved for one, `sidebar.right.tab.guide.list`. A later slot-level store for the manual order and pin state of a list slot reads these keys and ids directly; saved `unpinned` values need no migration, and fields added later default to empty lists when absent from saved values.
Right-sidebar ids use `kind` instead of `providerId`: when an extension takes over a kind, `providerId` changes while `kind` stays the same, so persisted pin state remains valid after the takeover.
## Alternatives considered
**Keep pin state in a Cordis service (provided by `ui-layout` or the plugin).** A region must read pin state while drawing its own buttons, and a slot only passes data from the region to the plugin, so plugin-held state needs a new public service. A `ui-layout` service would hold state unrelated to layout; a plugin-provided service would require each region to handle the service appearing and disappearing. Region-held state needs only a few lines.
**One pin service per region.** Adds three public services with different signatures, while pin state is used only by its region and that region's menu.
**Add `useSlotEntries` / `useManualOrderedListSlot` to the slot framework.** Requires changing `ui-slots` and `ui-renderer`, makes `ui-renderer` hold user preferences and read and write localStorage, and requires the right sidebar to mirror its guide entries into an internal list slot. Without reordering, each region's pin logic is a few lines, which does not justify changing the framework in a release.
**Drag reordering in the left sidebar.** Only the left sidebar would use it, so the three regions would behave differently; this version does not support reordering.
**Make the popover a Component Factory with the More button as a local slot.** The callers are the three regions and the implementation is the plugin. Declaring the Factory in the plugin package creates a dependency cycle, because the plugin's dependencies `ui-plugin-manager` and `ui-workspace` depend on the regions. Declaring it in `ui-layout` cannot name the plugin's locale namespace, so the declaration would be split across two packages and completed through declaration merging.
**Let the plugin draw the whole More control (button and menu) from region data.** The plugin would copy the three regions' button styles, because CSS Modules cannot be reused across packages, and would need layout state from each region. Button styling carries region-specific behavior and belongs to the region.
**Let the plugin render the region entries.** Left-sidebar icons and right-sidebar cards depend on their regions' child slots, and the plugin cannot reach the region's `renderSlot`; React nodes also cannot be passed as props.
**Stop applying pin state while the plugin is not loaded.** The only benefit is that entries hidden before the plugin was disabled or failed to load would still show. That case requires explicitly disabling a default-loaded plugin; the limitation is accepted and documented in the README.
**Show the tab bar based on entries left visible by pin state.** With other Views registered, hiding all of them would remove the tab bar together with its More button, and users could not pin them again.
**Publish `agentPresetNavigation`, or have `ui-agent-preset` react to `startSession` calls that carry `agentPreset` and play the introduction animation.** The first adds a service only to forward to `startSession`; the second changes the `ui-workspace` and `ui-agent-preset` interfaces, and the introduction animation is not essential to this feature.
**Name localStorage keys after region packages.** A later slot-level store keyed by list slot would have to migrate saved data.
## Verification
- Unit tests for the three regions cover: entry buttons show only fixed or pinned entries; `setPinned` ignores the fixed entry; `setPinned` writes and persists to the region's key; corrupted localStorage content reads as an empty list; ids that are no longer registered are kept; More shows while the actions slot has registrations and passes `entries` without fixed entries and the callbacks, and is absent without registrations; the Conversation header tab bar stays hidden with only Chat and stays visible after every other View is hidden; right-sidebar ids are `kind:entryId`.
- Unit tests for the menu-content component cover: opening an entry, toggling a pin, an empty list showing only the footer actions, the arguments of Browse other plugins and Create your plugin, and removal of the three registrations when the plugin is released.
- The Web e2e `entry-customization.e2e.ts` exercises the left sidebar through the real Web composition: More shows by default, hiding a panel removes its button, the panel still opens from the menu, it stays hidden after a reload, and pinning it again restores the button. Recorded-session Web goldens cover the Conversation header and right-sidebar More buttons; `changed-files-turn.e2e.ts` and `document-preview.e2e.ts` assert that the compact header has no tab bar with only Chat.
- Every changed source file has 100% per-file coverage.
## Consequences
- If a user hides entries and the plugin is later explicitly disabled or fails to load, the hidden entries stay hidden with no menu to restore them; re-enabling the plugin or clearing the region's localStorage key restores them.
- localStorage is isolated by protocol, host, and port, so pin state is not shared after changing the Web port or between Web and Desktop.
- client-store's persist does not validate restored values, so each region validates on read; the validation and the owner props type exist once per region.
- The actions slots sit inside the More menus, so other plugins can add menu items but cannot place arbitrary controls beside the entries.
- The right-sidebar key reserves `sidebar.right.tab.guide.list`; a later list slot for guide entries must use that name for saved pin state to carry over.
- When an extension takes over a kind and uses different guide entry ids, existing pin state does not match the new entries.
- With the plugin loaded by default, Web goldens that record the left navigation, the Conversation header tab bar, or the right sidebar guide include a More button.
@@ -0,0 +1,136 @@
# Agent Note: 插件入口自定义:区域自管的 Pin 与“更多”菜单
Status: implemented
[English](2026-10-14-plugin-entry-customization.md) | 中文
## 问题
`release/dsh-0.2.1` 的 Web 应用在三个区域显示插件入口:左侧栏的面板按钮(`sidebar.panellist`)、会话头部的 View 标签(`conversation.view`)和右侧栏起始页的工具卡片(右侧栏 tab 注册表的 guide 入口)。三处都按注册顺序显示全部入口,用户不能收起不常用的入口,插件越多三处越挤;用户也不能在这些位置直接浏览更多插件或开始创建插件。
该功能随 `release/dsh-0.2.1` 发布,之后随 release 合回 master,因此相对 release 的改动要尽量少:不修改 slot 框架(`ui-slots`、`ui-renderer`),不新增 Cordis 服务,`ui-layout` 保持不变,client 插件包之间只共享类型([Client 包导出规则](../../../../packages/client/AGENTS.md))。持久化的数据要能被以后在 slot 层统一保存手动顺序与 Pin 状态的实现直接沿用。
## 决策
三个区域各自保存本区域被收起入口的列表并持久化,用它过滤自己的入口按钮;各自画自己的“更多”按钮,用共用的 `Menu` 组件弹出菜单,菜单里渲染本区域的 actions slot。默认加载的插件包 `ui-entry-customization` 往三个 actions slot 注册同一个菜单内容组件:入口列表与 Pin 按钮、“浏览更多插件”和“创建你的插件”。做法与插件页的“添加插件”菜单一致:页面画按钮和菜单,`ui-agent-preset` 通过 `plugins.add.actions` 往菜单里加“创建插件”。不提供排序。
### 用户可见行为
- 左侧栏、会话头部、右侧栏起始页各有一个“更多”按钮,打开后的菜单列出该区域可自定义的入口。点入口名称打开它;点 Pin 让它在区域里显示或收起;底部有“浏览更多插件”(打开插件页主列表)和“创建你的插件”(用 Creator 预设新开或复用空白会话,填入该区域对应的提示语,不发送)。
- 入口按注册顺序显示,与 release 一致;不提供排序。
- 默认绑定的入口始终显示,不进入菜单:左侧栏的插件管理、会话头部的 Chat、右侧栏的文件。
- Pin 状态在刷新页面后保留,所有会话共用。
- 收起当前选中的入口不会切走当前内容;左侧栏行或 View 标签在选中其他入口之前保持显示,并标为当前项。
- 左侧栏 `<nav>` 与会话头部标签条的显示条件与 release 一致:`<nav>` 在有已注册面板时显示,标签条在注册的 View 多于一个时显示。条件按已注册的入口计算,不按 Pin 后可见的入口计算,因此只有 Chat 时不显示标签条;有其他 View 时即使全部收起,标签条和其中的“更多”仍然显示,用户可以重新固定。
- actions slot 没有注册项时(例如 `ui-entry-customization` 未加载),区域不显示“更多”。
### 结构与职责
```text
ui-sidebar / ui-conversation / ui-sidebar-right (each region)
apply: the region's unpinned list (createSnapshotStore + persist) and setPinned
whether the actions slot has registrations (entriesOfSlot + subscribe)
root component: draws only fixed or pinned entries
while the actions slot has registrations, draws the region's More button and Menu:
<Menu anchor={region's More button} …>
renderSlot('<region>.actions', { entries, onOpen, onPinnedChange, onDismiss })
</Menu>
ui-entry-customization (default-loaded)
registers one menu-content component into each actions slot (id 'entry-customization')
content: non-fixed entries with pin buttons, Browse other plugins, Create your plugin; keeps no state
```
依赖方向:三个区域不依赖插件包,只增加 `client-store` 与 `ui-primitives` 的既有用法;`ui-entry-customization` 依赖三个区域(slot 类型)、`ui-plugin-manager` 与 `ui-workspace`(服务类型),运行时通过 `ctx.pluginNavigation` 与 `ctx.uiWorkspace` 调用。各包之间不新增运行时值导入。
### 各包改动(相对 release/dsh-0.2.1)
| 包 | 改动 |
|---|---|
| `ui-sidebar` | 新增 `sidebar.panellist.actions`;apply 中新增 unpinned 列表、`setPinned` 和 actions slot 注册状态;`SidebarRoot` 过滤入口,并在 `<nav>` 末尾画“更多”按钮与菜单;展开态面板行间距由 4px 改为 2px |
| `ui-conversation` | 新增 `conversation.session.header.views.actions`;apply 中新增同样三项;会话头部过滤 View 标签,并在标签条内、tablist 之后画“更多”按钮与菜单;View 标签间距由 36px 改为 32px,与“更多”间隔 20px |
| `ui-sidebar-right` | 新增 `sidebar.right.tab.guide.actions`;apply 中新增同样三项;起始页过滤卡片,并在卡片之后画“更多”卡片与菜单;guide 注册声明 `sidebarRight` 文案命名空间 |
| `ui-entry-customization`(新包) | 往三个 actions slot 注册菜单内容组件;`entryCustomization` 文案命名空间;不保存状态 |
| `ui-plugin-manager` | `pluginNavigation` 增加 `openList()`:切到插件页主列表的“已安装”标签,不打开对话框,不改变当前会话 |
| `ui-primitives` | `Menu` 增加 `minWidthFromAnchor`,让右侧栏菜单不窄于卡片;新增 `IconAppsAddOutlineRegular` / `IconAppsAddOutlineMedium`(`compact` 用于卡片尺寸),供左侧栏和右侧栏的按钮共用 |
| `@deepseek-ai/dsh-web-app` | 默认加载 `ui-entry-customization` |
### actions slot 与菜单内容
三个 actions slot 都是 root 级 list slot,由各区域声明,渲染在本区域的“更多”菜单里,其他插件也可以往里注册菜单项,排列顺序按注册项的 `order`。owner props 由各区域在自己的 contract 中声明(`SidebarPanelActionsOwnerProps`、`ConversationViewActionsOwnerProps`、`SidebarRightGuideActionsOwnerProps`),三处字段相同,以左侧栏为例:
```ts
interface SidebarPanelActionsOwnerProps {
/** The region's customizable entries in registration order; fixed entries are excluded. */
readonly entries: readonly { readonly id: string; readonly label: string; readonly pinned: boolean }[]
/** Open an entry without changing its pin state. */
readonly onOpen: (id: string) => void
/** Show (true) or hide (false) an entry. */
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
readonly onDismiss: () => void
}
```
`ui-entry-customization` 的菜单内容组件按上述字段渲染:每个入口一行(`EntryPickerRow`,名称与 Pin 按钮各自可聚焦),点名称先 `onDismiss` 再 `onOpen`,点 Pin 调 `onPinnedChange`;其后是“浏览更多插件”和“创建你的插件”,先 `onDismiss` 再执行。每个注册项通过 registration `inject` 得到所属区域(只用于选择 Pin 提示与创建提示语)和两个底部操作:“浏览更多插件”调用 `ctx.pluginNavigation.openList()`;“创建你的插件”调用 `ctx.uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`,`prompt` 按区域取本地化文案。该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画;Creator 预设不存在时会话创建失败,Workspace 显示创建失败提示,不产生草稿。
“更多”按钮的样式、无障碍名称(“更多”“更多会话视图”“更多工具”,分别来自各区域的 `panels.more`、`session.moreViews`、`tab.guide.more` 文案)、Tooltip、菜单的弹出方向与宽度都由区域决定;菜单外框统一使用 `ui-primitives` 的 `Menu`。
### 三个区域
| | 左侧栏(`ui-sidebar`) | 会话头部(`ui-conversation`) | 右侧栏起始页(`ui-sidebar-right`) |
|---|---|---|---|
| 入口来源 | 现有的 `panels`(来自 `sidebar.panellist`) | 现有的 View 列表(来自 `conversation.view`,未开启开发者工具时去掉 Trajectory) | 现有的 tab 注册表 `guide()` |
| 入口 id | 面板 id | View id | `kind:入口id` |
| fixed | `plugins` | `chat` | kind 为 `files` 的入口 |
| localStorage 键 | `dsh.slot.manual-order.sidebar.panellist.v1` | `dsh.slot.manual-order.conversation.view.v1` | `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` |
| actions slot | `sidebar.panellist.actions` | `conversation.session.header.views.actions` | `sidebar.right.tab.guide.actions` |
| “更多”按钮 | 与面板按钮同样式;展开时显示文字,收起时显示 Tooltip | 头部样式的小按钮,带 Tooltip | 卡片样式,菜单不窄于卡片并右对齐 |
| `onOpen` | `selectPanel(id)` | 本会话的 `selectView(id)` | 按 id 找到入口后 `openTab(kind, { replaceTab: true })` |
每个区域用一个模块常量定义 fixed。unpinned 列表在 apply 中用 client-store 的 `createSnapshotStore(…, { persist })` 创建,与会话无关,值为 `{ "unpinned": string[] }`,经 inject 的 `hooks.entryPins` 交给区域根组件,`setPinned` 作为 inject 回调,对 fixed 入口不生效。读取时只接受字符串数组,其他值按空列表处理,避免损坏的 localStorage 内容影响渲染。`unpinned` 中已不存在的 id 保留,同一入口重新注册后保持收起。actions slot 是否有注册项经 `hooks.entryActions` 交给根组件。
localStorage 键统一为 `dsh.slot.manual-order.<list slot key>.v1`,按入口所在的 list slot 命名,不按区域包命名:左侧栏与会话头部使用现有的 `sidebar.panellist`、`conversation.view`;右侧栏的 guide 入口来自 tab 注册表,release 中还没有对应的 list slot,键名使用为它预留的 `sidebar.right.tab.guide.list`。以后在 slot 层统一保存同一 list slot 的手动顺序与 Pin 状态时,直接沿用这些键和 id,已保存的 `unpinned` 不需要迁移;新增的字段在旧值中缺失时按空列表处理。
右侧栏 id 使用 `kind` 而不是 `providerId`:扩展接管某个 kind 时 `providerId` 会改变,`kind` 不变,持久化的 Pin 状态在接管后仍然有效。
## 考虑过的替代方案
**由一个 Cordis 服务保存 Pin 状态(在 `ui-layout` 或插件中提供)。** 区域画自己的按钮时要读到 Pin 状态,而 slot 只能把数据从区域传给插件;由插件保存就必须新增公开服务。放在 `ui-layout` 会让布局服务承担与布局无关的状态;由插件提供时,区域还要处理服务时有时无。区域自己保存只需几行。
**三个区域各提供一个 Pin 服务。** 新增三个形态不同的公开服务,而 Pin 状态只被所在区域和它的菜单使用。
**在 slot 框架中提供 `useSlotEntries` / `useManualOrderedListSlot`。** 需要修改 `ui-slots` 与 `ui-renderer`,让 `ui-renderer` 持有用户偏好并读写 localStorage,右侧栏还要在内部同步成 list slot;不提供排序时,各区域的 Pin 逻辑只有几行,不值得为 release 修改框架。
**左侧栏拖动排序。** 只有左侧栏使用,三个区域的交互不一致;本版本不提供排序。
**把弹出框做成 Component Factory,“更多”按钮作为局部 slot。** 调用方是三个区域,实现方是插件:Factory 声明放在插件包会形成依赖环(插件依赖的 `ui-plugin-manager`、`ui-workspace` 依赖区域),放在 `ui-layout` 又引用不到插件的文案命名空间,只能把声明拆在两个包里用声明合并补齐。
**插件画整个“更多”(按钮与菜单),区域只提供数据。** 插件要复制三个区域的按钮样式(CSS Module 不能跨包复用),并从区域取得布局状态;按钮样式带有区域的业务特点,应留在各自的区域。
**由插件渲染区域入口。** 左侧栏图标和右侧栏卡片依赖各自的子 slot,插件拿不到区域的 `renderSlot`;ReactNode 也不能作为 props 传递。
**插件未加载时停止应用 Pin 状态。** 唯一收益是插件在用户收起入口后被禁用或加载失败时,入口仍能显示;该情形需要显式禁用默认加载的插件,接受这一限制并在 README 中说明。
**按 Pin 后可见的入口数决定标签条是否显示。** 有其他 View 时全部收起后,标签条和其中的“更多”一起消失,用户无法重新固定。
**公开 `agentPresetNavigation`,或让 `ui-agent-preset` 响应带 `agentPreset` 的 `startSession` 并播放介绍动画。** 前者只为转调 `startSession` 新增服务;后者要修改 `ui-workspace` 与 `ui-agent-preset` 的接口,介绍动画不是本功能的必要部分。
**按区域包命名 localStorage 键。** 以后在 slot 层按 list slot 统一保存时需要迁移已保存的数据。
## 验证
- 三个区域的单元测试覆盖:入口按钮只显示 fixed 或未收起的入口;对 fixed 入口调用 `setPinned` 不生效;`setPinned` 写入并持久化到对应键;localStorage 内容损坏时按空列表处理;已不存在的 id 被保留;actions slot 有注册项时显示“更多”并传入不含 fixed 入口的 `entries` 与回调,没有注册项时不显示;会话头部标签条在只有 Chat 时不显示,有其他 View 时全部收起后仍然显示;右侧栏 id 为 `kind:入口id`。
- 菜单内容组件的单元测试覆盖:入口打开、Pin 切换、空列表时只有底部操作、浏览更多插件与创建插件的调用参数,以及三个注册项在插件释放后移除。
- Web e2e `entry-customization.e2e.ts` 通过真实 Web 组合验证左侧栏:默认显示“更多”,收起后面板按钮消失,仍可从菜单打开,刷新后保持收起,重新固定后恢复。会话头部与右侧栏的“更多”由录制会话的 Web golden 覆盖;`changed-files-turn.e2e.ts` 与 `document-preview.e2e.ts` 断言只有 Chat 时紧凑头部没有标签条。
- 改动的源文件逐文件覆盖率 100%。
## 后果
- 用户收起入口后,若插件被显式禁用或加载失败,被收起的入口保持隐藏且没有菜单可以恢复;恢复方式是重新启用插件或清除对应 localStorage 键。
- localStorage 按协议、主机和端口隔离,更换 Web 端口或在 Web 与 Desktop 之间切换时 Pin 状态不共享。
- client-store 的 persist 读回时不校验格式,三个区域各自在读取处校验;校验逻辑与 owner props 类型在三处各有一份。
- actions slot 位于“更多”菜单内,其他插件只能往里加菜单项,不能在入口旁放置任意控件。
- 右侧栏的键名预留了 `sidebar.right.tab.guide.list`;以后为 guide 入口新增 list slot 时必须使用这个名称,已保存的 Pin 状态才能沿用。
- 扩展接管某个 kind 且使用不同的 guide 入口 id 时,原有 Pin 状态无法对应到新入口。
- 插件默认加载后,录有左侧导航、会话头部标签条或右侧栏起始页的 Web golden 都会出现“更多”按钮。
@@ -9,8 +9,8 @@
en: 5071232d9743096c
zh: b553a626538b3466
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
en: 71f1c18f886ff1f8
zh: c6b40e5f763323ad
en: 99060769ee22b255
zh: 5616d446527a5551
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
en: acc465cfe02b624c
zh: db1d06574f647130
@@ -29,7 +29,7 @@ The Web Session Remote adopts the controller first. `session/prompt` resolves or
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
The Web client shows a message only after the Host accepts it. While the Host decides, the composer is in a `sending` phase: Send shows progress and another send waits, but the editor stays editable for the next message and a running Turn keeps its Stop. When `session/prompt` resolves, the sent text and attachments leave the composer while text added after them stays, and the Session publishes the message's echo and leaves the blank New Session layout. A refusal or failure leaves the draft as it is; the composer no longer clears on Enter and restores failed drafts.
The Web client shows a message only after the Host accepts it. While the Host decides, the composer is in a `sending` phase: Send shows progress and another send waits, but the editor stays editable for the next message and a running Turn keeps its Stop. Once the Host has accepted the message, as the `session/prompt` reply or an Inbox row carrying its request id shows, whichever reaches the client first, the Session publishes the message's echo and leaves the blank New Session layout, and in the same update the sent text and attachments leave the composer while text added after them stays. A refusal or failure leaves the draft as it is; the composer no longer clears on Enter and restores failed drafts.
## Alternatives considered
@@ -29,7 +29,7 @@ Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
Web 客户端只在 Host 接收消息后才显示它。Host 做决定期间 composer 处于 `sending` 阶段:Send 显示发送中,下一次发送需要等待,但编辑器保持可编辑,可以接着写下一条消息,运行中的轮次也保留 Stop。`session/prompt` 返回后,已发送的文字和附件离开 composer,期间在其后追加的文字保留,Session 公开该消息的回显并离开空白的新会话布局。被拒绝或失败时草稿保持当时的样子;composer 不再在 Enter 时清空并回填失败草稿。
Web 客户端只在 Host 接收消息后才显示它。Host 做决定期间 composer 处于 `sending` 阶段:Send 显示发送中,下一次发送需要等待,但编辑器保持可编辑,可以接着写下一条消息,运行中的轮次也保留 Stop。Host 接收消息后,Session 公开该消息的回显并离开空白的新会话布局,已发送的文字和附件也在同一次更新中离开 composer,期间在其后追加的文字保留;`session/prompt` 的返回和带有该请求 id 的 Inbox 行都能表明已接收,以先到达客户端的为准。被拒绝或失败时草稿保持当时的样子;composer 不再在 Enter 时清空并回填失败草稿。
## 备选方案
@@ -0,0 +1,40 @@
# Bilingual-pair consistency record for 2026-10-19-client-plugin-package-assets.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-client-plugin-package-assets.md
/agent-note-client-plugin-package-assets-served-by-url:
en: 5505b948511a2d52
zh: dae985d67e5c5935
/agent-note-client-plugin-package-assets-served-by-url/problem:
en: 159de908b09780eb
zh: 8d15ca2f15b8846a
/agent-note-client-plugin-package-assets-served-by-url/decision:
en: 8ffcd355ec5e8271
zh: 2932530bf783e3f7
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
en: 704267953c123629
zh: 860997024ad7825e
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
en: 485013b2b6778fa2
zh: 7e5cb238bb2b8585
/agent-note-client-plugin-package-assets-served-by-url/decision/host-route:
en: 151b7300f5b27a9f
zh: fae9adff942c2ce8
/agent-note-client-plugin-package-assets-served-by-url/decision/webworker-preview:
en: db1c7b2a0b916804
zh: 0edf4123667d8e2e
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
en: eedff52d04763c50
zh: 203bfcf81242b8fe
/agent-note-client-plugin-package-assets-served-by-url/decision/gates-and-migration:
en: 1e4d83d0de2d47c9
zh: dc2c2876c73d3b1c
/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered:
en: 98960e10634ee536
zh: f0fdb0b0cdf9511c
/agent-note-client-plugin-package-assets-served-by-url/consequences:
en: 898262f39c0243ae
zh: e30f2161f5b669fe
/agent-note-client-plugin-package-assets-served-by-url/testing:
en: 8b6ce91df7c4c3a5
zh: b149b71cef5be761
@@ -0,0 +1,92 @@
# Agent Note: Client plugin package assets served by URL
Status: implemented
English | [中文](2026-10-19-client-plugin-package-assets.zh.md)
## Problem
The browser half of a dynamic Client plugin is a lazy-CJS factory built by the [client build preset](../../../../packages/client/tsdown.client.ts) and loaded through the `/plugins` route of the [Client module system](../../../../packages/client/modules/README.md). That route served only `client.js` and package-local chunks, so an image that plugin code referenced had to be embedded in the bundle as a base64 data URL, and an image used by a stylesheet needed a package-level build plugin that rewrote it into the inlined style.
The embedded bytes were downloaded and parsed with the JavaScript whether or not the image was shown, and base64 adds a third to their size: the onboarding illustrations made the ui-settings-account `client.js` about 3.4 MB. ui-chat carried its own [tsdown plugin](../../../../packages/client/ui-chat/tsdown.config.ts) for one mask image.
## Decision
A Client plugin package keeps raw images under `src/client/assets/`. The shared preset emits each referenced image under `lib/assets/` and turns the reference into a synchronous `require.asset(<document-relative URL>)` call on the bundle's module loader; the package publishes the emitted files with `"exports": { "./assets/*": "./lib/assets/*" }` and `lib/assets` in `files`; the client-modules Host serves them at `/plugins/<package>/assets/<emitted path>`.
### References and file names
Client code imports an image (`import art from './assets/a.webp'`), and the package's own stylesheets reference one with a relative `url()`. Both compile to a `require.asset(reference)` call whose argument is the same reference; a stylesheet becomes string literals joined with those calls, which run when its injected text is built. The module system answers each call through its `resolveAsset` option, which returns the reference unchanged unless a carrier supplies one. The emitted file name inserts the content revision, the first 12 hexadecimal digits of the SHA-1 digest of the bytes, before the extension, so one URL always names one content:
```text
src/client/assets/running-whale@2x.png
lib/assets/running-whale@2x-06738fa29b2f.png
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
```
Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`, and the package name is emitted verbatim. The URL is relative to the shell document, like every app-owned route, so it resolves under any mount and from injected `<style>` text. Components need no hook, injected value, or service: the URL is a module constant computed when the module first runs.
The supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) in client-modules owns the media-type table, the export key and target, the revision, the emitted name, and the URL, and the preset, the route, and the gates read them from there. The shared Client type root [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts), registered in [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json), declares each extension as a module with a string default export; a test keeps it equal to the table.
### Build
The `dsh-package-asset` plugin of the dynamic browser face resolves an import against its source location and fails the build when the file's real path is outside the package's `src/client/assets/` (which also rejects another package's or a dependency's image), when `exports["./assets/*"]` is not exactly `./lib/assets/*`, when the extension is unsupported, when a path segment is `node_modules` or contains `%`, `#`, `?`, `\`, or NUL, which the Host route cannot serve, or when the extension is not lowercase, as the import types are. It emits each file once per build and registers it as a watch input. The three CSS pipelines (CSS Modules, global, and `?inline`) rewrite relative `url()` values through a lightningcss visitor in stylesheets inside the package's own `src/`; `data:`, fragment, scheme, `/`-prefixed, and empty URLs stay unchanged, a relative file reference with a fragment or query fails the build, and dependency stylesheets are not rewritten. lightningcss does not call its `Url` visitor inside `image-set()`, so an `Image` visitor rewrites those options with the same rule. The rewrite writes an identifier placeholder that lightningcss never escapes, so a file name with parentheses or quotes still becomes its resolver call. The Node Client face of a dual-face package rejects asset imports. The browser face is the only writer of `lib/assets/`: a one-shot build deletes files it did not emit, and a watch rebuild keeps them, because the Host still serves the previous bundle generation and pages running it request those names.
### Host route
`bundleResource` matches `/plugins/<package>/assets/<path>` before the bundle tables, and such a path is answered by the asset route alone. For an active client row, the path is percent-decoded once and `createRequire(<package manifest>).resolve('<package>/assets/<path>')` applies the package's own exports, so unexported paths, `.` and `..` segments, missing files, and directories do not resolve. The media type comes from the resolved file's extension, because Node reads `#` and `?` in the subpath as URL syntax. Any resolution failure, an inactive package, and an unsupported type answer 404; the query selects nothing.
A served asset streams from disk on every request; the Host keeps neither its bytes nor a hash, and a request the browser cancels mid-stream ends without a warning. It carries the media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag` equal to the requested path, `x-content-type-options: nosniff`, and, for SVG, `content-security-policy: script-src 'none'`. An `If-None-Match` equal to the tag answers 304 without opening the file; `HEAD` answers without a body. `fetchBundle()` returns the same responses with a streamed body that closes the file when cancelled.
### WebWorker preview
The preview page runs its Host in a Worker, and a page request for a reference never reaches it. Before any Client bundle runs, the page asks the Worker for `GET /__assets__`, answered by tunnel glue like `GET /__boot__`: an uncompressed tar of every file under `lib/assets/` of each image package that declares `"./assets/*": "./lib/assets/*"`, named by its unencoded `plugins/<package>/assets/<path>`, which fits the ustar name limits the base image already met. The page encodes each entry name into its reference and turns the entry into a Blob URL with the media type of its extension, and its `resolveAsset` returns that URL, or the reference when the image holds no such file. The base image already carries these files, so no network request is added; each boot still walks the image's packages, archives every asset, and copies each entry into a Blob, enabled or not.
### Changes and package manifests
A rebuilt image produces a new file name in a rebuilt `client.js`, which reaches the browser through the existing bundle HMR; assets have no channel of their own. Client-modules reads a package manifest and the bundle metadata on every reconciliation, that is, on each Loader entry construction and disposal, instead of caching the manifest until restart, so an entry that restarts after a reinstall, upgrade, or move publishes its current declaration, client export, and manifest path; a reconciliation that finds the same manifest and bundle metadata leaves the published graph unchanged and reads no bundle bytes.
### Gates and migration
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) requires `lib/assets` in `files` when the export is declared. [`verify-client-packages`](../../../../scripts/verify-client-packages.ts) requires the exact export target, allows the export only on dynamic Web Client packages, and keeps the export and the package's references (imports, and `url()` in the stylesheets its Client sources import) consistent in both directions. [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) treats `./assets/*` as a non-code export. ui-settings-account and ui-chat moved to package assets, and ui-chat's tsdown plugin is gone.
Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, and workers), plugin metadata icons, detail-document images, and static-linked packages keep their own delivery.
## Alternatives considered
**Keep data URLs.** No route is needed, but the bytes travel and parse with the JavaScript, and every package that styles with an image writes its own build plugin.
**A plain URL string with no runtime helper.** The served Web app needs nothing more, but the WebWorker preview page cannot request the URL, and changing it afterwards means copying the files to a static deployment or rewriting strings found in bundle text. A static copy is a build-time snapshot that bypasses the Host's rules and misses packages installed at runtime; finding strings in bundle text guesses at code. A synchronous call keeps the build output explicit and lets each carrier decide the URL.
**A Client Cordis service such as `ctx.assets.url(path)`.** Current consumers reference fixed files; a runtime lookup also cannot know the content revision without publishing a per-package asset list.
**Raw files in a package-root `assets/` without the build.** Authors could not preprocess them, so naming and format conversion would stay manual.
**The revision in a `?rev=` query, verified by the Host.** The Host must read a whole file to decide between 200 and 404, so a first request reads it twice and the Host must cache hashes by file metadata; a revision in the file name lets the Host only resolve and stream.
**The `client.js` revision as the asset revision.** That revision comes from the written bundle's metadata after the build, too late to appear in the bundle.
**Images in CSS only through JavaScript style objects.** Masks, backgrounds, and `@supports` fallbacks would move into components, while the URL is known at build time and the same rules rewrite `url()`.
**One plugin file protocol shared with detail documents.** Detail images read any package version from local, npm, or Git sources, need authentication because they trigger Host network access, and cache by source. Joining them would move authentication, source providers, and cache policy into client-modules; the Plugin Manager and Plugin Market detail routes stay separate until another consumer needs files of other packages.
**Manifests from Plugin Manager, or a manifest cache with change checks.** Plugin Manager lists bundles, not every client row, and resolves from the profile installation rather than the Loader resolution of the running entry. The failure was timing: a cache that outlived entry restarts. Rereading on each reconciliation and skipping unchanged results removes the cache instead of adding validation to it.
## Consequences
- The ui-settings-account `client.js` is 214 KB, and the account and ui-chat bundles contain no image data URLs; an image costs one same-origin request when shown and may appear after layout.
- `require.asset` belongs to the bundle factory contract beside `require.async`; a package built outside this repository must emit the call. The WebWorker preview creates a Blob URL for every package asset of its image at boot, enabled or not, and keeps them for the page's lifetime.
- The Desktop forwarder rewrites `/plugins/` cache headers to `no-store`, so Desktop reads an asset again on each display.
- The Host does not compare file contents with the revision in the name: a file under `lib/assets/` edited in place keeps its immutable cache entries. Only the build writes that directory.
- `require.resolve` runs synchronously on each request; a local measurement took about 43 µs for an existing file and 170 µs for a missing one. It reads exports through Node's package configuration cache, so an `./assets/*` export edited in place takes effect after boot HMR expires that package or the Host restarts. The route does not check the resolved file's real path, so a symbolic link under `lib/assets/` that leads outside the package is served.
- A package outside this repository must reproduce the emitted-name rule and the `require.asset` call; the Client module system README records them.
- A development session leaves earlier asset files under `lib/assets/` until the next one-shot build removes them; that pruning assumes the browser face is the directory's only writer.
- An entry restart reads its manifest and stats its bundle; only a changed result reads the bundle and broadcasts a graph frame (about 30 KB for 69 rows). Editing only `package.json`, without an entry restart, still takes effect at the next entry or Host restart.
## Testing
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) composes the real Web server and compares every answer with `fetchBundle()`: headers including the SVG policy, 304 without opening the file, `HEAD`, 405, multi-chunk streaming, stream closing on cancel, a browser cancellation that logs nothing, and 404 for excluded subpaths, missing exports and files, `..`, `%2e%2e`, encoded `..`, an empty path, malformed escapes, decoded `%` and `#`, unsupported types, directories, and inactive packages.
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) covers media types, the revision, emitted names, and references; [client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) keeps the type root equal to the table.
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) covers emitted names, resolver calls in scripts and stylesheet text, their CommonJS rendering in a complete build, preserved `url()` forms, each build rejection, the Node face guard, one-shot pruning, and watch rebuilds that keep earlier files; [loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) covers the default and carrier resolvers.
- The gate specs reject each invalid manifest or reference form, and [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) covers declarations, moved exports, and added or removed client declarations after an entry restart, an unchanged restart that publishes nothing and reads no bundle, and a chunk of a package named `@scope/assets`.
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) and [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) assert the package-asset URLs and decode the images in a browser; [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) decodes the ui-chat mask the WebWorker preview resolves to a Blob URL, and the runtime's [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) covers the archive and resolver.
@@ -0,0 +1,92 @@
# Agent Note: Client 插件包资源经 URL 提供
Status: implemented
[English](2026-10-19-client-plugin-package-assets.md) | 中文
## Problem
动态 Client 插件的浏览器半侧由 [client 打包预设](../../../../packages/client/tsdown.client.ts) 打成 lazy-CJS factory,经 [Client 模块系统](../../../../packages/client/modules/README.zh.md) 的 `/plugins` 路由加载。这条路由过去只提供 `client.js` 和包内分片,所以插件代码引用的图片只能以 base64 data URL 打进 bundle,样式里用到的图片则要靠包级构建插件替换成内联样式。
不管图片显不显示,这些字节都随 JavaScript 一起下载和解析,base64 还会让体积大三分之一:引导插图使 ui-settings-account 的 `client.js` 达到约 3.4 MB。ui-chat 还为一张遮罩图维护了自己的 [tsdown 插件](../../../../packages/client/ui-chat/tsdown.config.ts)。
## Decision
Client 插件包把原始图片放在 `src/client/assets/`。共享预设把每个被引用的图片产出到 `lib/assets/`,并把引用改成对 bundle 模块加载器的同步调用 `require.asset(<文档相对 URL>)`;包通过 `"exports": { "./assets/*": "./lib/assets/*" }` 和 `files` 里的 `lib/assets` 发布这些产物;client-modules 的 Host 在 `/plugins/<包名>/assets/<产出路径>` 提供它们。
### 引用与文件名
Client 代码用 import 引用图片(`import art from './assets/a.webp'`),包自己的样式表用相对 `url()` 引用,两者都编译成参数为同一个引用的 `require.asset(reference)` 调用;样式表变成字符串字面量与这些调用的拼接,在构建注入文本时执行。模块系统通过 `resolveAsset` 选项回答每次调用;除非载体提供了自己的实现,否则原样返回引用。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
```text
src/client/assets/running-whale@2x.png
lib/assets/running-whale@2x-06738fa29b2f.png
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
```
路径逐段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service:URL 就是模块首次运行时算出的常量。
支持的扩展名是 `.svg`、`.png`、`.webp`、`.jpg` 和 `.jpeg`。client-modules 的 [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) 统一定义类型表、导出键与目标、revision、产出文件名和 URL,预设、路由和门禁都从这里读取。共享的 Client 类型根 [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts) 在 [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
### 构建
动态浏览器 face 的 `dsh-package-asset` 插件按源码位置解析 import。以下情况构建失败:文件真实路径不在本包 `src/client/assets/` 内(他包或依赖里的图片也因此被拒绝);`exports["./assets/*"]` 不是恰好 `./lib/assets/*`;扩展名不受支持;路径段为 `node_modules` 或含 Host 路由无法提供的 `%`、`#`、`?`、`\` 或 NUL;扩展名不是小写(导入类型只声明小写扩展名)。每个文件每次构建只产出一次,并登记为监听输入。三条 CSS 管线(CSS Modules、全局样式、`?inline`)对本包 `src/` 内的样式表,经 lightningcss visitor 改写相对 `url()`;`data:`、片段、带协议、以 `/` 开头和空的 URL 保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包的样式表不改写。lightningcss 不会在 `image-set()` 内部调用 `Url` visitor,因此由 `Image` visitor 按同样的规则改写这些选项。改写时先写入 lightningcss 从不转义的标识符占位,因此含括号或引号的文件名仍会变成对应的解析调用。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
### Host 路由
`bundleResource` 先于 bundle 表匹配 `/plugins/<包名>/assets/<路径>`,这类路径只由资源路由回答。对启用中的 client 行,路径解码一次后,`createRequire(<包的 manifest>).resolve('<包名>/assets/<路径>')` 套用包自己的 exports,未导出路径、`.` 和 `..` 段、不存在的文件和目录都解析不出来。媒体类型按解析出的文件扩展名判断,因为 Node 会把子路径中的 `#` 和 `?` 当作 URL 语法。任何解析失败、未启用的包和不支持的类型都返回 404;查询串不参与选择。
被提供的资源每次请求都从磁盘流式读取,Host 既不保存字节也不保存 hash;浏览器中途取消的请求直接结束,不记 warning。响应带媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、等于请求路径的 `etag`、`x-content-type-options: nosniff`;SVG 另带 `content-security-policy: script-src 'none'`。`If-None-Match` 等于该 tag 时返回 304,且不打开文件;`HEAD` 不带 body。`fetchBundle()` 返回同样的响应,body 为流,取消时关闭文件。
### WebWorker 预览
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以未编码的 `plugins/<包名>/assets/<路径>` 命名,基础镜像已满足同样的 ustar 名称限制。页面把每个条目名编码为引用,并按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以不增加网络请求;但每次启动仍会遍历镜像中的包、打包全部资源,并把每个条目复制成 Blob,不论包是否启用。
### 变化与包 manifest
图片重建后,重建的 `client.js` 带着新文件名,经现有的 bundle HMR 到达浏览器;资源没有单独的通道。client-modules 每次对账(即每次 Loader 条目构造和销毁)都读取包 manifest 和 bundle 元数据,不再把 manifest 缓存到重启,所以重装、升级或移动后重启的条目会发布当前的声明、client 导出和 manifest 路径;读到相同 manifest 和 bundle 元数据的对账不改动已发布的启动图,也不读取 bundle 字节。
### 门禁与迁移
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) 要求声明导出时 `files` 含 `lib/assets`。[`verify-client-packages`](../../../../scripts/verify-client-packages.ts) 要求导出目标精确匹配,只允许动态 Web Client 包声明该导出,并要求导出与包内引用(import,以及其 Client 源码所 import 的样式表中的 `url()`)双向一致。[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 把 `./assets/*` 视为非代码导出。ui-settings-account 和 ui-chat 已迁移到包资源,ui-chat 的 tsdown 插件已删除。
三方库自己加载的资源(PDF.js 的字体、cmap、wasm 和 worker)、插件元数据图标、详情文档图片,以及 static-linked 包,保留各自的交付方式。
## Alternatives considered
**保留 data URL。** 不需要路由,但字节随 JavaScript 一起传输和解析,每个在样式里用图片的包都要自写构建插件。
**不带运行时 helper 的纯 URL 字符串。** 普通 Web 应用不需要更多,但 WebWorker 预览页无法请求这个 URL,事后改写只能把文件复制到静态部署,或在 bundle 文本里查找字符串替换。静态复制是构建时的快照,绕过了 Host 的规则,也覆盖不到运行时安装的包;在 bundle 文本里找字符串是在猜代码。同步调用让构建产物保持显式,并由各个载体决定 URL。
**Client Cordis service,例如 `ctx.assets.url(path)`。** 当前使用方都引用固定文件;运行时查找也拿不到内容 revision,除非另外发布每个包的资源清单。
**原始文件直接放在包根 `assets/`,不经构建。** 作者无法做预处理,命名和格式转换只能手工维护。
**revision 放在 `?rev=` 查询串,由 Host 校验。** Host 必须读完整个文件才能决定回 200 还是 404,首次请求要读两遍,还要按文件元数据缓存 hash;revision 写进文件名后,Host 只需解析路径并流式发送。
**用 `client.js` 的 revision 作为资源 revision。** 这个 revision 在构建写出 bundle 后才由文件元数据得出,来不及写进 bundle。
**CSS 只能经 JavaScript style 对象引用图片。** 遮罩、背景和 `@supports` 降级会被挪进组件,而 URL 在构建时就能确定,同一套规则也能改写 `url()`。
**与详情文档共用一套插件文件协议。** 详情图片要从本地、npm 或 Git 读取任意包版本,会触发 Host 网络访问因而需要鉴权,缓存方式也随来源而定。合并会把鉴权、来源提供方和缓存策略搬进 client-modules;在出现需要读取其他包文件的新使用方之前,Plugin Manager 和 Plugin Market 的详情路由保持独立。
**从 Plugin Manager 获取 manifest,或给 manifest 缓存加变化检查。** Plugin Manager 只列出 bundle,不覆盖所有 client 行,而且从 profile 安装目录解析,而不是用运行中条目的 Loader 解析。问题出在时机:缓存活得比条目重启更久。每次对账重新读取并跳过未变化的结果,是删掉缓存,而不是给缓存加校验。
## Consequences
- ui-settings-account 的 `client.js` 为 214 KB,账户和 ui-chat 的 bundle 都不再含图片 data URL;图片显示时多一次同源请求,可能晚于布局出现。
- `require.asset` 和 `require.async` 一样属于 bundle factory 契约;仓库外构建的包必须输出这个调用。WebWorker 预览在启动时为镜像里所有包资源创建 Blob URL,不论包是否启用,并在页面生命周期内保留。
- Desktop 转发层把 `/plugins/` 的缓存头改写为 `no-store`,Desktop 每次显示都会重新读取资源。
- Host 不比对文件内容与文件名中的 revision:原地修改 `lib/assets/` 下的文件会保留它的 immutable 缓存。这个目录只由构建写入。
- `require.resolve` 在每次请求时同步执行;本机实测文件存在约 43 µs,不存在约 170 µs。它经 Node 的包配置缓存读取 exports,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包失效或 Host 重启后才生效。路由不检查解析结果的真实路径,所以 `lib/assets/` 下指向包外的符号链接会被提供。
- 仓库外的包必须复刻产出文件名规则和 `require.asset` 调用;Client 模块系统 README 记录了它们。
- 开发期间 `lib/assets/` 会留下旧的资源文件,直到下一次一次性构建把它们清掉;这种清理的前提是浏览器 face 是该目录的唯一写入方。
- 条目重启时会读取它的 manifest 并 stat 它的 bundle;只有结果变化时才读取 bundle 并广播启动图帧(69 行时约 30 KB)。只改 `package.json` 而条目没有重启时,仍要到下次条目重启或 Host 重启才生效。
## Testing
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) 组合真实 Web server,并将每个响应与 `fetchBundle()` 比对:包括 SVG 策略在内的响应头、不打开文件的 304、`HEAD`、405、多分片流式发送、取消时关闭流、浏览器取消请求时不记日志,以及以下情况的 404:被排除的子路径、缺少导出或文件、`..`、`%2e%2e`、编码后的 `..`、空路径、错误的转义、解码后的 `%` 和 `#`、不支持的类型、目录和未启用的包。
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) 覆盖媒体类型、revision、产出文件名和引用;[client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) 保证类型根与类型表一致。
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) 覆盖产出文件名、脚本和样式表文本中的解析调用、完整构建中它们的 CommonJS 渲染、保留的 `url()` 形式、每种构建拒绝、Node face 守卫、一次性构建的清理,以及 watch 重建保留旧文件;[loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) 覆盖默认解析和载体解析。
- 各门禁的 spec 拒绝每种无效的 manifest 或引用形式;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 覆盖条目重启后的声明更新、导出移动、client 声明的增减、未变化的重启既不发布也不读取 bundle,以及名为 `@scope/assets` 的包的分片。
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) 和 [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) 断言包资源 URL,并在浏览器中解码这些图片;[preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 解码 WebWorker 预览解析为 Blob URL 的 ui-chat 遮罩图,运行时的 [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) 覆盖归档和解析。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-20-new-session-blank-reuse-by-preset.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-new-session-blank-reuse-by-preset.md
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset:
en: ff1fe8bacfb05cee
zh: b03fa775407f8aa2
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/problem:
en: 5e6529a1ff3c1af5
zh: 7d08e27fd9e4ccac
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/decision:
en: a06d2e45382320f0
zh: 3eb09fc03c69eced
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/alternatives-considered:
en: 895f9dedbb6d7abf
zh: bb882bd3cc962ed3
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/verification:
en: bcfd62fdd75b53c4
zh: 040ca441c67030c2
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/consequences:
en: 88d1152b67ab392d
zh: 8c95b9fa7c1b2a04
/agent-note-new-session-reuses-a-blank-session-matching-a-requested-preset/deferred-work:
en: a7a8e49adae7b4fc
zh: a835f0e39b981fc6
@@ -0,0 +1,51 @@
# Agent Note: New Session reuses a blank Session, matching a requested preset
Status: implemented
English | [中文](2026-10-20-new-session-blank-reuse-by-preset.zh.md)
## Problem
In 0.2.0-rc.2, New Session (the sidebar button, the Workspace "+" button, and the shortcut, all calling `uiWorkspace.startSession` without options) reused the Workspace's first blank Session. PR #5775, first released in `dsh-v0.2.1-alpha.2`, changed it to create a fresh Session and Agent on every call, so that New Session composes the current preset definition after the user changes one instead of keeping a reused blank's earlier composition ([upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md)).
A blank Session that is not current is hidden from the Session tree, and New Session is the only way back to it. After that change, a user who types a draft into a blank Session, switches to another Session, and clicks New Session lands in another empty Session; the original draft stays in localStorage under the old Session id, but no control returns to it. 0.2.1-rc.2 therefore loses New Session's unsent drafts.
The Creator entries (`startSession(…, { agentPreset: 'cordis', prompt, clearPreviousDraft: false })`) also reuse the first blank Session and switch it to Creator: an existing ordinary draft stays under Creator mode and the Creator prompt is not filled in, and New Session can likewise take over a Creator blank.
## Decision
Only the Client (`ui-workspace`) changes; the Host is unchanged:
- `startSession` without options takes the reuse path again and follows the same rule as `openWorkspace`, `connectWorkspace`, and `startSession` with options.
- `createInWorkspace` names the first unstarted, unarchived blank Session that is still a Workspace member. A request that names a preset skips a blank whose listed preset (`projectionValues.agentPreset`) is a different one, while a blank with no listed preset remains eligible; a request without a preset may name any blank, which the Host switches to its default preset. The Host creates a Session when no blank is eligible.
As a result, New Session returns to the original blank Session with its unsent draft, and preset flows such as the Creator entries do not take over a blank that lists another preset, so an ordinary draft is not moved under Creator. No persisted state is added, and the Client does not read the preset roster.
## Alternatives considered
**Revert the PR #5775 change completely, reusing the first blank for every request.** Drafts come back, but preset flows such as the Creator entries take over the blank holding the user's ordinary draft, switch it to Creator, keep the draft there, and cannot fill in their prompt.
**Keep creating a fresh Session and move the abandoned blank's draft into it.** This needs rules for which blank to move from when several exist, how to treat attachments, and whether to archive the old Session, which is more change and state than reusing blanks.
**Let a request without a preset reuse only a blank of the Host default preset.** The Client does not know the default preset and would have to request the `agentPresets.list()` roster.
**Record a persisted source-to-blank-Session map in the Client.** It duplicates the preset each blank already records and must handle stale entries and per-origin isolation.
**Clear the existing draft in place when the preset changes (`clearPreviousDraft: true`).** It deletes a draft the user was writing in the other mode.
**Also change the Host to always call `presets.select` when reusing a loaded blank.** It fully keeps #5775's goal but is outside this Client-only fix; see Deferred work.
## Verification
- `ui-workspace` unit tests: New Session without options returns to the existing blank; a request without a preset reuses any blank, a request with a preset reuses only a blank listing the same preset, and a new Session is created when none matches. `navigation.ts` has 100% per-file coverage.
- Regression localization: the same unit probe on the PR #5775 commit that made this change and on its parent, plus a browser reproduction on builds of both, confirmed that the draft loss comes from that change.
## Consequences
- New Session no longer guarantees the current preset definition: a reused blank whose Agent is still loaded in the Host with the same preset id keeps its creation-time composition; a blank resumed after a Host restart composes the current definition.
- New Session may reuse a blank left by a preset flow such as the Creator entries; the Host switches it back to the default preset and its draft stays.
- The [new-session-blank-reuse](../../../../docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.md) upgrade guide records the change relative to 0.2.1-rc.2.
## Deferred work
- Host `reuseLive` (`packages/api/session-controller/src/agent.ts`) returns the loaded Agent unchanged when the preset id matches and does not call `presets.select`. `select` decides whether a definition was registered again by comparing `Generation` objects: when they are the same it does nothing; when they differ it recomposes under the same Session id with the current definition and records `agent-preset/selected`; on a started Session it throws `agent-preset/locked`, and the caller creates a new Session instead. Removing the `target === current` early return and always calling `select` keeps drafts while meeting #5775's goal that New Session composes the current definition; it needs a Host test, and updates to the `reuseLive` JSDoc and to the upgrade guide's statement that the current preset definition is no longer guaranteed.
@@ -0,0 +1,51 @@
# Agent Note: 新会话复用空白会话,指定预设时按预设匹配
Status: implemented
[English](2026-10-20-new-session-blank-reuse-by-preset.md) | 中文
## 问题
0.2.0-rc.2 中,新会话(左侧栏按钮、工作区“+”和快捷键,均调用不带选项的 `uiWorkspace.startSession`)复用工作区内第一个空白会话。PR #5775(首次发布于 `dsh-v0.2.1-alpha.2`)改为每次新建 Session 与 Agent,目的是在用户修改预设定义后,新会话按当前定义装配,而不是沿用复用空白会话的旧装配([升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md))。
空白会话不是当前会话时不在 Session 树中显示,返回它的唯一入口是新会话。改为每次新建后,用户在空白会话中写下草稿、切到其他会话再点新会话,会进入另一个空的 Session;原草稿仍按会话 id 保存在 localStorage,但没有界面入口可以回到它。0.2.1-rc.2 因此丢失新会话的未发送草稿。
此外,创造模式入口(`startSession(…, { agentPreset: 'cordis', prompt, clearPreviousDraft: false })`)复用第一个空白会话并把它切换到 Creator:已有普通草稿时,草稿留在 Creator 模式下,Creator 提示语不会填入;反之新会话也可能接管 Creator 的空白会话。
## 决策
只修改前端(`ui-workspace`),Host 不变:
- 不带选项的 `startSession` 重新走复用分支,与 `openWorkspace`、`connectWorkspace` 和带选项的 `startSession` 使用同一规则。
- `createInWorkspace` 在未开始、未归档、仍属于该工作区的空白会话中选第一个。请求指明预设时,跳过列出的预设(`projectionValues.agentPreset`)为其他值的空白会话,未列出预设的空白会话仍可用;请求未指明预设时任何空白会话都可用,由 Host 切换到默认预设。没有可用的空白会话时由 Host 新建。
结果:新会话回到原来的空白会话,未发送的草稿保留;创造模式等预设流程不会接管列出其他预设的空白会话,用户的普通草稿不会被切到 Creator 下。不新增持久化状态,前端也不读取预设名单。
## 考虑过的替代方案
**完全回退 PR #5775 的这项改动,所有请求都复用第一个空白会话。** 草稿可以回来,但创造模式等预设流程会接管用户普通草稿所在的空白会话,把它切到 Creator,草稿留在 Creator 下,提示语也填不进去。
**保持每次新建,把被丢下的空白会话中的草稿迁移到新会话。** 需要定义多个空白会话时迁移哪一个、附件如何处理、旧会话是否归档,改动和状态都比按预设复用多。
**未指明预设的请求只复用 Host 默认预设的空白会话。** 前端不知道默认预设,需要额外请求 `agentPresets.list()` 名单。
**在前端记录“来源 → 空白会话 id”的映射并持久化。** 与空白会话自身记录的预设重复,需要处理映射过期、按浏览器 origin 隔离等问题。
**预设变化时在原会话上清空草稿(`clearPreviousDraft: true`)。** 会删除用户在另一种模式下写到一半的草稿。
**同时修改 Host,让复用内存中的空白会话时总是调用 `presets.select`。** 能完整保留 #5775 的目的,但不在本次只修前端的范围内,见“推迟工作”。
## 验证
- `ui-workspace` 单元测试:不带选项的新会话回到已有空白会话;未指明预设时复用任何空白会话,指明预设时只复用列出相同预设的空白会话,没有时新建。`navigation.ts` 逐文件覆盖率 100%。
- 回归定位:在 PR #5775 中做出这项改动的提交及其父提交上运行同一单元探针,以及在两侧构建上用浏览器复现,确认草稿丢失由该改动引入。
## 后果
- 新会话不再保证使用当前预设定义:复用的空白会话若其 Agent 仍在 Host 内存中且预设 id 相同,沿用创建时的装配;Host 重启后恢复的空白会话按当前定义装配。
- 新会话可能复用创造模式等预设流程留下的空白会话,Host 把它切回默认预设,其中的草稿保留。
- 升级指南 [new-session-blank-reuse](../../../../docs/upgrade-guide/v0.2.1-rc.2/new-session-blank-reuse/guide.zh.md) 记录相对 0.2.1-rc.2 的行为变化。
## 推迟工作
- Host `reuseLive`(`packages/api/session-controller/src/agent.ts`)在预设 id 相同时直接返回内存中的 Agent,不调用 `presets.select`。`select` 用定义的 `Generation` 对象是否相同判断定义是否重新注册过:相同则不做任何事;不同则在同一会话 id 上按当前定义重新装配并记录 `agent-preset/selected`;会话已开始时抛出 `agent-preset/locked`,调用方改为新建。去掉 `target === current` 的提前返回、改为总是调用 `select`,可以在保留草稿的同时满足 #5775“新会话按当前定义装配”的目的;需要配套 Host 测试,并更新 `reuseLive` 的 JSDoc 和上述升级指南中“不再保证当前预设定义”的说明。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-13-web-install-icons-and-launch-display.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-13-web-install-icons-and-launch-display.md
/agent-note-web-install-icons-and-launch-display:
en: 99458c7724588fd3
zh: 1af39a382e1a8b6a
/agent-note-web-install-icons-and-launch-display/problem:
en: 726b732fcaf49153
zh: b04aad2d0a65a42d
/agent-note-web-install-icons-and-launch-display/decision:
en: 65c6eb6a3ad5ab30
zh: 46ca48685f38269c
/agent-note-web-install-icons-and-launch-display/alternatives-considered:
en: 588d709bd7c8c95b
zh: 871b618b86247926
/agent-note-web-install-icons-and-launch-display/consequences:
en: 79695897b5237603
zh: b61304c9527e8dfc
@@ -0,0 +1,41 @@
# Agent Note: Web install icons and launch display
Status: implemented
English | [中文](2026-10-13-web-install-icons-and-launch-display.zh.md)
## Problem
A launcher masks the icon it puts on the home screen and chooses that icon itself. The install manifest advertised the whale favicon: an SVG mark drawn edge to edge on a transparent canvas, with no tile and no background of its own. Handed to a mask, the mark loses its fins and fills the tile at the wrong proportion. iOS reads no manifest icon at all and had no `apple-touch-icon` to fall back on, so an installed web app showed a generic tile. The manifest also requested `display: "fullscreen"`, which removes both system bars on Android and leaves a chat surface with no clock, battery, or navigation.
## Decision
`apps/web/public/` carries four install icons: a 192px and a 512px `any` raster, a 512px `maskable` raster, and a 180px `apple-touch-icon`. [`render-app-icons.ts`](../../../../apps/web/scripts/render-app-icons.ts) renders all four from the official application tile in `apps/desktop/resources/icon.svg`, and `pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` regenerates the committed files. Each variant preserves the whale's geometry, gradients, opacity, and filters. Rendering first produces a bitmap at no less than the 1024px source extent and four times the output edge, then reduces it with Lanczos3. This separate rasterization retains the original shading while smoothing the small icon's curves. Every variant crops the tile's desktop drop shadow. The maskable variant scales the whale about the tile centre by `MASKABLE_ARTWORK_SCALE` while the tile's gradient keeps its own extent, so the border a mask may crop is still the real tile rather than a flat colour meeting it at a seam.
[`pwa-manifest.e2e.ts`](../../../../apps/web/tests/pwa-manifest.e2e.ts) uses the browser image decoder to measure the committed maskable whale’s ink radius and rejects artwork outside the platform safe zone or smaller than the intended scale. The unscaled tile exceeds that zone and serves as the negative control.
The manifest requests `display: "standalone"`, names `#ffffff` as its `theme_color` and `background_color`, and lists the three rasters. `apps/web/index.html` adds the `apple-touch-icon` link, `mobile-web-app-capable`, and `apple-mobile-web-app-title`. It adds no `theme-color`: `ThemePresenter` owns the single such node and follows the resolved in-application theme, which a static media query cannot. The tab favicons are unchanged.
This supersedes the icon entry and the display mode in the [archived Web install manifest note](../../archived/feature/2026-08-06-web-install-manifest.md); its link to the manifest, product name, compact name, and root identity remain current.
## Alternatives considered
**Keep `favicon.svg` as the only icon.** It leaves iOS, which reads no manifest icon, with a generic tile, and the mark already reaches its own edges, so a launcher's mask crops it. `pwa-manifest.e2e.ts` keeps the unscaled tile’s overflow as its browser-pixel negative control.
**Commit the rasters without a generator.** The tile artwork moves with the desktop application's icon. A hand-exported set drifts silently and records neither the crop nor the safe-zone scale that makes it installable.
**Draw a separate browser mark.** The product has one approved tile. A second mark would need designer approval and would not match the installed desktop application beside it.
**Keep `display: "fullscreen"`.** Hiding both system bars suits an immersive surface, not a conversation the user reads beside the clock and returns to from the app switcher; `fullscreen` also offers no way back to the bars without a swipe.
**Ship a service worker for offline start.** Installation, launch, and reconnect work without one. Offline use is a separate capability with its own cache-invalidation contract, and no current requirement asks for it.
## Consequences
The repository carries four committed rasters, all rendered by one script from `apps/desktop/resources/icon.svg`. Changing the desktop tile means rerunning that script and reviewing the PNG diff. Unit tests inspect committed PNG dimensions without loading the native renderer. Browser tests execute the generator and decode its output and all four built PNGs, checking declared edges, opaque corners, and artwork extents against the source tile with a two-pixel rasterization tolerance. Maskable checks bound its artwork above and below, with the unscaled tile as the safe-zone negative control. Tests also compare fractional coverage along the Apple icon’s curves against direct small-size SVG rendering, which retains the source’s hard pixel edges.
The manifest's `theme_color` is one value and cannot follow the in-application theme, so a user whose theme differs from the system scheme sees that value until `ThemePresenter` writes the synchronized node shortly after boot. The presenter stays the only owner of `meta[name="theme-color"]`: a second static node wins document order and desynchronizes surrounding browser chrome from the rendered palette, which `settings-chrome.e2e.ts` pins.
iOS ignores `background_color` and masks the touch icon with its own rounded rectangle; the tile's own margins keep the whale clear of that mask. Android selects the maskable raster for the home screen and an `any` raster for the install surface. The install icons are not a service worker: an installed Web App still needs the Host reachable.
Real-device installation, launch display, and window mode remain a device check.
@@ -0,0 +1,41 @@
# Agent Note: Web 安装图标与启动显示
Status: implemented
[English](2026-10-13-web-install-icons-and-launch-display.md) | 中文
## Problem
启动器会自行选择放到桌面上的图标,并对它套用蒙层。安装 manifest 使用的却是鲸鱼 favicon:一个铺满整张画布、背景透明的 SVG 标记,既没有底板,也没有自己的背景。交给蒙层后,标记会丢掉尾鳍,并且以错误的比例填满图标。iOS 完全不读 manifest 图标,此前又没有 `apple-touch-icon` 可回退,因此安装后的 web app 只显示通用图标。manifest 还请求了 `display: "fullscreen"`,这在 Android 上会同时移除两条系统栏,让一个对话界面失去时钟、电量和导航。
## Decision
`apps/web/public/` 承载四个安装图标:192px 与 512px 的 `any` 位图、512px 的 `maskable` 位图,以及 180px 的 `apple-touch-icon`。[`render-app-icons.ts`](../../../../apps/web/scripts/render-app-icons.ts) 从 `apps/desktop/resources/icon.svg` 的官方应用底板渲染这四个文件,`pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` 重新生成已提交的文件。每个变体都保留鲸鱼的几何路径、渐变、透明度和滤镜。渲染先生成不小于源图 1024px、且边长至少为输出尺寸四倍的位图,再以 Lanczos3 下采样。分开的栅格化步骤在平滑小图标曲线的同时保留原有明暗细节。每个变体都会裁掉底板的桌面投影。maskable 变体围绕底板中心按 `MASKABLE_ARTWORK_SCALE` 缩放鲸鱼,而底板渐变保持自身范围,因此可能被蒙层裁掉的那圈边框仍是真实底板,而不是与其相接的纯色。
[`pwa-manifest.e2e.ts`](../../../../apps/web/tests/pwa-manifest.e2e.ts) 使用浏览器图像解码器测量已提交 maskable 图标的鲸鱼墨迹半径,拒绝越出平台安全区或小于预期比例的图案。未缩放图块超出该安全区,用作反向对照。
manifest 请求 `display: "standalone"`,把 `theme_color` 与 `background_color` 定为 `#ffffff`,并列出三个位图。`apps/web/index.html` 增加 `apple-touch-icon` 链接、`mobile-web-app-capable` 与 `apple-mobile-web-app-title`。它不添加 `theme-color`:该节点由 `ThemePresenter` 独占并跟随解析后的应用内主题,这是静态媒体查询做不到的。标签页 favicon 保持不变。
本决策取代[已归档的 Web 安装 manifest 记录](../../archived/feature/2026-08-06-web-install-manifest.md)中的图标条目与显示模式;其指向 manifest 的链接、产品名、简称与根路径身份仍然有效。
## Alternatives considered
**只保留 `favicon.svg` 作为图标。** 这会让不读 manifest 图标的 iOS 只显示通用图标,而该标记已经抵达自身边界,启动器的蒙层必然裁切它。`pwa-manifest.e2e.ts` 把未缩放图块的越界保留为浏览器像素反向对照。
**不使用生成器,直接提交位图。** 底板素材随桌面应用图标变化。手工导出的图标集会静默漂移,也无法记录使其可安装的裁剪方式与安全区缩放。
**另画一个浏览器专用标记。** 产品只有一个已批准的底板。第二个标记需要设计师批准,并且无法与旁边已安装的桌面应用保持一致。
**保留 `display: "fullscreen"`。** 隐藏两条系统栏适合沉浸式界面,而不适合用户对着时钟阅读、并从应用切换器返回的对话;`fullscreen` 也无法在不滑动的情况下恢复系统栏。
**为离线启动引入 service worker。** 安装、启动与重连都不需要它。离线使用是独立能力,有自己的缓存失效约定,当前没有需求要求它。
## Consequences
仓库携带四张已提交的栅格图,均由同一个脚本从 `apps/desktop/resources/icon.svg` 渲染。桌面图块变化时,需要重新运行该脚本并审查 PNG diff。单元测试直接检查已提交 PNG 的尺寸,不加载原生渲染器。浏览器测试执行生成器,并解码生成结果和全部四张构建后的 PNG,以两个像素的栅格化容差检查声明边长、不透明角落及相对于源图块的图案范围。maskable 检查同时限制图案范围的上界和下界,并以未缩放图块作为安全区反向对照。测试也将 Apple 图标曲线边缘的部分像素覆盖与直接小尺寸渲染的 SVG 对比;后者保留源图的硬像素边缘。
manifest 的 `theme_color` 是单个值,无法跟随应用内主题;因此当用户选择的主题与系统方案不同时,该值会一直生效,直到 `ThemePresenter` 在启动后不久写入同步节点。该 presenter 仍是 `meta[name="theme-color"]` 的唯一所有者:第二个静态节点会在文档顺序上胜出,使周边浏览器界面与已渲染的配色脱节,这一点由 `settings-chrome.e2e.ts` 固定。
iOS 忽略 `background_color`,并用自己的圆角矩形裁切触摸图标;底板自带的留白使鲸鱼避开该蒙层。Android 为桌面选择 maskable 位图,为安装界面选择 `any` 位图。安装图标不是 service worker:安装后的 Web App 仍要求 Host 可达。
真机安装、启动显示与窗口模式仍属设备检查。
@@ -1,19 +0,0 @@
# Bilingual-pair consistency record for 2026-10-14-built-in-plugin-feature-previews.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-14-built-in-plugin-feature-previews.md
/agent-note-built-in-plugin-feature-previews:
en: f6abb7d00b13d362
zh: b89d5da18677f31a
/agent-note-built-in-plugin-feature-previews/problem:
en: e38fa1e5b3a62b5a
zh: c612ea229705d695
/agent-note-built-in-plugin-feature-previews/decision:
en: aa12e3f72f7c2498
zh: 7dda9f061009aaa0
/agent-note-built-in-plugin-feature-previews/alternatives-considered:
en: 4a4416610894d7c6
zh: 4256e81e7ab70db5
/agent-note-built-in-plugin-feature-previews/consequences:
en: a3dbd70080416314
zh: 4d802da45085dfb0
@@ -15,11 +15,11 @@
en: ec653b59d62ed95f
zh: 0d675616059930ba
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
en: a95618f172ef63ee
zh: 306556be09c56bdf
en: ad7b3e4fbe528cf2
zh: 8ab8779bfdaaa4fa
/agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership:
en: 8051417b08739ed0
zh: c11d35a346014436
en: 7566e400f9ab3a51
zh: 118b2e84d0030480
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
en: d5a8e36b545f1c04
zh: d741fd0789401c62
@@ -33,5 +33,5 @@
en: a55da02f4d3c06c0
zh: 3c1a624f03f34f28
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
en: 41f9a331c7aca397
zh: 17a0fd53e133c2f5
en: 4c84e7bd035b52a7
zh: 4e5687bda0a47706
@@ -24,7 +24,7 @@ The guide specifies API-key Bearer authentication, and requests send only the do
Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful.
The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
The provider preserves service order and maps URL, title, `snippet`, `summary`, and nullable `date` into shared sources. Following the guide, the tool gives the model each source's `summary` and keeps the short `snippet` for the search card and its replay metadata. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16. `dsh-tool-web` therefore collapses summary whitespace and caps each summary at `searchSummaryMaxChars`, 800 characters by default, the upper end of the guide's typical range; one call's summaries stay within the source cap times that cap. The summary rides in the tool output value and rendered text, which the Session already records. The provider does not consume full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities.
Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data.
@@ -32,7 +32,7 @@ Direct retrieval emits no auxiliary model-request event because Harness makes no
Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged.
The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
The HTTP provider retains its own URL validation and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content.
### Default migration
@@ -78,7 +78,6 @@ Provider tests cover request mapping, empty results, malformed responses, indepe
The following capabilities remain outside the implemented integration:
- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16.
- Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language.
- Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources.
- Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes.
@@ -24,7 +24,7 @@ Status: implemented
直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。
提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
提供方保留服务端顺序,将 URL、标题、`snippet`、`summary` 和可为 null 的 `date` 映射为共享来源。按照指南,工具把每个来源的 `summary` 提供给模型,短的 `snippet` 留给搜索卡片及其回放元数据。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。因此 `dsh-tool-web` 合并摘要中的空白,并将每条摘要限制在 `searchSummaryMaxChars` 以内,默认 800 个字符,即指南典型范围的上限;一次调用的摘要总长不超过来源上限乘以该上限。摘要随工具输出值和渲染文本一起进入 Session 记录。提供方不使用完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。
直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。
@@ -32,7 +32,7 @@ Status: implemented
Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。
HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。
### 默认值迁移
@@ -78,7 +78,6 @@ Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一
以下能力尚未包含在已实现的接入中:
- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。
- 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。
- 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。
- 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-19-sdk-native-user-questions.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-19-sdk-native-user-questions.md
/agent-note-sdk-native-user-questions:
en: b506991bd07d87dc
zh: 9f3e1ecb42163d72
/agent-note-sdk-native-user-questions/problem:
en: b3dbcdb4b5bce2d1
zh: 60df882dc253fcf5
/agent-note-sdk-native-user-questions/decision:
en: 0f7b71f0ef58f5f8
zh: 07bb12acad27d2fc
/agent-note-sdk-native-user-questions/consequences:
en: 46e3a450c6dce54e
zh: 9f99efc6f8d2ab8d
/agent-note-sdk-native-user-questions/alternatives-considered:
en: 9861066ae00e7bde
zh: 0dec00f4e3c692e0
/agent-note-sdk-native-user-questions/verification:
en: c16fb364fe8ec13b
zh: fa23c162348ba682
@@ -0,0 +1,29 @@
# Agent Note: SDK native user questions
Status: implemented
English | [中文](2026-10-19-sdk-native-user-questions.zh.md)
## Problem
The SDK can expose the shared `ask_user_question` tool without a Web GUI, but an out-of-process host cannot answer the native user-questions event through the SDK. Publishing the tool alone leaves calls without an answerer. The SDK must carry the interaction while the application chooses its user interface or simulated-user policy.
## Decision
The TypeScript and Python clients accept a question callback and advertise it during initialization. The SDK server adapts the existing user-questions seam to a bidirectional RPC only for its exact owned root Agents. Clients without the capability keep native in-process answerers. Tool definitions and the service's live-root and delegation checks remain shared with the desktop composition.
Question identity is independent of the JSON-RPC envelope so cancellation can identify outstanding host work. The runtime validates a complete answer batch, aborts pending requests when the tool or server stops, and ignores late responses. The host owns answer collection; the SDK neither chooses answers nor treats missing input as approval. The [protocol reference](../../../../packages/sdk/protocol/README.md#native-user-questions) owns wire details.
## Consequences
A GUI and runtime patch module are unnecessary. A host can use the same interface for a terminal, browser, or simulated user. The Python SDK keeps its synchronous API by running callbacks outside the reader thread; callbacks must observe cancellation and release their own resources. Late callbacks cannot publish into a later runtime process. Question waits are process-local; completed tool results retain ordinary Session persistence.
A Web Session controller is not an interaction provider for the SDK. Reminder restoration uses the SDK's own Session owner and initialized model options; it does not claim a second write handle through a GUI controller.
## Alternatives considered
Requiring a Web GUI or mounting its controller couples headless execution to another lifecycle owner. Asking each host to inject a runtime patch repeats the transport bridge outside the maintained SDK. Removing the shared question tool would reduce the model's available capabilities rather than supply its missing answer path.
## Verification
Server tests cover ownership, native fallback, answer validation, cancellation, and transport failure. Client tests cover concurrent bidirectional transport and cancellation, including callback advertisement and answer delivery with either explicit startup or a direct `initialize()` call. Initialization starts the runtime before constructing the handshake capabilities so both entry paths advertise the installed callback. Python tests also cover reentrant requests and a late callback after process replacement. Real shipped-profile tests drive the built-in question in standard and PTC modes and close a runtime while the host is answering. The [server tests](../../../../packages/sdk/server/tests/user-questions.spec.ts), [client tests](../../../../packages/sdk/client/tests/user-questions.spec.ts), [Python tests](../../../../python/sdk/tests/test_user_questions.py), and [profile tests](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts) retain this evidence.
@@ -0,0 +1,29 @@
# Agent Note: SDK 原生用户问答
Status: implemented
[English](2026-10-19-sdk-native-user-questions.md) | 中文
## 问题
SDK 可以在没有 Web GUI 时暴露共享的 `ask_user_question` 工具,但进程外宿主无法通过 SDK 回答原生用户问答事件。仅发布工具会使调用缺少回答方。SDK 必须承载交互,应用则自行选择用户界面或模拟用户策略。
## 决策
TypeScript 与 Python 客户端接收问答回调,并在初始化时声明。SDK server 仅为其确切拥有的根 Agent,把现有用户问答 seam 适配为双向 RPC。未声明能力的客户端保留原生进程内回答方。工具定义以及服务对存活根 Agent 和委派关系的检查仍与桌面端组合共享。
问题标识独立于 JSON-RPC 帧,使取消能够定位待决的宿主工作。运行时校验完整答案批次,在工具或服务器停止时中止待决请求,并忽略晚到响应。宿主拥有答案收集过程;SDK 不选择答案,也不把缺失输入当作批准。[协议参考](../../../../packages/sdk/protocol/README.zh.md#native-user-questions)拥有具体协议说明。
## 影响
此流程不要求 GUI 或运行时补丁模块。宿主可以用同一接口接入终端、浏览器或模拟用户。Python SDK 在读取线程之外执行回调,以保留同步 API;回调必须响应取消并释放自己的资源。晚到回调无法向后续新运行时进程发送结果。问题等待仅属于当前进程;已完成工具结果仍通过普通 Session 路径持久化。
Web Session controller 不能充当 SDK 的交互提供方。提醒恢复使用 SDK 自身的 Session 所有者和初始化模型选项,不通过 GUI 控制器争用第二个写入 handle。
## 已考虑的替代方案
要求 Web GUI 或挂载其控制器,会将 headless 执行与另一个生命周期所有者耦合。要求各宿主注入运行时补丁,会在受维护的 SDK 外重复实现传输适配。移除共享问答工具只会减少模型可用能力,无法提供缺失的回答通道。
## 验证
服务器测试覆盖所有权、原生回退、答案校验、取消和传输失败。客户端测试覆盖并发双向传输和取消,包括显式启动和直接调用 `initialize()` 两条路径的回调声明与答案投递。初始化在构造握手能力之前启动运行时,使两条入口均声明已安装的回调。Python 测试还覆盖回调内再次请求,以及进程替换后的晚到回调。真实发行 profile 测试在标准模式和 PTC 模式调用内建问答,并在宿主回答期间关闭运行时。[服务器测试](../../../../packages/sdk/server/tests/user-questions.spec.ts)、[客户端测试](../../../../packages/sdk/client/tests/user-questions.spec.ts)、[Python 测试](../../../../python/sdk/tests/test_user_questions.py)和 [profile 测试](../../../../apps/cli/tests/profiles/sdk/user-questions.e2e.ts)保留这些依据。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-29-weighted-approval-decision-check-run.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md
/agent-note-weighted-approval-decisions-are-completed-check-runs:
en: 1b120e96b0f5f340
zh: ff20c20fe0558964
/agent-note-weighted-approval-decisions-are-completed-check-runs/problem:
en: 6ba6f6a15a1562ec
zh: 1cbef3e736c258e6
/agent-note-weighted-approval-decisions-are-completed-check-runs/decision:
en: 7dcebfed221053cc
zh: 84346547d1b95ee3
/agent-note-weighted-approval-decisions-are-completed-check-runs/verification:
en: 3771d95af79c69bb
zh: d4053338d878511f
/agent-note-weighted-approval-decisions-are-completed-check-runs/alternatives-considered:
en: b8daea66b0a90d26
zh: fb7079c815b38281
/agent-note-weighted-approval-decisions-are-completed-check-runs/consequences:
en: 5708f2112ee1e647
zh: 02d29ce5b18db06d
@@ -0,0 +1,61 @@
# Agent Note: Weighted approval decisions are completed check runs
Status: implemented
English | [中文](2026-09-29-weighted-approval-decision-check-run.zh.md)
## Problem
The weighted approval policy must block merging until its conditions hold, distinguish an unmet condition from a failed evaluation, and let tools that wait for pull-request checks to finish terminate. A draft pull request, insufficient approval points, and an effective `CHANGES_REQUESTED` review from a write-capable reviewer are reversible review states rather than evaluation failures.
Commit statuses offer only `pending`, `success`, `failure`, and `error`. A `pending` commit status blocks merging, but `gh pr checks --watch` and other CI watchers treat it as running work and wait until someone approves. A `failure` commit status ends the wait but reports an unmet, reversible condition as a malfunction and conflates the policy outcome with publisher health.
## Decision
The [publisher](../../../../.github/review-ownership/check-approval.mjs) reports the decision as the `weighted approval decision` check run on the pull-request head, written with the job's GitHub Actions token. Branch rules require that check run with GitHub Actions as its expected source.
| Condition | Check run |
|---|---|
| First evaluation of a head | `in_progress` until the decision is written |
| Re-evaluating a head that published `success` | conclusion `action_required` until the decision is written |
| Draft, fewer than the required points, or an effective write-capable `CHANGES_REQUESTED` review | `completed`, conclusion `action_required` |
| Ready, threshold met, and no blocking review | `completed`, conclusion `success` |
| Setup, evaluation, or cancellation failure | `completed`, conclusion `failure` |
A check run that reached `completed` never returns to `in_progress` or `queued`: GitHub answers `200` to that update and leaves the run completed, so only the conclusion is mutable. The publisher therefore revokes a published success by writing the blocking conclusion before dependency setup, which covers the setup and history fetch that dominate a cold evaluation. The head keeps one GitHub Actions run with the required name: the publisher updates the head's existing run and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. The publish step records the conclusion it wrote in a step output, and the setup-failure step runs only when no conclusion was recorded: a published decision survives a later failure or cancellation, while a run cancelled before that write concludes `failure`. A run cancelled before the publish step records its conclusion, including a cancellation whose file-command outputs the runner drops, therefore concludes `failure`, which still blocks the merge. During a re-evaluation, a previous decision other than a published success keeps its conclusion and title until the new decision is written; a published success is replaced by `action_required` and the preparation title.
A blocking review dominates the point total, so the conclusion remains `action_required` even when counted approvals reach the threshold. GitHub accepts only `success`, `neutral`, and `skipped` for a required check, so `action_required` blocks merging while completing the check.
## Verification
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, revoking a published success through its conclusion, updating an existing run instead of creating another, recording the conclusion that the setup-failure step reads, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `revoke` and `failure` phase commands, the publish step identity, and the fallback that runs when a run fails or is cancelled before publishing.
The transition matrix and the fallback condition were measured on 2026-09-30 with throwaway tag runs in this repository, using the same GitHub Actions token as the publisher. A run created as `in_progress` and completed as `success` accepted `status: in_progress` and `status: queued` with `200` and stayed `completed`, while conclusion-only updates moved it to `action_required`, back to `success`, and to `failure`. Those runs measured the fallback condition as it stood on 2026-09-30 (`failure() || (cancelled() && steps.publish.outcome != 'success')`): the step ran for a failed publish and for a cancellation during the publish step, and did not run when the job was cancelled after the decision was published. The current condition also requires an empty `concluded` output, so a job cancelled after the publish step recorded its conclusion skips the fallback in either condition. A `timeout-minutes` cancellation was measured separately on 2026-10-20: a job that exceeded its one-minute timeout on a throwaway `push` branch still ran its `cancelled()` step, so the step executes on that path; whether a cancelled step's file-command outputs are exposed was not measured, and dropping them only makes that cancellation conclude `failure`.
The list response's `total_count` counts the `check_name`-filtered runs rather than every check on the head: on 2026-10-20 a head carrying 33 check runs reported `total_count` 0 for a name that matches none and 2 for an existing name, so an equality between `total_count` and the returned array length rejects a truncated page.
## Alternatives considered
**Keep the `pending` commit status.** Branch rules block correctly, but CI watchers cannot distinguish an approval wait from running checks and do not finish.
**Publish `failure` for unmet conditions.** This ends the wait, but it represents an unmet, reversible merge condition as a malfunction and conflates the policy outcome with publisher health.
**Revoke by returning the completed run to `in_progress`.** GitHub accepts that update without applying it, so the published success would stand through dependency setup and the history fetch.
**Publish nothing until success.** A required check that was never reported remains expected and is invisible to many watchers. Revoking a success already published on the same head still requires a non-success write, and each watcher's treatment of a missing required check is unspecified.
**Keep the `weighted approval` name.** When a check and a commit status share a required name, GitHub requires both to pass. Heads that carry a `pending` commit status from the earlier publisher would stay blocked until a new push.
**Create a new check run on every evaluation.** Several same-name check runs on one head make the result depend on how GitHub and each watcher select among them.
**Let approval points override a blocking review.** This makes the score the only success condition, but it permits success while a write-capable reviewer's effective decision still requests changes.
## Consequences
CI watchers finish while the decision waits on reviewers. `gh pr checks` groups `action_required` with failed checks and exits non-zero; consumers distinguish it by the check name, the `ACTION_REQUIRED` conclusion, or the output title, which carries the score or blocker. The `weighted approval publisher` Actions job and the `failure` conclusion carry operational failures.
Re-evaluating a head that already published `success` reports `action_required` until the new decision is written, so a watcher that polls inside that window sees a failed check.
The branch-rule switch leaves two gaps on heads the publisher has not evaluated since it. A check run's name does not identify its publisher. Any workflow in the pushed branch can name a job `weighted approval decision`: on 2026-10-20 a job with `if: false` on a throwaway branch produced a `github-actions` check run with that name and conclusion `skipped` on the pushed commit, and a required check accepts `skipped`. The publisher therefore revokes every passing conclusion it finds before dependency setup and adopts every same-named run when it publishes, which covers a run that exists at either write; a same-named passing run created after the publisher's last write on the head is outside its control. Requiring the check from a dedicated GitHub App is the only complete binding, and it replaces the job token with an app-installed identity.
A head without the decision check run reports the required check as expected until the next publisher event creates it; re-running a pre-switch run does not, because a re-run replays that run's earlier workflow file, which calls the removed `pending` phase against the new script. A re-run of a pre-switch run on 2026-10-20 executed the pre-switch step names and no check-run write. A pull-request edit triggers the publisher's `edited` event, so the rollout can create the run on a head that has none. A head that also carries the earlier `weighted approval` commit status keeps that pending status until the same event: the combined status resolves each context to its latest status, so the rollout posts a terminal `success` status for that context on every open head instead of waiting for its next push.
@@ -0,0 +1,61 @@
# Agent Note: 加权批准决策使用已完成的 check run
Status: implemented
[English](2026-09-29-weighted-approval-decision-check-run.md) | 中文
## 问题
加权批准策略必须在条件满足之前阻止合并,区分尚未满足的条件与失败的评估,并让等待 PR 检查结束的工具能够退出。draft PR、批准点数不足,以及具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审,都是可撤销的评审状态,而不是评估故障。
commit status 只提供 `pending`、`success`、`failure` 和 `error`。`pending` commit status 会阻止合并,但 `gh pr checks --watch` 和其他 CI 监视工具把它当作仍在运行的工作,会一直等到有人批准。`failure` commit status 能结束等待,但会把尚未满足且可撤销的条件报告为故障,并混淆策略结果与 publisher 的健康状态。
## 决策
[publisher](../../../../.github/review-ownership/check-approval.mjs) 使用该 job 的 GitHub Actions token,在 PR head 上以 `weighted approval decision` check run 报告决策。分支规则要求该 check run,并把 GitHub Actions 设为预期来源。
| 条件 | Check run |
|---|---|
| head 的首次评估 | 写入决策前为 `in_progress` |
| 重新评估已发布 `success` 的 head | 写入决策前 conclusion 为 `action_required` |
| draft、点数少于要求,或存在具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审 | `completed`,conclusion 为 `action_required` |
| 已 ready、达到阈值且没有阻塞性评审 | `completed`,conclusion 为 `success` |
| 安装、评估或取消导致的失败 | `completed`,conclusion 为 `failure` |
已经到达 `completed` 的 check run 不会再回到 `in_progress` 或 `queued`:GitHub 对该更新返回 `200`,但让 run 保持已完成,因此只有 conclusion 可变。publisher 因此在依赖安装前用阻塞性 conclusion 撤销已发布的成功,覆盖冷启动中占主要耗时的安装与历史拉取。同一 head 上只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建一个。列表不完整或 run 没有有效 ID 时发布失败。publish 步骤把自己写入的 conclusion 记录到 step output,安装失败步骤只在该 output 未记录时运行:已发布的决策在之后的失败或取消中保留,而在该写入之前被取消的 run 以 `failure` 结束。在 publish 步骤记录 conclusion 之前被取消的 run——包括 runner 丢弃其 file-command 输出的取消——因此以 `failure` 结束,仍然阻止合并。重新评估期间,先前决策若不是已发布的 `success`,其 conclusion 与标题会一直显示到新决策写入为止;已发布的 `success` 会被替换为 `action_required` 与准备中的标题。
阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,conclusion 仍为 `action_required`。GitHub 只接受 `success`、`neutral` 和 `skipped` 作为必需检查的通过结果,因此 `action_required` 会阻止合并,同时让该检查处于完成状态。
## 验证
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、通过 conclusion 撤销已发布的成功、更新已有 run 而不是另建一个、记录安装失败步骤所读的 conclusion,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`revoke` 和 `failure` 阶段命令、publish 步骤的身份,以及运行失败或在发布前被取消时执行的兜底步骤。
转换矩阵与该兜底条件于 2026-09-30 在本仓库用一次性 tag 运行实测,使用的是与 publisher 相同的 GitHub Actions token。以 `in_progress` 创建、以 `success` 完成的 run,接受 `status: in_progress` 和 `status: queued` 的更新时都返回 `200` 并保持 `completed`;只改 conclusion 的更新把它依次改为 `action_required`、`success` 和 `failure`。这些运行测的是 2026-09-30 当时的兜底条件(`failure() || (cancelled() && steps.publish.outcome != 'success')`):兜底步骤在发布失败以及发布步骤执行期间被取消时运行,在决策发布之后才取消 job 时不运行。当前条件还要求 `concluded` output 为空,因此两种条件下,publish 步骤已记录结论后才取消的 job 都跳过兜底。`timeout-minutes` 取消路径于 2026-10-20 单独实测:一个在一次性 `push` 分支上超过一分钟超时的 job 仍然执行了 `cancelled()` 步骤,因此该步骤在这条路径上会执行;取消步骤的 file-command 输出是否会被暴露未实测,若被丢弃只会让该次取消以 `failure` 结束。
列表响应中的 `total_count` 统计的是经 `check_name` 过滤后的 run,而不是该 head 上的全部检查:2026-10-20 实测,一个带有 33 个 check run 的 head,对不匹配任何 run 的名称返回 `total_count` 0,对已存在的名称返回 2,因此用 `total_count` 与返回数组长度相等来拒绝被截断的分页是有效的。
## 考虑过的替代方案
**保留 `pending` commit status。** 分支规则能正确阻止合并,但 CI 监视工具无法区分等待批准与仍在运行的检查,因此不会结束。
**为尚未满足的条件发布 `failure`。** 该方案能结束等待,但会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。
**通过把已完成的 run 改回 `in_progress` 来撤销。** GitHub 接受该更新但不执行,因此已发布的成功会在依赖安装与历史拉取期间继续存在。
**在成功之前不发布任何结果。** 从未上报的必需检查保持 expected 状态,许多监视工具看不到它。撤销同一 head 上已发布的成功仍然需要写入非成功结果,而各监视工具如何对待缺失的必需检查没有规定。
**保留 `weighted approval` 名称。** 当 check 和 commit status 使用同一个必需名称时,GitHub 要求两者都通过。带有旧 publisher 所写 `pending` commit status 的 head 会一直被阻止,直到出现新的 push。
**每次评估都创建新的 check run。** 同一 head 上存在多个同名 check run 时,结果取决于 GitHub 和各监视工具如何在其中选择。
**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功结果。
## 后果
决策等待评审人时,CI 监视工具会结束。`gh pr checks` 把 `action_required` 与失败的检查归为一组并以非零状态退出;消费方可以通过 check 名称、`ACTION_REQUIRED` conclusion 或携带分数或 blocker 的输出标题加以区分。`weighted approval publisher` Actions job 和 `failure` conclusion 承载运行故障信号。
重新评估已经发布 `success` 的 head 时,在新决策写入前会报告 `action_required`,因此在该窗口内轮询的监视工具会看到失败的检查。
分支规则切换后,对切换以来 publisher 尚未评估过的 head 存在两个缺口。check run 的名称并不标识发布者。被推送分支里的任何 workflow 都可以把一个 job 命名为 `weighted approval decision`:2026-10-20 在一次性分支上,带 `if: false` 的 job 在被推送的 commit 上产生了 `github-actions` 来源、该名称、conclusion 为 `skipped` 的 check run,而必需检查接受 `skipped`。因此 publisher 在依赖安装前撤销它找到的所有可通过的 conclusion,并在发布时接管所有同名 run,覆盖两次写入时已存在的 run;在 publisher 对该 head 最后一次写入之后新出现的同名可通过 run 不在其控制范围内。把该检查绑定到专用 GitHub App 是唯一完整的绑定方式,它会用 App 安装身份替换 job token。
没有该决策 check run 的 head 会把必需检查报告为 expected,直到下一次 publisher 事件创建它;重跑切换前的 run 不能完成迁移,因为重跑会回放该 run 当时的 workflow 文件,而它调用的 `pending` 阶段已被新脚本拒绝。2026-10-20 对切换前 run 的重跑执行的是切换前的步骤名,没有写入 check run。编辑 PR 会触发 publisher 的 `edited` 事件,因此 rollout 可以对没有该 run 的 head 制造这次事件。同时也带有先前 `weighted approval` commit status 的 head 会一直保留那个 pending status 直到同一事件:combined status 对每个 context 取最新的 status,因此 rollout 对每个开放 head 发布该 context 的终态 `success` status,而不是等它下一次 push。
@@ -26,6 +26,9 @@
/agent-note-frontend-large-session-performance-budgets/calibration/trajectory-readiness-polling:
en: bdcfefd7358e335b
zh: f718f66ca5944203
/agent-note-frontend-large-session-performance-budgets/calibration/history-selector-invalidation:
en: 2d2f78f4cf6aad8a
zh: aec6af5f7c66de16
/agent-note-frontend-large-session-performance-budgets/alternatives-considered:
en: 4d2a4a64ccd14507
zh: 8a4aaf4d4abea13d
@@ -86,6 +86,22 @@ A controlled macOS arm64 diagnostic on Node 24.19.0, Chromium 149.0.7827.55 and
Complete local 240-turn runs produce Trajectory samples of 157.795/146.737/148.571 ms before the observer change and 130.184/191.247/157.543 ms afterward, with medians 148.571 and 157.543 ms. Both runs pass every fixed endpoint budget; no local speedup or hosted calibration is claimed. The readiness regression rejects a hidden searchbox or record despite a visible unrelated table row; restoring the original selector waits makes that test fail.
### History selector invalidation
The older-history control is the immediate sibling before the `display: contents` flow anchor. Leading spacing therefore uses `.column > .older + [data-slot="conversation.chat.flow"]`, preserving the earlier selector's specificity and visible-row filtering. An ancestor `.column:has(> .older)` combined with the descendant empty-row `:has()` makes unrelated draft mutations invalidate the history subtree. A temporary Chromium invalidation trace identified this exact spacing selector and style recalculations covering 13,591–13,641 elements. The trace is causal evidence, not a latency measurement: tracing itself adds substantial overhead.
On Linux x64, Node 24.19.0 and Chromium 149.0.7827.55, separate three-sample runs of the unchanged browser benchmark measured these medians using the original and sibling selectors. Both retained all 25,409 DOM nodes and every trusted input overlapped the streaming reply.
| Endpoint | Original selector | Sibling selector |
|---|---:|---:|
| Draft typing | 517.39 ms | 378.50 ms |
| First reply | 1556.89 ms | 801.58 ms |
| Stream main-thread task | 2540.59 ms | 1774.75 ms |
These local runs both failed the independent open budget: their open medians were 1192.85 and 1666.99 ms. A separate six-sample diagnostic alternated the original and sibling selectors in the browser CSSOM before timing, using the same built artifacts. Its original/sibling open wall medians were 2598.99/1349.22 ms, while Chromium task medians were 325.67/305.90 ms. This finds no browser-computation regression in those samples, but does not explain the remaining wait or certify the whole benchmark. The alternating diagnostic measured typing medians of 350.18/362.82 ms, so the earlier typing improvement is not uniform across runs; first-reply medians were 1498.48/815.85 ms and stream-task medians 2449.69/1915.73 ms. The diagnostic changes are not shipped; workload, overlap assertions, budgets and benchmark source remain unchanged.
Post-GC browser heap was 80.70/80.71 MiB. GNU time reported maximum RSS of 535.78/561.80 MiB for each benchmark command and its children; this is not input-phase memory or an aggregate browser-memory measurement. Moving the composer's selection reveal into an animation frame was rejected: profiling showed the style flush moved to another geometry read, without improving typing. The product fix removes the invalidation source instead.
## Alternatives considered
**Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim.
@@ -86,6 +86,22 @@ Trajectory 激活在动画帧上采样可见搜索框与 `[data-trajectory-scrol
完整本地 240 轮运行在修改观察器前得到 Trajectory 样本 157.795/146.737/148.571 ms,修改后为 130.184/191.247/157.543 ms,中位数分别为 148.571 和 157.543 ms。两次运行均通过所有固定终点预算;不声称本地提速或托管环境校准。即使存在可见的无关表格行,就绪回归测试仍拒绝隐藏的搜索框或记录;恢复原选择器等待会使该测试失败。
### 历史区域选择器失效
更早历史控件紧邻 `display: contents` 流锚点之前。因此首行间距使用 `.column > .older + [data-slot="conversation.chat.flow"]`,保持原选择器的优先级与可见行过滤。祖先 `.column:has(> .older)` 与后代空行 `:has()` 组合,会让无关的草稿修改使历史子树样式失效。临时 Chromium 失效跟踪定位到这条具体的间距选择器,样式重算覆盖 13,591–13,641 个元素。跟踪用于定位原因,不用于测量延迟:跟踪本身会增加大量开销。
在 Linux x64、Node 24.19.0 与 Chromium 149.0.7827.55 上,使用原选择器与兄弟选择器分别执行未修改的浏览器基准,每组取三个样本的中位数。两组都保留全部 25,409 个 DOM 节点,且每个受信任的输入都与流式回复重叠。
| 终点 | 原选择器 | 兄弟选择器 |
|---|---:|---:|
| 草稿键入 | 517.39 ms | 378.50 ms |
| 首段回复 | 1556.89 ms | 801.58 ms |
| 流式主线程任务 | 2540.59 ms | 1774.75 ms |
这两次本地运行都未通过独立的打开预算:打开中位数分别为 1192.85 与 1666.99 ms。另一项六样本诊断使用同一份构建产物,在计时前通过浏览器 CSSOM 交替使用原选择器与兄弟选择器。原版/修改版的打开壁钟中位数为 2598.99/1349.22 ms,Chromium 任务中位数为 325.67/305.90 ms。这些样本未观察到浏览器计算回归,但没有解释剩余等待,也不代表完整基准已通过。交替诊断的键入中位数为 350.18/362.82 ms,因此前述键入改善并非每次运行都能观察到;首段回复中位数为 1498.48/815.85 ms,流式任务中位数为 2449.69/1915.73 ms。诊断改动不随产品提交;负载、重叠断言、预算与基准源码保持不变。
强制 GC 后的浏览器 heap 为 80.70/80.71 MiB。GNU time 为两次基准命令及其子进程报告的最大 RSS 为 535.78/561.80 MiB;这不是输入阶段内存,也不是浏览器总内存测量。曾尝试将输入框的选区显示推迟到动画帧,但分析显示样式刷新转移到另一处几何读取,没有改善键入,因此未采用。产品修复直接移除样式失效的来源。
## 考虑过的替代方案
**用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。
@@ -9,7 +9,7 @@ Draw recognizable artwork that belongs beside the existing official icons. Offic
## Optional feature previews
Before drawing a larger image below a built-in bundle's description, read and follow [adding a built-in plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, display size and position, language variants, asset limits, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature, preserve its produced assets, and keep built-in previews in the Plugin Manager client assets directory and its internal bilingual map.
Before drawing a larger image for the top of an official bundle's detail document, read and follow [adding a plugin feature preview](../../../docs/cookbook/adding-a-plugin-preview.md). It owns optionality, image count, design canvas, position, language variants, file location, and acceptance. The guide also owns the embedded-raster exception for SVG previews; small icons remain geometry-only. The icon dimensions below do not apply to previews. Inspect the real feature and preserve its produced assets.
## Establish the subject
+1 -1
View File
@@ -124,7 +124,7 @@ For GitHub PRs, inspect remote CI after the push:
gh pr checks
```
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment.
Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. A `weighted approval decision` check that `gh pr checks` lists as failed means the approval gate is waiting on reviewers only when its conclusion is `ACTION_REQUIRED`; the same check concluded `FAILURE` is a publisher fault, so read the `weighted approval publisher` job before attributing it to the branch.
When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=<sha>` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event:
+6 -6
View File
@@ -16,11 +16,11 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
## Approval scoring
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists; a head without one starts `in_progress` and a re-evaluated head that published `success` is concluded `action_required` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. A completed check run only ever changes conclusion, so every later write completes it again. Failures and cancellations that happen before the publish step records a conclusion conclude it as `failure`; a cancellation after that record keeps the published decision. A completed evaluation concludes `action_required` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker.
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup concludes the check as `failure`.
A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points.
@@ -28,13 +28,13 @@ Production source means supported code files under `src/` in `packages/`, `apps/
Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before check-run writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though decision check runs target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the check run. Permission changes take effect on the next subscribed event.
<a id="delegating-points"></a>
## Delegating points
Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal concludes the check as `failure` and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving eligible, author-authorized command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points.
@@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production
## Security
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
All actions in the check-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures conclude the check as `failure`; request failures remain separate from the approval decision.
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
@@ -52,7 +52,7 @@ Approval policy changes take effect only after they merge into the default branc
## Verification
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, check-run publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
<a id="dev-note"></a>
+79 -31
View File
@@ -10,7 +10,8 @@ import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs'
const API_VERSION = '2026-03-10'
const MAX_PULL_REQUEST_RECORDS = 3_000
const PAGE_SIZE = 100
const STATUS_CONTEXT = 'weighted approval'
const CHECK_NAME = 'weighted approval decision'
const PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped'])
const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
@@ -209,13 +210,13 @@ function timestamp(value, subject) {
/**
* Evaluate approval points from current reviews and repository permissions.
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
* @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
* @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'action_required' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and check-run output fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
*/
export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
const pull = pullRequestFromEvent(event)
const policy = parseApprovalPolicy(policySource)
if (pull.draft) {
return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request')
return approvalResult(pull, policy.requiredPoints, [], [], [], 'action_required', 'draft pull request')
}
const reviews = await listPullRequestReviews(api, pull.repository, pull.number)
@@ -286,11 +287,11 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
return next
}, authorCredit?.points ?? 0)
if (blockers.length > 0) {
return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending',
return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'action_required',
`${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations)
}
// Tolerate floating-point addition error without rounding approval scores.
const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending'
const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'action_required'
return approvalResult(
pull,
policy.requiredPoints,
@@ -305,13 +306,12 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
}
/**
* Evaluate and publish the required commit status, publishing an error status when evaluation fails.
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
* Evaluate and publish the required check run, concluding it as failure when evaluation fails.
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, record?: (conclusion: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs; record receives each conclusion the run publishes.
* @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
*/
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`), record = () => {} }) {
const pull = pullRequestFromEvent(event)
await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
let result
try {
result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
@@ -331,7 +331,8 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
}
} catch (error) {
await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
await publishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl)
record('failure')
throw error
}
write(result.authorCredit
@@ -342,8 +343,9 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
`@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
await publishStatus(api, pull, result.state, result.description, runUrl)
write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
await publishCheck(api, pull, result.state, result.description, runUrl)
record(result.state)
write(`Published ${JSON.stringify(CHECK_NAME)} check run ${JSON.stringify(result.state)}.`)
try {
await requestDelegatedReviews(result, api, write)
} catch (error) {
@@ -353,14 +355,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
}
/**
* Revoke a previous success before dependency installation, or report its failure.
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
* @returns {Promise<void>} Completion of the status write.
* Revoke a published success before dependency installation, or conclude a failed setup.
* @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
* @returns {Promise<void>} Completion of the check-run write.
*/
export async function publishApprovalPhase({ event, api, runUrl, phase }) {
if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase')
await publishStatus(api, pullRequestFromEvent(event), phase,
phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl)
const pull = pullRequestFromEvent(event)
if (phase === 'revoke') return revokeApproval(api, pull, runUrl)
if (phase === 'failure') return publishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl)
throw new Error('invalid approval setup phase')
}
/**
@@ -414,7 +417,7 @@ export async function approvalEventFromComment({ event, api }) {
}
/**
* Refresh a pull-request event before any status write; closed PRs and stale heads are skipped.
* Refresh a pull-request event before any check-run write; closed PRs and stale heads are skipped.
* @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Pull-request event and API caller.
* @returns {Promise<Record<string, unknown> | null>} Live event, or null for a closed PR or superseded head.
*/
@@ -467,18 +470,60 @@ async function reviewerPermission(api, repository, login) {
return response.permission.toLowerCase()
}
async function publishStatus(api, pull, state, description, runUrl) {
// One decision per head keeps branch rules and CI watchers unambiguous; the head's existing GitHub
// Actions run is updated and a run is created only when none exists.
async function publishCheck(api, pull, conclusion, title, runUrl) {
requireHttpsRunUrl(runUrl)
const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } }
const runs = await decisionRuns(api, pull)
if (runs.length === 0) {
await api(`/repos/${pull.repository}/check-runs`, { method: 'POST', body: { name: CHECK_NAME, head_sha: pull.headSha, ...body } })
return
}
for (const { id } of runs) {
if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id')
await api(`/repos/${pull.repository}/check-runs/${id}`, { method: 'PATCH', body })
}
}
// A head without a decision starts in progress; a published success becomes action_required, so an
// interrupted evaluation cannot leave a success that its current inputs no longer support.
async function revokeApproval(api, pull, runUrl) {
requireHttpsRunUrl(runUrl)
const title = 'Preparing approval evaluation.'
const output = { title, summary: `${title} [Workflow run](${runUrl})` }
const runs = await decisionRuns(api, pull)
if (runs.length === 0) {
await api(`/repos/${pull.repository}/check-runs`, {
method: 'POST',
body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output },
})
return
}
for (const { id, conclusion } of runs) {
if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id')
// Revoke every conclusion a required check accepts, not only the success this publisher writes:
// a workflow job added by the pull request can produce a same-named passing check run.
if (!PASSING_CONCLUSIONS.has(conclusion)) continue
await api(`/repos/${pull.repository}/check-runs/${id}`, {
method: 'PATCH',
body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output },
})
}
}
// A check run that reached completed never returns to in_progress: GitHub answers 200 and leaves the
// run completed, so a published decision can only be replaced by another conclusion.
async function decisionRuns(api, pull) {
const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=${PAGE_SIZE}`)
if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) {
throw new Error('check-run list response is invalid or incomplete')
}
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions')
}
function requireHttpsRunUrl(runUrl) {
if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS')
if (description.length > 140) throw new Error('commit status description exceeds 140 characters')
await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, {
method: 'POST',
body: {
state,
context: STATUS_CONTEXT,
description,
target_url: runUrl,
},
})
}
function pullRequestFromEvent(event) {
@@ -571,7 +616,7 @@ async function main() {
const phase = process.argv[2]
if (phase) {
await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
if (phase === 'revoke' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
return
}
await runApprovalCheck({
@@ -579,6 +624,9 @@ async function main() {
policySource,
api,
runUrl: process.env.GITHUB_RUN_URL ?? '',
record: (conclusion) => {
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `concluded=${conclusion}\n`)
},
})
}
+173 -86
View File
@@ -11,14 +11,20 @@ import {
evaluateApproval as evaluateWithHistory,
listPullRequestReviews,
parseApprovalPolicy,
runApprovalCheck as runWithHistory,
runApprovalCheck as runPublisher,
publishApprovalPhase,
} from './check-approval.mjs'
const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options)
const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
const emptyCheckRuns = { total_count: 0, check_runs: [] }
const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?')
const withoutCheckRuns = api => (path, options) =>
isCheckRunsList(path) ? emptyCheckRuns : api(path, options)
const withEmptyReads = api => withoutCheckRuns((path, options) => path.includes('/comments?') ? [] : api(path, options))
const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) })
const runWithHistory = options => runPublisher({ ...options, api: withoutCheckRuns(options.api) })
const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) })
const publishedState = body => body.conclusion ?? body.status
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
@@ -198,7 +204,7 @@ test('accepts two one-point approvals and ignores reviews without write access',
assert.deepEqual(result.ignoredReviewers, ['reader'])
})
test('keeps one one-point approval pending without failing the status', async () => {
test('requires action for one one-point approval without failing the check', async () => {
const result = await evaluateApproval({
event: pullRequestEvent(),
policySource,
@@ -209,7 +215,7 @@ test('keeps one one-point approval pending without failing the status', async ()
throw new Error(`unexpected API path ${path}`)
},
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.points, 1)
})
@@ -228,12 +234,12 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn
},
})
const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api })
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.deepEqual(result.ignoredReviewers, ['former-writer'])
})
test('keeps the status pending on a write-capable change request while ignoring the author and read-only reviewers', async () => {
const statuses = []
test('requires action on a write-capable change request while ignoring the author and read-only reviewers', async () => {
const writes = []
const result = await runApprovalCheck({
event: pullRequestEvent({ author: 'author' }),
policySource,
@@ -251,45 +257,43 @@ test('keeps the status pending on a write-capable change request while ignoring
if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' }
if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' }
if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
if (path.includes('/statuses/')) {
statuses.push(options.body)
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) {
writes.push(options.body)
return {}
}
throw new Error(`unexpected API path ${path}`)
},
write: () => {},
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.description, '1 blocking change request.')
assert.equal(result.points, 2)
assert.deepEqual(result.blockers, ['blocker'])
assert.deepEqual(result.ignoredReviewers, ['reader'])
assert.deepEqual(statuses, [{
state: 'pending',
context: 'weighted approval',
description: 'Evaluating approval points.',
target_url: 'https://github.example/actions/runs/1',
}, {
state: 'pending',
context: 'weighted approval',
description: '1 blocking change request.',
target_url: 'https://github.example/actions/runs/1',
assert.deepEqual(writes, [{
name: 'weighted approval decision',
head_sha: HEAD_SHA,
status: 'completed',
conclusion: 'action_required',
details_url: 'https://github.example/actions/runs/1',
output: { title: '1 blocking change request.', summary: '1 blocking change request. [Workflow run](https://github.example/actions/runs/1)' },
}])
})
test('keeps drafts pending without reading reviews', async () => {
test('requires action on drafts without reading reviews', async () => {
const result = await evaluateApproval({
event: pullRequestEvent({ draft: true }),
policySource,
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
api: async () => { throw new Error('draft evaluation must not call GitHub') },
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.points, 0)
assert.match(result.description, /draft pull request/u)
})
test('publishes the required status and replaces stale success with error on evaluation failure', async () => {
test('publishes the decision check run and concludes failure when evaluation fails', async () => {
const calls = []
const output = []
const result = await runApprovalCheck({
@@ -301,21 +305,24 @@ test('publishes the required status and replaces stale success with error on eva
calls.push({ path, options })
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' }
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
throw new Error(`unexpected API path ${path}`)
},
write: line => output.push(line),
})
assert.equal(result.state, 'success')
assert.deepEqual(calls.at(-1), {
path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
path: '/repos/deepseek-harness/deepseek-harness/check-runs',
options: {
method: 'POST',
body: {
state: 'success',
context: 'weighted approval',
description: '2/2 approval points.',
target_url: 'https://github.example/actions/runs/1',
name: 'weighted approval decision',
head_sha: HEAD_SHA,
status: 'completed',
conclusion: 'success',
details_url: 'https://github.example/actions/runs/1',
output: { title: '2/2 approval points.', summary: '2/2 approval points. [Workflow run](https://github.example/actions/runs/1)' },
},
},
})
@@ -330,7 +337,8 @@ test('publishes the required status and replaces stale success with error on eva
runUrl: 'https://github.example/actions/runs/2',
api: async (path, options = {}) => {
if (path.includes('/reviews?')) throw new Error('reviews unavailable')
if (path.includes('/statuses/')) {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) {
failures.push({ path, options })
return {}
}
@@ -338,9 +346,10 @@ test('publishes the required status and replaces stale success with error on eva
},
write: () => {},
}), /reviews unavailable/u)
assert.equal(failures[0].options.body.state, 'pending')
assert.equal(failures[1].options.body.state, 'error')
assert.equal(failures[1].options.body.description, 'Approval evaluation failed.')
assert.deepEqual(failures.map(({ path }) => path), ['/repos/deepseek-harness/deepseek-harness/check-runs'])
assert.equal(failures[0].options.body.status, 'completed')
assert.equal(failures[0].options.body.conclusion, 'failure')
assert.equal(failures[0].options.body.output.title, 'Approval evaluation failed.')
})
test('sends authenticated JSON and escapes an API error body', async () => {
@@ -383,7 +392,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999
})
assert.equal(result.points, expectedPoints)
assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`)
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
assert.equal(result.state, expectedPoints === 2 ? 'success' : 'action_required')
assert.equal(measurements, 1)
assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
})
@@ -403,7 +412,7 @@ test('does not fetch ownership when a change request blocks approval', async ()
})
assert.equal(measurements, 0)
assert.equal(result.points, 2)
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
})
test('does not fetch history when approvals already have two-point weights', async () => {
@@ -415,7 +424,7 @@ test('does not fetch history when approvals already have two-point weights', asy
assert.equal(result.points, 2)
})
test('publishes error when production attribution fails', async () => {
test('concludes failure when production attribution fails', async () => {
const states = []
await assert.rejects(runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
@@ -424,30 +433,46 @@ test('publishes error when production attribution fails', async () => {
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
if (path.includes('/comments?')) return []
states.push(options.body.state)
if (isCheckRunsList(path)) return emptyCheckRuns
states.push(publishedState(options.body))
return {}
},
}), /incomplete history/u)
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(states, ['failure'])
})
test('revokes a previous success before starting expensive attribution', async () => {
const states = []
await runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
getOwnership: async () => {
assert.deepEqual(states, ['pending'])
return { totalLines: 100, reviewerLines: { writer: 25 } }
},
test('revokes a published success without returning a completed run to in progress', async () => {
const calls = []
const checkRuns = [
{ id: 7, app: { slug: 'github-actions' }, conclusion: 'success' },
{ id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required' },
{ id: 9, app: { slug: 'other-app' }, conclusion: 'success' },
{ id: 10, app: { slug: 'github-actions' }, conclusion: 'skipped' },
{ id: 11, app: { slug: 'github-actions' }, conclusion: 'neutral' },
{ id: 12, app: { slug: 'github-actions' }, conclusion: null },
]
await publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'revoke',
api: async (path, options) => {
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
states.push(options.body.state)
return {}
calls.push({ path, method: options?.method, body: options?.body })
return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {}
},
})
assert.deepEqual(states, ['pending', 'success'])
assert.deepEqual(calls.map(({ path }) => path), [
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
'/repos/deepseek-harness/deepseek-harness/check-runs/7',
'/repos/deepseek-harness/deepseek-harness/check-runs/10',
'/repos/deepseek-harness/deepseek-harness/check-runs/11',
])
for (const call of calls.slice(1)) {
assert.deepEqual(call.body, {
status: 'completed',
conclusion: 'action_required',
details_url: 'https://github.example/run/1',
output: { title: 'Preparing approval evaluation.', summary: 'Preparing approval evaluation. [Workflow run](https://github.example/run/1)' },
})
}
})
for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) {
@@ -477,14 +502,67 @@ test('publishes setup phases without evaluating or installing dependencies', asy
const states = []
const options = {
event: pullRequestEvent(), runUrl: 'https://github.example/run/1',
api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) },
api: withEmptyReads(async (path, { body }) => { assert.match(path, /\/check-runs$/u); states.push(publishedState(body)) }),
}
await publishApprovalPhase({ ...options, phase: 'pending' })
await publishApprovalPhase({ ...options, phase: 'error' })
assert.deepEqual(states, ['pending', 'error'])
await publishApprovalPhase({ ...options, phase: 'revoke' })
await publishApprovalPhase({ ...options, phase: 'failure' })
assert.deepEqual(states, ['in_progress', 'failure'])
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
})
test('updates the head\'s GitHub Actions decision check run instead of creating another', async () => {
const calls = []
const checkRuns = [
{ id: 7, app: { slug: 'github-actions' }, conclusion: 'action_required' },
{ id: 8, app: { slug: 'other-app' }, conclusion: 'action_required' },
]
await publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure',
api: async (path, options) => {
calls.push({ path, method: options?.method })
return { total_count: checkRuns.length, check_runs: checkRuns }
},
})
assert.deepEqual(calls, [
{ path: `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, method: undefined },
{ path: '/repos/deepseek-harness/deepseek-harness/check-runs/7', method: 'PATCH' },
])
for (const response of [{}, { total_count: 101, check_runs: [] }, { total_count: 1, check_runs: [{ id: '7', app: { slug: 'github-actions' } }] }]) {
await assert.rejects(publishApprovalPhase({
event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', api: async () => response,
}), /check-run list response|valid id/u)
}
})
test('records each published conclusion so the failure step never replaces it', async () => {
const recorded = []
const api = async (path, options) => {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
if (path.includes('/comments?')) return []
assert.match(path, /\/check-runs$/u)
return {}
}
await runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', api, write: () => {},
record: conclusion => recorded.push(conclusion),
})
await assert.rejects(runApprovalCheck({
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/2', write: () => {},
getMergedCount: async () => { throw new Error('history unavailable') },
record: conclusion => recorded.push(conclusion),
api: async path => {
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/reviews?')) return []
if (path.includes('/comments?')) return []
return {}
},
}), /history unavailable/u)
assert.deepEqual(recorded, ['success', 'failure'])
})
for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) {
test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
const result = await evaluateApproval({
@@ -493,7 +571,7 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089]
})
assert.equal(result.points, credit)
assert.equal(result.authorCredit.points, credit)
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.deepEqual(result.approvals, [])
})
}
@@ -509,7 +587,7 @@ test('combines author credit and reviewer ownership at the passing threshold', a
}
})
for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) {
for (const [mergedCount, state] of [[90, 'action_required'], [91, 'success'], [100, 'success']]) {
test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => {
const result = await evaluateApproval({
event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
@@ -532,14 +610,14 @@ test('blockers skip history and ignore non-write reviewers', async () => {
? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')]
: { permission: path.includes('/reader/') ? 'read' : 'write' },
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.equal(result.points, 1)
assert.equal(result.authorCredit, null)
assert.deepEqual(result.blockers, ['blocker'])
assert.deepEqual(result.ignoredReviewers, ['reader'])
})
test('drafts skip history and history failures revoke success with error', async () => {
test('drafts skip history and a failed history lookup concludes the check as failure', async () => {
await evaluateApproval({
event: pullRequestEvent({ draft: true }), policySource,
getMergedCount: async () => { throw new Error('draft must skip history') },
@@ -550,20 +628,20 @@ test('drafts skip history and history failures revoke success with error', async
getMergedCount: async () => { throw new Error('history unavailable') },
api: async (path, options) => {
if (path.includes('/reviews?')) return []
states.push(options.body.state)
states.push(publishedState(options.body))
},
}), /history unavailable/u)
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(states, ['failure'])
})
test('a score just below the threshold remains pending even if its display rounds to two', async () => {
test('a score just below the threshold requires action even if its display rounds to two', async () => {
const result = await evaluateApproval({
event: pullRequestEvent(), policySource, getMergedCount: async () => 50,
getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }),
api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
})
assert.equal(result.state, 'pending')
assert.equal(result.state, 'action_required')
assert.ok(result.points < 2)
})
@@ -587,14 +665,14 @@ test('the publisher counts merged history through the production API path', asyn
}
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
if (path.includes('/permission')) return { permission: 'write' }
states.push(options.body.state)
states.push(publishedState(options.body))
return {}
},
})
assert.equal(result.points, 2)
assert.equal(result.authorCredit.points, 0.55)
assert.equal(result.approvals[0].points, 1.45)
assert.deepEqual(states, ['pending', 'success'])
assert.deepEqual(states, ['success'])
assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).')
})
@@ -612,7 +690,7 @@ test('sufficient reviewer points and drafts publish without querying author hist
return {}
},
})
assert.equal(result.state, draft ? 'pending' : 'success')
assert.equal(result.state, draft ? 'action_required' : 'success')
assert.equal(result.authorCredit, null)
assert.equal(output[0], `Author credit: not evaluated (${draft ? 'draft' : 'reviewer points suffice'}).`)
}
@@ -678,7 +756,7 @@ test('delegates the sender fixed points once after the recipient approves, retai
}) })
const points = recipientState === 'APPROVED' ? 3 : 0
assert.equal(result.points, points)
assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending')
assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'action_required')
assert.deepEqual(result.blockers, [
...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
@@ -786,7 +864,7 @@ test('a sender submitted review takes back delegation, including comment-only an
}) })
assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0)
assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1)
assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending')
assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'action_required')
if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined)
}
}
@@ -829,7 +907,8 @@ test('an active delegate command requests review once and preserves other reques
if (options?.method === 'POST') { requests.push(options.body); return {} }
return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
}
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
return api(path, options)
},
})
@@ -857,7 +936,8 @@ test('inactive commands and drafts do not request review', async () => {
policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
api: async (path, options) => {
assert.ok(!path.endsWith('/requested_reviewers'))
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
return api(path, options)
},
})
@@ -873,7 +953,8 @@ test('failed review requests preserve the computed score and report the retry',
event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line),
api: async (path, options) => {
if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} }
if (path.endsWith('/requested_reviewers')) {
if (options?.method === 'POST') throw new Error('request rejected')
return failedRead ? {} : { users: [] }
@@ -883,7 +964,7 @@ test('failed review requests preserve the computed score and report the retry',
})
assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u)
assert.match(output.at(-1), /score is unchanged.*will retry/u)
assert.deepEqual(statuses, ['pending', 'success'])
assert.deepEqual(statuses, ['success'])
}
})
@@ -895,7 +976,8 @@ test('delegation dismisses only the sender old decisions and stays active across
const states = []
const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
const api = async (path, options) => {
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} }
if (path.endsWith('/dismissals')) {
calls.push(options)
const id = Number(path.split('/').at(-2))
@@ -919,13 +1001,13 @@ test('delegation dismisses only the sender old decisions and stays active across
assert.deepEqual(result.blockers, ['other'])
assert.equal(result.delegations.length, 1)
assert.deepEqual(result.delegations[0].reviewIds, [])
assert.deepEqual(states, ['pending', 'pending'])
assert.deepEqual(states, ['action_required'])
reviews.find(review => review.id === 21).state = 'DISMISSED'
for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) {
reviews.find(review => review.id === 20).state = state
const next = await evaluateDelegation({ api })
assert.equal(next.delegations.length, 1)
assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending')
assert.equal(next.state, state === 'APPROVED' ? 'success' : 'action_required')
}
reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30))
const reclaimed = await evaluateDelegation({ api })
@@ -939,7 +1021,8 @@ test('a new sender review arriving during dismissal is not dismissed or delegate
const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
api: async (path, options) => {
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
if (path.endsWith('/dismissals')) {
assert.ok(path.endsWith('/reviews/10/dismissals'))
reviews[0].state = 'DISMISSED'
@@ -954,14 +1037,15 @@ test('a new sender review arriving during dismissal is not dismissed or delegate
assert.deepEqual(result.blockers, ['turtle1999'])
})
test('a failed or unconfirmed dismissal prevents review requests and publishes error', async () => {
test('a failed or unconfirmed dismissal prevents review requests and concludes failure', async () => {
for (const rejected of [false, true]) {
const states = []
const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('turtle1999', 'CHANGES_REQUESTED')] })
await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
policySource, runUrl: 'https://github.example/run/1', write: () => {},
api: async (path, options) => {
if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} }
if (path.endsWith('/dismissals')) {
if (rejected) throw new Error('dismissal denied')
return { id: 10, state: 'CHANGES_REQUESTED' }
@@ -970,7 +1054,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e
return read(path, options)
},
}), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
assert.deepEqual(states, ['pending', 'error'])
assert.deepEqual(states, ['failure'])
}
})
@@ -983,7 +1067,8 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
getMergedCount: async () => 0, write: line => output.push(line),
api: async (path, options) => {
if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} }
if (path.includes('/comments?')) return comments(path)
return api(path, options)
},
@@ -996,7 +1081,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
})
assert.equal(pages.length, 2)
assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
assert.deepEqual(statuses.splice(0), ['pending', 'success'])
assert.deepEqual(statuses.splice(0), ['success'])
for (const [comments, message] of [
[() => { throw new Error('comment API unavailable') }, /comment API unavailable/u],
[() => ({}), /comments response is not an array/u],
@@ -1006,7 +1091,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing
[() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u],
]) {
await assert.rejects(run(comments), message)
assert.deepEqual(statuses.splice(0), ['pending', 'error'])
assert.deepEqual(statuses.splice(0), ['failure'])
}
})
@@ -1090,7 +1175,8 @@ test('later events reconcile all active delegations after a draft or replaced co
const effects = []
const requested = []
const api = async (path, options) => {
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
if (path.endsWith('/dismissals')) {
const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`))
effects.push(old.id)
@@ -1122,7 +1208,8 @@ test('already approved delegates need no new request', async () => {
})
const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
api: (path, options) => {
if (path.includes('/statuses/')) return {}
if (isCheckRunsList(path)) return emptyCheckRuns
if (path.includes('/check-runs')) return {}
assert.ok(!path.endsWith('/requested_reviewers'))
return read(path, options)
},
+12 -8
View File
@@ -10,9 +10,9 @@ on:
types: [completed]
permissions:
checks: write
contents: read
pull-requests: write
statuses: write
concurrency:
group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}
@@ -29,20 +29,20 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# SECURITY: the status-writing job executes policy from the trusted default
# SECURITY: the check-writing job executes policy from the trusted default
# branch and reads pull-request reviews and comments only as API data.
- name: Check out trusted approval policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Revoke previous approval status
- name: Revoke previous approval decision
id: revoke
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/review-ownership/check-approval.mjs pending
# SECURITY: dependency setup runs in the status-writing job.
run: node .github/review-ownership/check-approval.mjs revoke
# SECURITY: dependency setup runs in the check-writing job.
- if: steps.revoke.outputs.active == 'true'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
@@ -52,15 +52,19 @@ jobs:
- name: Install production lexer
if: steps.revoke.outputs.active == 'true'
run: python3 -m pip install -r .github/review-ownership/requirements.txt
- name: Publish weighted approval status
- name: Publish weighted approval decision
id: publish
if: steps.revoke.outputs.active == 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/review-ownership/check-approval.mjs
# A failed or cancelled run must not leave an earlier conclusion standing. The publish step
# records the conclusion it wrote, so a decision already published survives a later failure or
# cancellation, and a cancelled publish step that wrote nothing still concludes failure.
- name: Publish approval setup failure
if: failure() && steps.revoke.outputs.active == 'true'
if: (failure() || cancelled()) && steps.publish.outputs.concluded == ''
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: node .github/review-ownership/check-approval.mjs error
run: node .github/review-ownership/check-approval.mjs failure
+2 -2
View File
@@ -24,8 +24,8 @@
en: 850434e8ca162221
zh: 4893613f97a21eb8
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
en: f40edcf903a51f3f
zh: 8a9a915451047c8b
/dsh-cli-behavior-reference/source-execution:
en: 05c2b23790f7b296
zh: 51f809020fb230a1
+1 -1
View File
@@ -134,7 +134,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission
## Shared deployment behavior
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
+1 -1
View File
@@ -136,7 +136,7 @@ dsh web --help
## 共享部署行为
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
@@ -0,0 +1,10 @@
/** Frame one text or tool response from the local Messages endpoint. */
export function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
return [
{ type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: content },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
}
@@ -0,0 +1,10 @@
/** Freeze only the isolated runtime's wall clock before reminder activation starts. */
export function apply(ctx) {
const original = Date.now
const now = Number(process.env.DSH_TEST_SCHEDULE_NOW)
ctx.effect(() => {
Date.now = () => now
return () => { Date.now = original }
})
ctx.provide('sdkTestClock', { ready: true })
}
@@ -10,6 +10,8 @@ import { execa } from 'execa'
import { describe, expect, it, onTestFinished } from 'vitest'
import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
import { messagesResponse } from './fixtures/messages-response.ts'
const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
const launch = resolveExampleLaunch({
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
@@ -17,16 +19,6 @@ const launch = resolveExampleLaunch({
})
const decompress = promisify(zstdDecompress)
/** Frame one text or tool response from the local Messages endpoint. */
function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
return [
{ type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: content },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
}
function waitForLine(
lines: string[],
@@ -66,7 +58,11 @@ describe('Python SDK dsh profile keyless smoke', () => {
{ label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false },
{ label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
{ label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
])('$label', async ({ envValue, editorEnabled }) => {
{ label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true },
])('$label', async (scenario) => {
const { envValue, editorEnabled } = scenario
const ptc = 'ptc' in scenario && scenario.ptc
const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-'))
const editorPatch = join(root, 'editor.patch.yml')
if (editorEnabled) await writeFile(editorPatch, [
@@ -103,6 +99,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
DSH_HOME: join(root, '.dsh'),
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_DISABLED: '1',
DSH_TOOLS_MODE: ptc ? 'ptc' : 'native',
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
@@ -176,9 +173,23 @@ describe('Python SDK dsh profile keyless smoke', () => {
const toolNames = tools.map(tool => tool.name)
expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' })
expect(modelRequests[0]?.max_tokens).toBe(1234)
expect(toolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search']))
expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled)
expect(toolNames).not.toContain('list_subagent_models')
const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork',
'present', 'ask_user_question', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update']
const requestText = JSON.stringify(modelRequests[0])
if (ptc) {
expect(toolNames).toEqual(['run_code'])
for (const tool of expectedTools) expect(requestText.includes(`${tool}:`), tool).toBe(true)
} else {
expect(toolNames).toEqual(expect.arrayContaining(expectedTools))
expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled)
expect(toolNames).not.toContain('list_subagent_models')
}
for (const text of ['Tokens prefixed with @', 'Prefer showing the primary results',
'Time sampled while preparing']) {
expect(requestText.includes(text), text).toBe(true)
}
expect(requestText).not.toContain('Current DeepSeek Harness implementation checkout:')
expect(requestText).not.toContain('Current DeepSeek Harness Web GUI URL:')
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
@@ -0,0 +1,72 @@
/** Reminder activation must share SDK ownership and initialized model limits after process restart. */
import { createServer } from 'node:http'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
import { expect, it, onTestFinished, vi } from 'vitest'
import { messagesResponse } from './fixtures/messages-response.ts'
it('delivers an overdue reminder with the SDK route and lets the client resume that same Session', async () => {
const root = await mkdtemp(join(tmpdir(), 'sdk-schedule-resume-'))
const patch = join(root, 'clock.patch.yml')
const fixture = fileURLToPath(new URL('./fixtures/schedule-clock.mjs', import.meta.url))
await writeFile(patch, [
'- insert:', ' - id: sdk-test-clock', ` name: ${JSON.stringify(fixture)}`,
'- id: sdk-jsonrpc-server', ' inject: [sdkAppStartup, loader, sdkTestClock]', '',
].join('\n'))
const requests: Record<string, unknown>[] = []
const restored: Record<string, unknown>[] = []
let restarting = false
const server = createServer((incoming, response) => {
let body = ''
incoming.setEncoding('utf8')
incoming.on('data', (chunk: string) => { body += chunk })
incoming.on('end', () => {
const request = JSON.parse(body) as Record<string, unknown>
requests.push(request)
if (restarting) restored.push(request)
const content = requests.length === 1 ? {
type: 'tool_use', id: 'create-reminder', name: 'schedule_create',
input: { title: 'Check SDK', prompt: 'Report the SDK reminder', after_seconds: 86400 },
} : { type: 'text', text: 'ready' }
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(messagesResponse(content, requests.length === 1 ? 'tool_use' : 'end_turn'))
})
})
const clients: DeepSeekHarness[] = []
onTestFinished(async () => {
try { await Promise.all(clients.map(client => client.close())) } finally {
try {
await new Promise<void>((resolve, reject) => {
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
})
} finally { await rm(root, { recursive: true, force: true }) }
}
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
clearAmbientProxyEnv(env)
const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
model: 'deepseek-v4-pro', maxTokens: 5678, initializeTimeoutMs: 30_000,
env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-01T00:00:00Z')) } })
clients.push(first)
expect((await first.session('saved').run('Set a reminder')).finalResponse).toBe('ready')
expect(requests).toHaveLength(2)
await first.close()
restarting = true
const second = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch],
model: 'deepseek-v4-flash', maxTokens: 1234, initializeTimeoutMs: 30_000,
env: { ...env, DSH_TEST_SCHEDULE_NOW: String(Date.parse('2030-01-03T00:00:00Z')) } })
clients.push(second)
await second.start()
await vi.waitFor(() => { expect(restored).toHaveLength(1) }, { timeout: 30_000 })
expect(restored[0]).toMatchObject({ model: 'deepseek-v4-flash', max_tokens: 1234 })
const session = await second.resumeSession('saved')
expect((await session.run('Continue after the reminder')).finalResponse).toBe('ready')
})
@@ -0,0 +1,84 @@
/** Keyless SDK questions through the shipped profile, native tools, and both tool-call modes. */
import { createServer } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client'
import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts'
import { expect, it, onTestFinished } from 'vitest'
import { messagesResponse } from './fixtures/messages-response.ts'
it.each([
{ mode: 'native', cancel: false }, { mode: 'ptc', cancel: false }, { mode: 'native', cancel: true },
])('handles a built-in question through $mode (cancel: $cancel)', async ({ mode, cancel }) => {
const root = await mkdtemp(join(tmpdir(), 'sdk-question-profile-'))
const requests: Record<string, unknown>[] = []
const questions = [{ id: 'format', question: 'Which format?', options: [{ label: 'CSV' }, { label: 'JSON' }] }]
const server = createServer((incoming, response) => {
let body = ''
incoming.setEncoding('utf8')
incoming.on('data', (chunk: string) => { body += chunk })
incoming.on('end', () => {
requests.push(JSON.parse(body) as Record<string, unknown>)
const tool = mode === 'ptc' ? {
type: 'tool_use', id: 'question-call', name: 'run_code', input: {
code: `return await tools.ask_user_question(${JSON.stringify({ questions })});`, description: 'Ask which output format to use',
},
} : { type: 'tool_use', id: 'question-call', name: 'ask_user_question', input: { questions } }
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(messagesResponse(requests.length === 1 ? tool : { type: 'text', text: 'answer received' },
requests.length === 1 ? 'tool_use' : 'end_turn'))
})
})
const state: { harness?: DeepSeekHarness } = {}
onTestFinished(async () => {
try { await state.harness?.close() } finally {
try {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
}
finally { await rm(root, { recursive: true, force: true }) }
}
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
clearAmbientProxyEnv(env)
const seen: SdkUserQuestionRequest[] = []
const entered = Promise.withResolvers<AbortSignal>()
const harness = state.harness = new DeepSeekHarness({
cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000,
onUserQuestion: async (request, { signal }) => {
expect(signal.aborted).toBe(false)
seen.push(request)
entered.resolve(signal)
if (cancel) return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('question cancelled')) }, { once: true })
})
return { answers: [{ id: 'format', selected: ['CSV'] }] }
},
})
const pending = harness.session('question-session').run('Ask which output format to use')
if (cancel) {
const settled = pending.catch(() => undefined)
const signal = await entered.promise
await harness.close()
await settled
expect(signal.aborted).toBe(true)
expect(requests).toHaveLength(1)
return
}
const result = await pending
expect(seen).toEqual([{ requestId: expect.any(String) as string, sessionId: 'question-session', questions }])
expect(result.finalResponse).toBe('answer received')
expect(requests).toHaveLength(2)
expect(requests[1]?.max_tokens).toBe(1234)
expect(JSON.stringify(requests[1]?.messages)).toContain('CSV')
expect(result.events.some(event => event.type === 'tool/result' && event.data.error !== undefined)).toBe(false)
})
+3
View File
@@ -4,6 +4,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="DSH" />
<link rel="icon" type="image/svg+xml" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" media="(prefers-color-scheme: light)" />
<title>DSH Local Build</title>
+3 -1
View File
@@ -26,7 +26,8 @@
"dev": "vite",
"watch": "vite build --watch --no-emptyOutDir",
"build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz",
"serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1"
"serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1",
"render:app-icons": "tsx scripts/render-app-icons.ts"
},
"license": "MIT",
"devDependencies": {
@@ -56,6 +57,7 @@
"playwright": "^1.49.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sharp": "0.35.5",
"typescript": "^6.0.3",
"vite": "^6.4.3",
"vitest": "^4.1.11",
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+18 -4
View File
@@ -3,13 +3,27 @@
"short_name": "DSH",
"start_url": "./",
"scope": "./",
"display": "fullscreen",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Render the install icons of the Web application from the official
* application tile.
*
* A browser installs a Web App only with a raster 192px and a 512px icon, masks
* the icon it puts on the home screen, and has no manifest support for iOS,
* which reads `apple-touch-icon` instead. The whale favicon is the bare mark
* drawn edge to edge: handed to a launcher directly it loses its fins to the
* mask, and it carries neither the tile nor a background of its own. This
* script rasterizes the official application tile (`apps/desktop/resources/icon.svg`,
* the artwork the desktop application ships) into the committed PNGs under
* `public/`. Every variant crops the tile's drop shadow, which belongs to a
* desktop window rather than a launcher; the maskable variant additionally
* scales the whale inside the safe zone a platform mask may crop, leaving the
* tile's own gradient to fill the icon so no mask meets a seam.
*
* The committed PNGs are the output; rerun
* `pnpm --filter @deepseek-ai/dsh-web-frontend run render:app-icons` after the
* tile artwork changes.
* @module render-app-icons
*/
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { assertNever } from '@deepseek-ai/dsh-util-values'
/** Vector source owned by the desktop application; 1104 units square with the tile inset. */
export const TILE_SOURCE = fileURLToPath(new URL('../../desktop/resources/icon.svg', import.meta.url))
/** Directory the generated PNGs are committed to. */
export const ICON_OUTPUT_DIRECTORY = fileURLToPath(new URL('../public/', import.meta.url))
/** Tile edge in source units; every render density is expressed against it. */
const TILE_EDGE = 1024
/** Tile inset in the source canvas: the drop shadow occupies the surrounding band. */
const TILE_INSET = { x: 40, y: 32 } as const
/** Tile centre in source units, the fixed point of the maskable scale. */
const TILE_CENTER = { x: TILE_INSET.x + TILE_EDGE / 2, y: TILE_INSET.y + TILE_EDGE / 2 } as const
/** Density at which the source's own 1:1 scale renders. */
const SOURCE_DENSITY = 72
/** Samples per output pixel along each axis before reduction. */
const RENDER_SCALE = 4
/**
* Fraction of a maskable icon the artwork keeps: the platform may crop the
* border outside it. The tile's own ink reaches 0.461 of its edge from the
* centre, so 0.868 is the largest scale a circular mask still shows whole;
* `pwa-manifest.e2e.ts` checks the committed artwork against that circle.
*/
export const MASKABLE_ARTWORK_SCALE = 0.82
/** Radius a platform mask is guaranteed to show, as a fraction of the icon edge. */
export const MASKABLE_SAFE_RADIUS_RATIO = 0.4
/** Root element of the source document, rewritten to crop the canvas to the tile. */
const SOURCE_ROOT = /^<svg[^>]*>/u
/** The tile group's drop shadow; stripped because a launcher icon has no window around it. */
const TILE_SHADOW_GROUP = '<g filter="url(#filter0_d_1637_371)">'
/** The whale group inside the tile; the only element a maskable icon scales. */
const TILE_ARTWORK_GROUP = '<g filter="url(#filter1_ddi_1637_371)">'
/** One committed install icon: the manifest or link member it serves and its edge in pixels. */
export interface AppIconVariant {
/** File name inside `public/`. */
readonly file: string
/** Square edge in pixels. */
readonly size: number
/** Which rendering the platform receives. */
readonly kind: 'tile' | 'maskable'
}
/**
* Install icons the Web application ships. Chrome requires the 192px and 512px
* raster pair to offer installation, prefers a maskable icon for the home
* screen, and iOS reads the touch icon alone.
*/
export const APP_ICON_VARIANTS: readonly AppIconVariant[] = [
{ file: 'icon-192.png', size: 192, kind: 'tile' },
{ file: 'icon-512.png', size: 512, kind: 'tile' },
{ file: 'icon-maskable-512.png', size: 512, kind: 'maskable' },
{ file: 'apple-touch-icon.png', size: 180, kind: 'tile' },
]
/** The cropped 1:1 root every variant shares, at the tile's own square extent. */
function croppedRoot(): string {
return `<svg width="${String(TILE_EDGE)}" height="${String(TILE_EDGE)}"`
+ ` viewBox="${String(TILE_INSET.x)} ${String(TILE_INSET.y)} ${String(TILE_EDGE)} ${String(TILE_EDGE)}"`
+ ' fill="none" xmlns="http://www.w3.org/2000/svg">'
}
/** A scale about the tile centre, so a masked crop stays symmetric. */
function aboutCenter(scale: number): string {
return `translate(${String(TILE_CENTER.x)} ${String(TILE_CENTER.y)}) scale(${String(scale)})`
+ ` translate(${String(-TILE_CENTER.x)} ${String(-TILE_CENTER.y)})`
}
/** Replace the source root so the icon canvas is exactly the tile. */
function reparent(source: string): string {
const trimmed = source.trim()
const root = SOURCE_ROOT.exec(trimmed)
if (root === null) throw new Error('app icons: the tile source has no root element')
if (!trimmed.includes(TILE_SHADOW_GROUP)) throw new Error('app icons: the tile source has no shadowed tile group')
if (!trimmed.includes(TILE_ARTWORK_GROUP)) throw new Error('app icons: the tile source has no artwork group')
return `${croppedRoot()}${trimmed.slice(root[0].length).replace(TILE_SHADOW_GROUP, '<g>')}`
}
/**
* Rewrite the tile source so the icon canvas is exactly the tile.
* @param source - complete `icon.svg` document.
* @returns an SVG document whose 1024-unit square is the tile without its shadow.
*/
export function tileDocument(source: string): string {
return reparent(source)
}
/**
* Rewrite the tile source into a maskable icon. The tile keeps its own gradient
* and extent, so the icon's border is the real tile rather than an extension;
* only the whale scales into the safe zone a platform mask may crop.
* @param source - complete `icon.svg` document.
* @returns an SVG document whose artwork survives a circular mask whole.
*/
export function maskableDocument(source: string): string {
const relocated = reparent(source)
return relocated.replace(
TILE_ARTWORK_GROUP,
`${TILE_ARTWORK_GROUP.slice(0, -1)} transform="${aboutCenter(MASKABLE_ARTWORK_SCALE)}">`,
)
}
/**
* Rasterize one rewritten document at a square edge.
* @param document - output of {@link tileDocument} or {@link maskableDocument}.
* @param size - square edge in pixels.
* @returns the PNG bytes.
*/
async function renderPng(document: string, size: number): Promise<Buffer> {
const { default: sharp } = await import('sharp')
const renderSize = Math.max(TILE_EDGE, size * RENDER_SCALE)
// A separate raster preserves the source's filters at full resolution and
// prevents SVG resize optimizations from bypassing the downsampling step.
const raster = await sharp(Buffer.from(document), { density: SOURCE_DENSITY * renderSize / TILE_EDGE })
.png()
.toBuffer()
return sharp(raster)
.resize(size, size, { kernel: 'lanczos3' })
.png()
.toBuffer()
}
function documentForVariant(source: string, kind: AppIconVariant['kind']): string {
switch (kind) {
case 'tile': return tileDocument(source)
case 'maskable': return maskableDocument(source)
default: return assertNever(kind)
}
}
/**
* Render every install icon from the tile source.
* @param source - complete `icon.svg` document.
* @returns one PNG per {@link APP_ICON_VARIANTS} entry, in declaration order.
*/
export async function renderAppIcons(source: string): Promise<readonly { variant: AppIconVariant; png: Buffer }[]> {
return Promise.all(APP_ICON_VARIANTS.map(async variant => ({
variant,
png: await renderPng(
documentForVariant(source, variant.kind),
variant.size,
),
})))
}
async function main(): Promise<void> {
const icons = await renderAppIcons(await readFile(TILE_SOURCE, 'utf8'))
await Promise.all(icons.map(async ({ variant, png }) => {
await writeFile(resolve(ICON_OUTPUT_DIRECTORY, variant.file), png)
}))
console.info(`app icons: wrote ${icons.map(icon => icon.variant.file).join(', ')} to ${ICON_OUTPUT_DIRECTORY}`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
+51 -28
View File
@@ -357,7 +357,7 @@ describe('web e2e: agent-preset selection', () => {
await writeComposerDraft(page, composer, '')
}, 90_000)
it('preserves the current mode while New Session uses the reset Standard default', async () => {
it('preserves the current mode until New Session reuses its blank under the reset Standard default', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
@@ -381,17 +381,23 @@ describe('web e2e: agent-preset selection', () => {
await page.keyboard.press('Escape')
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
// The current Session is still the Workspace's only blank, so New Session names it for
// reuse and the Host runs it under the reset default instead of its kept Minimal mode.
const currentSessionId = await page.evaluate(() =>
(JSON.parse(localStorage.getItem('dsh.sessions.current') ?? '{}') as { sessionId?: string }).sessionId)
expect(currentSessionId).toBeDefined()
const creation = page.waitForResponse('**/api/session/create')
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
const created = await creation
expect(created.request().postDataJSON()).not.toHaveProperty('payload.args.request.sessionId')
expect(created.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', currentSessionId)
expect(created.request().postDataJSON()).not.toHaveProperty('payload.args.request.agentPreset')
const createdBody = await created.json() as { result: { value: { sessionId: SessionId; agentPreset: string } } }
expect(createdBody).toMatchObject({ result: { ok: true, value: { agentPreset: 'standard' } } })
const freshSessionId = createdBody.result.value.sessionId
const freshConversation = page.locator(`[data-conversation-session="${freshSessionId}"]`)
await freshConversation.waitFor()
expect(createdBody).toMatchObject({ result: { ok: true, value: { sessionId: currentSessionId, agentPreset: 'standard' } } })
const newSessionId = createdBody.result.value.sessionId
const newConversation = page.locator(`[data-conversation-session="${newSessionId}"]`)
await newConversation.waitFor()
await page.reload()
await freshConversation.waitFor()
await newConversation.waitFor()
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
await openSettings(page, 'en')
@@ -405,7 +411,7 @@ describe('web e2e: agent-preset selection', () => {
await reopened.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Set as new task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Close' }).last().click()
await expect.poll(() => livePreset(scaffold, freshSessionId), { timeout: 15_000 }).toBe('standard')
await expect.poll(() => livePreset(scaffold, newSessionId), { timeout: 15_000 }).toBe('standard')
await page.getByRole('button', { name: 'Standard mode' }).waitFor({ timeout: 10_000 })
})
@@ -445,40 +451,53 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-creator'))
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const creator = scaffold.ctx.agents.list()[0]
if (creator === undefined) throw new Error('Connecting the workspace did not create a Session')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('standard')
const standard = scaffold.ctx.agents.list()[0]
if (standard === undefined) throw new Error('Connecting the workspace did not create a Session')
expect(scaffold.ctx.agentPresets.composedPreset(standard.ctx)).toBe('standard')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
const composerOf = (sessionId: SessionId) =>
page.locator(`[data-conversation-session="${sessionId}"] [data-composer-input][contenteditable="true"]`)
const draft = 'Keep this unsent plugin idea.'
await writeComposerDraft(page, composer, draft)
await writeComposerDraft(page, composerOf(standard.id), draft)
await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click()
// The Creator entry reuses the draft's blank Session and names its preset in the creation request.
await Promise.all([
// The draft's blank lists Standard, so the Creator entry names no blank and the Host creates a separate Creator Session.
const [creation] = await Promise.all([
page.waitForResponse('**/api/session/create'),
page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(),
])
expect(creation.request().postDataJSON()).toHaveProperty('payload.args.request.agentPreset', 'cordis')
expect(creation.request().postDataJSON()).not.toHaveProperty('payload.args.request.sessionId')
const creationBody = await creation.json() as { result: { ok: boolean; value: { sessionId: SessionId; agentPreset: string } } }
expect(creationBody).toMatchObject({ result: { ok: true, value: { agentPreset: 'cordis' } } })
const creator = scaffold.ctx.agents.get(creationBody.result.value.sessionId)
if (creator === undefined) throw new Error('The Creator entry did not publish its Session')
expect(creator.id).not.toBe(standard.id)
const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' })
await picker.waitFor()
await composer.filter({ hasText: draft }).waitFor()
expect(await composer.innerText()).toBe(draft)
const creatorComposer = composerOf(creator.id)
await creatorComposer.waitFor()
expect((await creatorComposer.innerText()).trim()).toBe('')
expect(await picker.getAttribute('aria-haspopup')).toBe('menu')
expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0)
expect(creator.session.header.agentPreset).toBe('cordis')
expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis')
expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name))
.toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']))
// The chip may also select Creator once the list updates; the registry records the switch only once.
expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected'
&& event.data.agentPreset === 'cordis')).toHaveLength(1)
// The Session starts under Creator; a chip selection after the list updates records no switch.
expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected')).toEqual([])
expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
// The draft's blank keeps its preset and starts no turn.
expect(scaffold.ctx.sessionProjections.checkpoint(standard.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(standard.ctx)).toBe('standard')
expect(standard.session.snapshotEvents().some(event => event.type === 'agent-preset/selected'
|| event.type === 'user/message' || event.type === 'turn/start')).toBe(false)
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
expect(scaffold.ctx.agentPresets.defaultId).toBe('standard')
// Close a fixture turn on the real Creator Session without requesting a model.
await writeComposerDraft(page, composer, '')
creator.session.append('turn/start', { turn: 1 })
creator.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Creator fixture completed without a model call.' }], source: { kind: 'user' },
@@ -486,14 +505,18 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
creator.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(creator.session)
await page.getByText('Creator fixture completed without a model call.', { exact: true }).waitFor()
// New Session returns to the Standard blank and its unsent draft.
const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' })
await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()])
expect(scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)).toBeDefined()
const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)!
expect(next.id).not.toBe(creator.id)
expect(next.session.header.agentPreset).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(next.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard')
const [reopening] = await Promise.all([page.waitForResponse('**/api/session/create'), newSession.click()])
expect(reopening.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId', standard.id)
expect(await reopening.json()).toMatchObject({ result: { ok: true, value: { sessionId: standard.id, agentPreset: 'standard' } } })
await composerOf(standard.id).filter({ hasText: draft }).waitFor()
expect(await composerOf(standard.id).innerText()).toBe(draft)
const reopened = scaffold.ctx.agents.get(standard.id)
if (reopened === undefined) throw new Error('New Session did not keep the Standard blank live')
expect(reopened.session.header.agentPreset).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(reopened.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(reopened.ctx)).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
+43
View File
@@ -0,0 +1,43 @@
/** Committed install icon dimensions and the generator's SVG crop and safe-zone scale. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
APP_ICON_VARIANTS, ICON_OUTPUT_DIRECTORY, MASKABLE_ARTWORK_SCALE, TILE_SOURCE,
maskableDocument, tileDocument,
} from '../scripts/render-app-icons.ts'
import { pngSize } from './png-size.ts'
describe('web install icons', () => {
it('ships every declared variant at its declared edge', async () => {
for (const variant of APP_ICON_VARIANTS) {
expect(pngSize(await readFile(join(ICON_OUTPUT_DIRECTORY, variant.file))))
.toEqual([variant.size, variant.size])
}
})
it('crops the desktop shadow and scales only the maskable artwork', async () => {
const source = await readFile(TILE_SOURCE, 'utf8')
const tile = tileDocument(source)
const maskable = maskableDocument(source)
expect(tile).toContain('viewBox="40 32 1024 1024"')
expect(tile).not.toContain('<g filter="url(#filter0_d_1637_371)">')
expect(tile).not.toContain(`scale(${MASKABLE_ARTWORK_SCALE})`)
expect(tile).toContain('<g filter="url(#filter1_ddi_1637_371)">')
expect(tile).toContain(source.match(/<path\b[^>]*>/u)![0])
expect(tile).toContain(source.match(/<defs>[\s\S]*<\/defs>/u)![0])
expect(maskable).toContain(`transform="translate(552 544) scale(${MASKABLE_ARTWORK_SCALE}) translate(-552 -544)"`)
})
it('rejects non-PNG bytes and incomplete image headers', () => {
expect(() => pngSize(Buffer.from('<svg/>'))).toThrow(/PNG IHDR/)
expect(() => pngSize(Buffer.alloc(33))).toThrow(/PNG IHDR/)
})
it('fails loudly on a source without the tile it transforms', () => {
expect(() => tileDocument('')).toThrow(/no root element/)
expect(() => tileDocument('<svg></svg>')).toThrow(/no shadowed tile group/)
expect(() => maskableDocument('<svg><g filter="url(#filter0_d_1637_371)"></g></svg>'))
.toThrow(/no artwork group/)
})
})
@@ -0,0 +1,367 @@
/**
* Scroll continuity when "On next message" folds a process group the reader opened. The latest Turn has
* one Step; the reader opened its group and expanded the reasoning inside, so the group body is capped and
* scrollable, then sends the next message from the floor. Agent maintenance keeps the accepted message in
* the inbox, so its echo folds the group before the new Turn starts, and only the fold moves the view while
* the scrollport is sampled on every animation frame.
*
* - A long answer leaves the folded group above the viewport. The scroll position changes in one direction
* only: the fold never scrolls the view away and then snaps it back.
* - A short answer leaves fold room below the tail. Growth fills that room without scrolling, so the running
* status keeps its entrance slide when the new Step's first row appears instead of moving in one frame.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, openSettings } from './support.ts'
declare global {
interface Window {
openFoldTrace?: { readonly frames: TraceFrame[]; readonly slides: number[]; stop(): void }
}
}
const MODE = webSnapshotMode()
const ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])'
/** Settled Turns before the latest one, so the transcript overflows the scrollport. */
const TURNS = 7
const NEXT_INPUT = 'OPENFOLD_NEXT_INPUT'
const REPLY = 'OPENFOLD_REPLY'
/** One painted frame of the main scrollport. */
interface TraceFrame {
readonly top: number
readonly floor: number
/** Height of the fold room below the transcript. */
readonly spacer: number
/** The column marks the tail as pinned, which runs the status slide at zero duration. */
readonly pinned: boolean
/** The running status's top relative to the scrollport, when it is mounted. */
readonly status: number | null
readonly folding: boolean
}
function paragraphs(marker: string, count: number, text: string): string {
return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}`
}
/** Settled history whose latest Turn is one Step: long reasoning, then an answer of `answerParagraphs`. */
function seededLog(id: string, answerParagraphs: number): { log: string; search: string } {
const base = createChatScrollFixture({ markerPrefix: `OPENFOLD${id.toUpperCase()}`, title: `OPENFOLD ${id}`, turns: TURNS })
const parsed = parseSeedFixture(base.log)
const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === TURNS)
const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary)
drafts.push(
{ type: 'turn/start', data: { turn: TURNS } },
{ type: 'step/start', data: { turn: TURNS, step: 1 } },
{ type: 'user/message', data: createUserMessage({
content: [{ type: 'text', text: 'OPENFOLD_LAST_USER' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'assistant/message', surfaceOp: 'append', data: {
turn: TURNS, step: 1, stream: [], message: createAssistantMessage({
content: [
{ type: 'reasoning', text: paragraphs('OPENFOLD_REASONING', 30, 'Reasoning weighs one more consideration, paragraph') },
{ type: 'text', text: paragraphs('OPENFOLD_ANSWER', answerParagraphs, 'Final answer paragraph of the single Step') },
],
source: { provider: 'fixture', model: 'fixture' },
}),
} },
{ type: 'step/end', data: { turn: TURNS, step: 1 } },
{ type: 'turn/end', data: { turn: TURNS, reason: { kind: 'completed' } } },
)
return {
log: [parsed.headerLine, ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n'),
search: base.markers.user(1),
}
}
function reply(): ReplayEntry {
const text = `${REPLY} short answer.`
const finish: StreamChunk[] = [
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
return { kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
...finish,
] }
}
async function stableScroll(page: Page): Promise<void> {
await page.evaluate(async (selector) => {
const host = document.querySelector<HTMLElement>(selector)!
let previous = host.scrollTop
const started = performance.now()
let stableSince = started
await new Promise<void>((resolveStable, reject) => {
const check = (): void => {
const now = performance.now()
const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null
if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now
previous = host.scrollTop
if (now - stableSince >= 300) resolveStable()
else if (now - started >= 20_000) reject(new Error(`scroll did not settle: top ${String(host.scrollTop)}`))
else requestAnimationFrame(check)
}
requestAnimationFrame(check)
})
}, ROOT)
}
async function toBottom(page: Page): Promise<void> {
const button = page.locator(ROOT).getByRole('button', { name: 'Back to bottom', exact: true })
if (await button.count() > 0) await button.click()
await stableScroll(page)
await expect.poll(() => page.locator(`${ROOT} [data-chat-following-tail]`).count()).toBe(1)
}
async function openSessionBySearch(page: Page, query: string): Promise<void> {
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query)
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
await results.click()
}
async function choosePreferences(page: Page): Promise<void> {
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..')
await timing.getByRole('button').click()
await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
const details = dialog.getByText('Work details', { exact: true }).locator('../..')
await details.getByRole('button').click()
await page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
}
/** Open the latest Turn's process group and expand its reasoning until the group body caps its height. */
async function openLatestGroup(page: Page): Promise<void> {
const group = page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS)}"]`).last()
const header = group.locator('button[aria-expanded]').first()
if (await header.getAttribute('aria-expanded') !== 'true') await header.click()
const thought = group.locator('[data-variant="think"]').getByRole('button').first()
if (await thought.getAttribute('aria-expanded') !== 'true') await thought.click()
const body = group.locator('[data-step-process-body]')
await expect.poll(() => body.evaluate(element => element.scrollHeight > element.clientHeight + 100)).toBe(true)
// Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there.
await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() })
await page.mouse.move(0, 0)
}
/**
* Sample the scrollport on every animation frame, and record the duration of every status entrance slide
* that a flow change starts.
*/
async function startTrace(page: Page): Promise<void> {
await page.evaluate((selector) => {
const host = document.querySelector<HTMLElement>(selector)!
const column = host.querySelector<HTMLElement>('[data-chat-flow]')!
const spacer = host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
const frames: TraceFrame[] = []
const slides: number[] = []
let frame = 0
const tick = (): void => {
const status = host.querySelector<HTMLElement>('[data-chat-running]')
frames.push({
top: host.scrollTop,
floor: Math.max(0, host.scrollHeight - host.clientHeight),
spacer: spacer.getBoundingClientRect().height,
pinned: column.hasAttribute('data-chat-tail-pinned'),
status: status === null ? null : status.getBoundingClientRect().top - host.getBoundingClientRect().top,
folding: column.hasAttribute('data-input-fold') || host.querySelector('[data-chat-motion="collapse"]') !== null,
})
frame = requestAnimationFrame(tick)
}
// The slide starts at the style update after the flow change; reading animations there sees its duration.
const observer = new MutationObserver(() => {
const status = host.querySelector<HTMLElement>('[data-chat-running]')
for (const animation of status?.getAnimations() ?? []) {
if (animation instanceof CSSAnimation && animation.animationName.includes('running-follows-output')) {
slides.push(Number(animation.effect?.getComputedTiming().duration ?? 0))
}
}
})
observer.observe(column, { childList: true, subtree: true })
window.openFoldTrace = { frames, slides, stop: () => { cancelAnimationFrame(frame); observer.disconnect() } }
frame = requestAnimationFrame(tick)
}, ROOT)
}
async function readTrace(page: Page): Promise<{ frames: TraceFrame[]; slides: number[] }> {
return page.evaluate(() => {
const trace = window.openFoldTrace!
return { frames: [...trace.frames], slides: [...trace.slides] }
})
}
/** Per-frame scroll movements larger than subpixel rounding. */
function movements(frames: readonly TraceFrame[]): number[] {
const steps: number[] = []
for (let index = 1; index < frames.length; index++) {
const step = frames[index]!.top - frames[index - 1]!.top
if (Math.abs(step) > 1) steps.push(Math.round(step))
}
return steps
}
interface World {
readonly scaffold: WebScaffold
readonly page: Page
readonly session: SessionId
readonly tripwire: ReturnType<typeof watchConsole>
/** Ends the Agent maintenance that keeps the accepted message in the inbox; the new Turn starts after this. */
readonly releaseTurn: () => void
/** Releases the held model stream; the new Step's output arrives after this. */
readonly releaseModel: () => void
readonly ended: Promise<void>
readonly close: () => Promise<void>
}
/**
* Seed and open the case, open the latest group, and send the next message from the floor with the new Turn
* and its model stream held.
*/
async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraphs: number): Promise<World> {
const replayDir = await mkdtemp(join(tmpdir(), 'dsh-open-fold-'))
await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify([reply()]))
const scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride: join(replayDir, 'replay.override.json'),
paceMs: 16,
replayContextWindow: 10_000_000,
})
const model = Promise.withResolvers<undefined>()
const disposeHold = scaffold.ctx.on('llm/stream', async function* (_options, next) {
await model.promise
for await (const chunk of next()) yield chunk
})
const turn = Promise.withResolvers<undefined>()
let maintenance: Promise<undefined> = Promise.resolve(undefined)
const page = await newEnglishPage(browser, 826)
const close = async (): Promise<void> => {
turn.resolve(undefined)
model.resolve(undefined)
disposeHold()
const failures: unknown[] = []
await maintenance.catch((error: unknown) => failures.push(error))
await page.context().close().catch((error: unknown) => failures.push(error))
await scaffold.close().catch((error: unknown) => failures.push(error))
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, `${id}: cleanup failed`)
}
try {
const tripwire = watchConsole(page)
await page.setViewportSize({ width: 1170, height: 826 })
await page.emulateMedia({ reducedMotion: 'no-preference' })
const session = SessionId(`open-fold-${id}`)
const { log, search } = seededLog(id, answerParagraphs)
await seedSession(scaffold, log, session)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSessionBySearch(page, search)
await page.locator(ROOT).getByText('OPENFOLD_ANSWER', { exact: false }).last().waitFor({ timeout: 30_000 })
await choosePreferences(page)
await toBottom(page)
await openLatestGroup(page)
await toBottom(page)
const resolved = await scaffold.ctx.sessionController.resolveAgent(session)
if ('error' in resolved) throw resolved.error
maintenance = resolved.agent.runMaintenance(() => turn.promise)
const ended = new Promise<void>((resolveEnded, reject) => {
const timer = setTimeout(() => { off(); reject(new Error(`${id}: no turn/end within 60000ms`)) }, 60_000)
const off = scaffold.ctx.on('session/event', (owner: { id: SessionId }, event: SessionEvent) => {
if (owner.id !== session || event.type !== 'turn/end') return
clearTimeout(timer)
off()
resolveEnded()
})
})
// A failure before the release awaits nothing; the success path awaits and rethrows this rejection.
ended.catch(() => undefined)
await startTrace(page)
await page.locator(`${ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT)
await page.locator(ROOT).getByRole('button', { name: 'Send message', exact: true }).click()
await page.locator(`${ROOT} [data-submission-echo]`).waitFor()
await stableScroll(page)
return {
scaffold, page, session, tripwire, ended, close,
releaseTurn: () => { turn.resolve(undefined) },
releaseModel: () => { model.resolve(undefined) },
}
} catch (error) {
await close().catch(() => undefined)
throw error
}
}
describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened process group', () => {
let browser: Browser
beforeAll(async () => {
browser = await chromium.launch()
})
afterAll(async () => {
await browser?.close()
})
it('folds the opened group above a long answer without scrolling away and back', async () => {
const world = await sendFromOpenedGroup(browser, 'long', 30)
try {
const { frames } = await readTrace(world.page)
const steps = movements(frames)
const reversals = steps.filter((step, index) => index > 0 && Math.sign(step) !== Math.sign(steps[index - 1]!)).length
// Soft assertions let the held Turn finish, so a failure reports the curve instead of replay leftovers.
expect.soft(frames.some(frame => frame.folding), 'the message folds the opened group').toBe(true)
expect.soft(reversals, `scroll direction changes; per-frame movements ${JSON.stringify(steps)}`).toBe(0)
const settled = frames.at(-1)!
expect.soft(settled.floor - settled.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1)
world.releaseTurn()
world.releaseModel()
await world.ended
expect(world.tripwire.pageErrors).toEqual([])
} finally {
await world.close()
}
})
it('keeps the running status slide while fold room below a short answer takes the new Step', async () => {
const world = await sendFromOpenedGroup(browser, 'short', 2)
try {
const folded = (await readTrace(world.page)).frames.at(-1)!
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
expect.soft(folded.pinned, 'tail pin while fold room remains').toBe(false)
world.releaseTurn()
// The running Step's group row is the first row after the user message; output stays held until then.
await world.page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS + 1)}"]`).waitFor({ timeout: 30_000 })
await world.page.locator(`${ROOT} [data-chat-running]`).waitFor()
await world.page.waitForTimeout(300)
const { frames, slides } = await readTrace(world.page)
const statusSteps = movements(frames.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
.filter(step => Number.isFinite(step))
// The fold runs status moves at zero duration on its own, so only settled frames show the pin's effect.
const pinnedOverRoom = frames.filter(frame => !frame.folding && frame.pinned && frame.spacer > 0)
expect.soft(pinnedOverRoom, 'settled frames pinned over fold room').toEqual([])
expect.soft(slides.some(duration => duration > 0), `status slide durations ${JSON.stringify(slides)}; status movements ${JSON.stringify(statusSteps)}`).toBe(true)
world.releaseModel()
await world.ended
expect(world.tripwire.pageErrors).toEqual([])
} finally {
await world.close()
}
})
})
+125 -3
View File
@@ -44,9 +44,54 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.waitFor({ timeout: 20_000 })
const trigger = row.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const trailing = row.locator('[data-session-trailing]')
const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = []
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise<void> => {
const icons = target.locator('[data-session-trailing]')
const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const time = target.locator('[class*="time"]')
await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(iconsVisible)
await expect.poll(() => actions.isVisible()).toBe(actionsVisible)
observations.push({
stage,
icons: await icons.filter({ visible: true }).evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')!)),
time: await time.isVisible(),
pinned: await target.getByRole('img', { name: 'Pinned', exact: true }).isVisible(),
actions: await actions.isVisible(),
})
}
await expect.poll(() => trailing.count()).toBe(2)
expect(await trailing.evaluateAll(elements => elements.map(element => element.getAttribute('data-session-trailing'))))
.toEqual([sessionId, sessionId])
await page.mouse.move(0, 0)
await observe('resting', true, false)
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(true)
await observe('dark', true, false)
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(false)
const titleWidth = await row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width)
await row.hover()
await trigger.focus()
await observe('hover', false, true)
await page.mouse.move(0, 0)
await observe('pointer-left', true, false)
await row.click()
await expect.poll(() => page.locator('[data-composer-input][contenteditable="true"]')
.evaluate(element => element === document.activeElement)).toBe(true)
await row.click()
await page.mouse.move(0, 0)
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-current-row', true, false)
expect(await row.locator('[data-slot="sidebar.session.row.leading"]').evaluate(element => element.tagName)).toBe('SPAN')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('focus', false, true)
await page.keyboard.press('Tab')
expect(await row.evaluate(element => element.querySelector('button') === document.activeElement)).toBe(true)
await trigger.click()
await observe('menu-open', false, true)
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('separator').count()).toBe(1)
@@ -56,7 +101,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
webSnapshotMode(),
)
await menu.getByRole('menuitem', { name: 'Export session' }).click()
await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter')
await expect.poll(() => menu.count()).toBe(0)
expect(await page.evaluate(() => ({
action: document.documentElement.dataset.sessionAction,
@@ -64,10 +109,87 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
title: document.documentElement.dataset.sessionActionTitle,
}))).toEqual({ action: 'fixture.export-session', id: sessionId, title: SESSION_TITLE })
await page.getByRole('button', { name: 'Account menu', exact: true }).focus()
await page.mouse.move(0, 0)
await observe('blur', true, false)
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await page.mouse.move(0, 0)
await observe('narrow', true, false)
const bounds = await row.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const icons = [...element.querySelectorAll('[data-session-trailing]')].map(icon => icon.getBoundingClientRect())
return {
titleRight: title.right, firstIconLeft: icons[0]!.left, lastIconRight: icons.at(-1)!.right,
rowRight: element.getBoundingClientRect().right,
}
})
expect(bounds.titleRight).toBeLessThanOrEqual(bounds.firstIconLeft)
expect(bounds.lastIconRight).toBeLessThanOrEqual(bounds.rowRight)
await page.setViewportSize({ width: 1680, height: 1000 })
const touchPage = await browser.newPage({
viewport: { width: 390, height: 844 }, locale: 'en-US', isMobile: true, hasTouch: true,
})
try {
const touchConsole = watchConsole(touchPage)
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) })
const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
await touchTrigger.waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
await observe('touch', true, true, touchRow)
expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
await touchTrigger.tap()
await touchPage.getByRole('menu').waitFor()
await observe('touch-menu-open', true, true, touchRow)
await touchPage.keyboard.press('Escape')
await observe('touch-menu-closed', true, true, touchRow)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow)
const touchBounds = await touchRow.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect()
const actions = element.querySelector('[class*="rowActions"]')!.getBoundingClientRect()
return { titleRight: title.right, restingLeft: resting.left, restingRight: resting.right,
actionsLeft: actions.left, actionsRight: actions.right, rowRight: element.getBoundingClientRect().right }
})
expect(touchBounds.titleRight).toBeLessThanOrEqual(touchBounds.restingLeft)
expect(touchBounds.restingRight).toBeLessThanOrEqual(touchBounds.actionsLeft)
expect(touchBounds.actionsRight).toBeLessThanOrEqual(touchBounds.rowRight)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Archive session', exact: true }).tap()
await expect.poll(() => touchRow.count()).toBe(0)
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)).toBe(true)
await page.getByRole('button', { name: 'View options', exact: true }).click()
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
await row.click()
await page.getByRole('alert').filter({ hasText: 'Archived sessions cannot be opened' }).waitFor()
await page.mouse.move(0, 0)
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-archived-row', true, false)
await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId)
await observe('touch-unarchived', true, true, touchRow)
expect(touchConsole.pageErrors).toEqual([])
} finally {
await touchPage.close()
}
scaffold.ctx.loader.remove(entryId)
await expect.poll(() => page.evaluate(
() => document.documentElement.dataset.liveDisposals,
)).toBe('1')
await expect.poll(() => trailing.count()).toBe(0)
await expect.poll(() => row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width))
.toBeGreaterThan(titleWidth)
await compareOrRefreshGolden(
join(EXPECTED, 'session-trailing.expected.json'),
JSON.stringify(observations, null, 2),
webSnapshotMode(),
)
await row.hover()
await trigger.click()
await menu.waitFor()
@@ -86,7 +208,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
expect(console.pageErrors).toEqual([])
await assertFixtureInventory(EXPECTED, [
'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md',
'session-actions.expected.md',
'session-actions.expected.md', 'session-trailing.expected.json',
])
} finally {
await browser.close()
+2 -2
View File
@@ -195,8 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
expect(calls).toEqual([])
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
// The bundle's detail document is shown between its components and its source.
await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
+2 -2
View File
@@ -212,8 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
// The bundle's detail document is shown between its components and its source.
await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
+5
View File
@@ -45,7 +45,12 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv
const before = await readFile(settingsPath, 'utf8')
const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const plugins = page.locator('[data-plugin-panel]')
await plugins.getByRole('button', { name: '更多', exact: true }).waitFor()
expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0)
await plugins.getByRole('button', { name: '更多', exact: true }).click()
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]')
expect(await card.getByText('实验性', { exact: true }).count()).toBe(1)
const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
expect(await card.textContent()).toContain('请切换到 PTC 模式')
+15 -10
View File
@@ -19,6 +19,7 @@ const MODE = webSnapshotMode()
const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url))
const NS = 'ui-settings-account'
const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }
const ACCOUNT_ASSET = /\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-[a-z-]+-[0-9a-f]{12}\.(?:svg|webp)$/
describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => {
let root: string
@@ -124,15 +125,18 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
async function snapshot(name: string): Promise<void> {
await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false')
const hasIllustration = name.startsWith('welcome') || name.startsWith('credit')
if (hasIllustration) {
const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements =>
Promise.all(elements.map(async (element) => {
const image = element as HTMLImageElement
await image.decode()
return image.naturalWidth > 0 && image.naturalHeight > 0
})))
expect(decoded).toEqual([true, true])
}
// Illustrations and card icons are package assets fetched after the step renders.
const images = await page.locator('[data-desktop-onboarding] img').evaluateAll(async elements =>
Promise.all(elements.map(async (element) => {
const image = element as HTMLImageElement
await image.decode()
return {
source: image.currentSrc, illustration: image.matches('[class*="illustration"] > img'),
loaded: image.naturalWidth > 0 && image.naturalHeight > 0,
}
})))
expect(images.filter(image => !image.loaded || !ACCOUNT_ASSET.test(image.source)), `${name}: images outside loaded package assets`).toEqual([])
expect(images.filter(image => image.illustration), `${name}: illustration images`).toHaveLength(hasIllustration ? 2 : 0)
const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd)
await mkdir(EXPECTED, { recursive: true })
await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE)
@@ -424,7 +428,8 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
expect(await page.getByRole('button', { name: 'Next', exact: true })
.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)')
await snapshot('credit-en-dark')
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml')
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage))
.toMatch(/^url\("[^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-back-[0-9a-f]{12}\.svg"\)$/)
await page.getByRole('button', { name: 'Next', exact: true }).click()
await page.getByRole('button', { name: 'Got it', exact: true }).click()
await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click()
+62
View File
@@ -0,0 +1,62 @@
/** Default-loaded entry controls activate through the real Web profile and Loader. */
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold, type WebConsoleTripwire } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
describe('default navigation entry customization', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
let tripwire: WebConsoleTripwire
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
})
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('unpins a sidebar panel across reloads while its panel stays reachable from More', async () => {
onTestFailed(() => saveFailureShot(page, 'entry-customization'))
const nav = page.getByRole('navigation', { name: 'Global panels' })
const plugins = nav.getByRole('button', { name: 'Plugins', exact: true })
const tasks = nav.getByRole('button', { name: 'Automation tasks', exact: true })
const more = nav.getByRole('button', { name: 'More', exact: true })
await plugins.waitFor()
await tasks.waitFor()
await more.waitFor()
const menu = page.getByRole('menu')
await more.click()
expect(await menu.getByRole('menuitem', { name: 'Plugins', exact: true }).count()).toBe(0)
await menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Automation tasks', exact: true }).click()
await tasks.waitFor({ state: 'detached' })
await menu.getByRole('menuitem', { name: 'Automation tasks', exact: true }).click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
// The selected panel keeps its row until another panel is selected.
await tasks.waitFor()
expect(await tasks.getAttribute('aria-current')).toBe('page')
await plugins.click()
await tasks.waitFor({ state: 'detached' })
expect(await plugins.getAttribute('aria-current')).toBe('page')
await page.reload({ waitUntil: 'load' })
await plugins.waitFor()
await more.waitFor()
expect(await tasks.count()).toBe(0)
await more.click()
await menu.getByRole('menuitem', { name: 'Pin to left sidebar: Automation tasks', exact: true }).click()
await tasks.waitFor()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
- button "Copy"
- status: Completed
@@ -0,0 +1,143 @@
[
{
"stage": "resting",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "dark",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "hover",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "pointer-left",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "clicked-current-row",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "focus",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "menu-open",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "blur",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "narrow",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "touch",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-menu-open",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-menu-closed",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-pinned",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": true,
"actions": true
},
{
"stage": "clicked-archived-row",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "touch-unarchived",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
}
]
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- navigation "Turn navigation":
- button "Jump to turn 1"
- button "Jump to turn 2"
@@ -7,64 +7,55 @@
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- paragraph:
- img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮"
- paragraph:
- text: 展开
- strong: 思考
- text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。
- heading "使用方法" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- listitem:
- text: 展开
- strong: 思考
- text: 行。点击
- strong: 查看原文
- text: 显示原文,点击
- strong: 查看译文
- text: 切换回译文。
- listitem:
- text: 在此插件的页面中选择
- strong: 翻译服务
- text: 和
- strong: 目标语言
- text: ,然后点击
- strong: 保存
- text: 。
- code: auto
- text: 表示跟随界面语言;也可以填写
- code: zh
- text: 、
- code: en
- text: 、
- code: ja
- text: 等语言代码。
- listitem:
- text: 翻译失败时,原文保持显示,并提供
- strong: 重试
- text: 。
- heading "注意事项" [level=2]
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- listitem: 你展开的思考内容可能包含你的代码和对话细节。
- listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。
- listitem:
- text: 如果已配置 DeepSeek 账号或官方 API,还会出现
- strong: DeepSeek Flash · 账号(付费)
- text: 和
- strong: DeepSeek Flash · 官方 API(付费)
- text: 。此时每段没有已保存译文的内容都是一次付费请求。
- listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。
- listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。
- listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求
- text: 翻译服务 已覆盖
@@ -7,64 +7,55 @@
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- paragraph:
- img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮"
- paragraph:
- text: 展开
- strong: 思考
- text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。
- heading "使用方法" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- listitem:
- text: 展开
- strong: 思考
- text: 行。点击
- strong: 查看原文
- text: 显示原文,点击
- strong: 查看译文
- text: 切换回译文。
- listitem:
- text: 在此插件的页面中选择
- strong: 翻译服务
- text: 和
- strong: 目标语言
- text: ,然后点击
- strong: 保存
- text: 。
- code: auto
- text: 表示跟随界面语言;也可以填写
- code: zh
- text: 、
- code: en
- text: 、
- code: ja
- text: 等语言代码。
- listitem:
- text: 翻译失败时,原文保持显示,并提供
- strong: 重试
- text: 。
- heading "注意事项" [level=2]
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- listitem: 你展开的思考内容可能包含你的代码和对话细节。
- listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。
- listitem:
- text: 如果已配置 DeepSeek 账号或官方 API,还会出现
- strong: DeepSeek Flash · 账号(付费)
- text: 和
- strong: DeepSeek Flash · 官方 API(付费)
- text: 。此时每段没有已保存译文的内容都是一次付费请求。
- listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。
- listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。
- listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求
- text: 翻译服务
@@ -16,6 +16,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
@@ -16,6 +16,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
@@ -6,6 +6,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- group "Command input": /goal
- 'button "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"'
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Render adjacent CJK strong emphasis. {{clock}}
- button "Copy"
- status: Completed
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Show the Markdown image policy. {{clock}}
- button "Copy"
- status: Completed
@@ -4,6 +4,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Show the local preview URL. {{clock}}
- button "Copy"
- status: Completed
@@ -5,6 +5,7 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "More conversation views"
- text: Render this mathematical proof. {{clock}}
- button "Copy"
- status: Completed
@@ -1,7 +1,7 @@
- dialog "选择要添加的模型":
- heading "选择要添加的模型" [level=2]
- dialog "选择模型":
- heading "选择模型" [level=2]
- button "关闭"
- paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。
- paragraph: 以下是模型提供商的可用模型,勾选的模型即为模型列表。
- searchbox "搜索模型"
- button "取消全选"
- list:
@@ -15,4 +15,4 @@
- checkbox "MiniMax-M3" [checked]
- text: MiniMax-M3
- button "取消"
- button "添加所选"
- button "更新列表"
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- listitem:
- button "查看 Computer Use": Computer Use
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- switch "启用 Computer Use"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
@@ -6,6 +6,13 @@
- paragraph:
- code: "@fixture/bundle"
- paragraph: Registry description for the fixture bundle.
- region "Details":
- heading "Fixture bundle" [level=1]
- paragraph: English | 中文
- paragraph:
- text: Registry fixture for the Web plugin-manager tests. It declares no
- code: meta.detail
- text: ", so its page shows this README."
- heading "Components" [level=4]
- text: 3 total · 3 off
- list:
@@ -6,6 +6,13 @@
- paragraph:
- code: "@fixture/bundle"
- paragraph: Registry description for the fixture bundle.
- region "详细介绍":
- heading "Fixture bundle" [level=1]
- paragraph: English | 中文
- paragraph:
- text: Registry fixture for the Web plugin-manager tests. It declares no
- code: meta.detail
- text: ", so its page shows this README."
- heading "包含的组件" [level=4]
- text: 共 3 个 · 3 已停用
- list:
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- listitem:
- button "查看 Computer Use": Computer Use
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- switch "启用 Computer Use"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
@@ -38,10 +38,6 @@
- button "查看 开发者工具": 开发者工具
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- switch "启用 开发者工具"
- listitem:
- button "查看 Computer Use": Computer Use
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
- switch "启用 Computer Use"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
@@ -5,54 +5,9 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-badge-skill-bundle"
- paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。
- img "DSH 徽章技能功能预览"
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: In Web or Desktop, open Plugins and enable
- strong: DSH badge skill
- text: in Official. Switch it off to remove its profile layer.
- paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- code: cordis.patch.yml
- text: contributes the
- code: skill-badge
- text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors.
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Capability implementation
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through the capability implementation, which owns model context and results.
- heading "KV Cache effect" [level=4]
- paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- img "DSH 徽章技能功能预览"
- heading "包含的组件" [level=4]
- text: 共 1 个 · 1 已停用
- list:

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