Merge remote-tracking branch 'origin/master' into worktree/composer-host-confirmed-echo
This commit is contained in:
commit
73883f5541
559 files changed
+11799
-2376
No files matched your search
@@ -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
@@ -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
@@ -1,6 +1,7 @@
|
||||
# Agent Note: 内置插件功能配图
|
||||
|
||||
Status: implemented
|
||||
Archived: 2026-10-19
|
||||
|
||||
[English](2026-10-14-built-in-plugin-feature-previews.md) | 中文
|
||||
|
||||
@@ -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` 格式化有界的模型输出,附带清晰的继续或后续引导。
|
||||
|
||||
|
||||
+4
-4
@@ -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`、接受带注释的豁免,并在当前代码树上通过。
|
||||
|
||||
|
||||
+6
-6
@@ -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
-2
@@ -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
-2
@@ -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`(切换展示模式后清空补白)。
|
||||
|
||||
+40
@@ -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/[email protected]
|
||||
lib/assets/[email protected]
|
||||
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/[email protected]
|
||||
lib/assets/[email protected]
|
||||
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) 覆盖归档和解析。
|
||||
-19
@@ -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)保留这些依据。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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. A held prompt keeps the new Turn from starting
|
||||
* until the fold settles, so 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, Route } 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>
|
||||
/** Releases the held prompt; the new Turn starts after this. */
|
||||
readonly releasePrompt: () => 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 prompt
|
||||
* and the 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 prompt = Promise.withResolvers<undefined>()
|
||||
const holdPrompt = async (route: Route): Promise<void> => {
|
||||
await prompt.promise
|
||||
await route.continue()
|
||||
}
|
||||
const page = await newEnglishPage(browser, 826)
|
||||
const close = async (): Promise<void> => {
|
||||
prompt.resolve(undefined)
|
||||
model.resolve(undefined)
|
||||
disposeHold()
|
||||
const failures: unknown[] = []
|
||||
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)
|
||||
await page.route('**/api/session/prompt', holdPrompt)
|
||||
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,
|
||||
releasePrompt: () => { prompt.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.releasePrompt()
|
||||
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.releasePrompt()
|
||||
// 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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}}']],
|
||||
|
||||
@@ -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}}']],
|
||||
|
||||
@@ -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 模式')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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: 翻译服务
|
||||
|
||||
@@ -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"
|
||||
- heading "已安装插件" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
|
||||
@@ -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"
|
||||
- heading "已安装插件" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
|
||||
@@ -38,10 +38,6 @@
|
||||
- button "查看 开发者工具": 开发者工具
|
||||
- text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
|
||||
- switch "启用 开发者工具"
|
||||
- listitem:
|
||||
- button "查看 Computer Use": Computer Use
|
||||
- text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。
|
||||
- switch "启用 Computer Use"
|
||||
- heading "已安装插件" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ Focus at 500ms:
|
||||
- tooltip "刷新"
|
||||
failure: pending
|
||||
- button "刷新" [disabled]
|
||||
{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
|
||||
{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
|
||||
failure: settled
|
||||
- button "刷新"
|
||||
{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
|
||||
{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
|
||||
@@ -1 +1 @@
|
||||
Prefer showing the primary results within your final response alongside a brief explanation. Use  when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
|
||||
Prefer showing the primary results within your final response alongside a brief explanation. Use  when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present to publish complete deliverables, including images, Office documents, spreadsheets, and slide decks, when separate access helps the user. Hosts that support file cards can show the presented file with preview and open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Bilingual-pair consistency record for README.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 apps/web/tests/fixtures/plugins/fixture-bundle/README.md
|
||||
/fixture-bundle:
|
||||
en: 987d3ae4a4d79382
|
||||
zh: 4b7ce36da8833efe
|
||||
@@ -0,0 +1,5 @@
|
||||
# Fixture bundle
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Registry fixture for the Web plugin-manager tests. It declares no `meta.detail`, so its page shows this README.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 测试组合包
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Web 插件管理测试用的 registry 测试组合包。它没有声明 `meta.detail`,所以它的页面显示这份 README。
|
||||
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
|
||||
id: '@fixture/live-client',
|
||||
factory(require) {
|
||||
const React = require('react')
|
||||
const { MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||
const { IconFolderCloseRegular, IconNewChatOutlineRegular, MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
|
||||
const style = document.createElement('style')
|
||||
style.dataset.plugin = '@fixture/live-client'
|
||||
style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
|
||||
@@ -88,6 +88,18 @@ window.__ModuleLoader__.load({
|
||||
t(label),
|
||||
)
|
||||
})
|
||||
ctx.slots.inject('sidebar.session.row.trailing', function* () {
|
||||
for (const [id, order, Icon, label] of [
|
||||
['fixture.trailing-new-chat', 200, IconNewChatOutlineRegular, 'section'],
|
||||
['fixture.trailing-folder', 100, IconFolderCloseRegular, 'badge'],
|
||||
]) {
|
||||
yield ctx.slots.register({
|
||||
name: 'sidebar.session.row.trailing', id, order, locale: 'fixtureLive',
|
||||
}, ({ sessionId, t }) => React.createElement('span', {
|
||||
role: 'img', 'aria-label': t(label), 'data-session-trailing': sessionId,
|
||||
}, React.createElement(Icon)))
|
||||
}
|
||||
})
|
||||
ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () {
|
||||
// The shipped rows end at Session ID (450); these follow as one group,
|
||||
// opened by the export row's hairline.
|
||||
|
||||
@@ -61,6 +61,35 @@ function parentPathFixture(path: string): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Build a settled turn whose relative link follows a directory change into a nested checkout. */
|
||||
function changedDirectoryFixture(path: string): string {
|
||||
const session = Session.create(SessionId('changed-directory-links-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Enter the worktree and link its notes.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', { title: 'Changed directory links', messageSeqs: [user.seq], source: { kind: 'fallback' } })
|
||||
session.append('working-directory/change', { cwd: '{{cwd}}/.agents/worktrees/w' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1, step: 1, stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: [{ type: 'text', text: `[Worktree notes](${path})` }],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
|
||||
cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
|
||||
}),
|
||||
...session.snapshotEvents().map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: Markdown file links', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -177,4 +206,27 @@ describe('web e2e: Markdown file links', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
it('resolves relative links from the Session\'s current working directory', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-directory-file-links'))
|
||||
const worktree = join(scaffold.workspaceCwd, '.agents/worktrees/w')
|
||||
await mkdir(worktree, { recursive: true })
|
||||
await writeFile(join(scaffold.workspaceCwd, 'notes.txt'), 'original checkout\n')
|
||||
await writeFile(join(worktree, 'notes.txt'), 'worktree checkout\n')
|
||||
const id = await seedSession(scaffold, changedDirectoryFixture('notes.txt'), 'changed-directory-file-links')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
const workspace = page.getByRole('treeitem').first()
|
||||
await workspace.waitFor()
|
||||
if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click()
|
||||
await page.locator(`[data-row-key="session:${id}"]`).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Worktree notes', exact: true }).click()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(worktree, 'notes.txt'))
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('worktree checkout\n')
|
||||
const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
|
||||
await mkdir(artifacts, { recursive: true })
|
||||
await page.screenshot({ path: join(artifacts, 'changed-directory-file-links.png'), animations: 'disabled' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -275,7 +275,7 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor()
|
||||
expect(mediaResponses).toEqual(new Map([
|
||||
[join(scaffold.workspaceCwd, 'valid.png'), 200],
|
||||
[`${scaffold.workspaceCwd}/./local-image.png`, 200],
|
||||
[join(scaffold.workspaceCwd, 'local-image.png'), 200],
|
||||
[join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200],
|
||||
[join(scaffold.workspaceCwd, 'test workspace/测试#100%.png'), 200],
|
||||
[join(scaffold.workspaceCwd, 'oversized.png'), 413],
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, parseSeedFixture, realizeSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
import { openSettings, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
@@ -191,6 +191,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let hostClock: ReturnType<typeof pinHostClock> | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
@@ -211,6 +212,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME - 90_000 })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
// Fork summaries use Host time; placeholders use browser time. Each pin
|
||||
// starts at the fixture date, so keep these calls adjacent: setup between
|
||||
// them can create enough clock skew to reverse the fork order.
|
||||
hostClock = pinHostClock()
|
||||
await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -218,8 +223,12 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
hostClock?.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
|
||||
|
||||
@@ -241,14 +241,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('filters the discovered model catalog and clears hidden selections', async () => {
|
||||
it('filters the discovered model catalog and deselects only visible candidates', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
|
||||
await settingsDialog.getByText('自定义设置').click()
|
||||
await settingsDialog.getByRole('button', { name: '获取可用模型' }).click()
|
||||
|
||||
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
|
||||
const picker = page.getByRole('dialog', { name: '选择模型' })
|
||||
await picker.waitFor({ timeout: 10_000 })
|
||||
const boxes = picker.getByRole('checkbox')
|
||||
const count = await boxes.count()
|
||||
@@ -268,12 +268,13 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
await search.fill('')
|
||||
await expect.poll(async () => boxes.count()).toBe(count)
|
||||
const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked))
|
||||
expect(restored).toEqual(Array.from({ length: count }, () => false))
|
||||
expect(restored.filter(checked => !checked)).toHaveLength(1)
|
||||
expect(await picker.getByRole('checkbox', { name: 'MiniMax-M2.7-highspeed', exact: true }).isChecked()).toBe(false)
|
||||
await picker.getByRole('button', { name: '全选' }).waitFor()
|
||||
await picker.getByRole('button', { name: '全选' }).click()
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="dialog"][aria-label="选择要添加的模型"]',
|
||||
'[role="dialog"][aria-label="选择模型"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE)
|
||||
@@ -517,11 +518,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
await dialog.getByRole('button', { name: '删除模型 1' }).click()
|
||||
await dialog.getByRole('button', { name: '获取可用模型' }).click()
|
||||
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
|
||||
const picker = page.getByRole('dialog', { name: '选择模型' })
|
||||
await picker.getByRole('button', { name: '取消全选' }).click()
|
||||
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
|
||||
await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
|
||||
await picker.getByRole('button', { name: '添加所选' }).click()
|
||||
await picker.getByRole('button', { name: '更新列表' }).click()
|
||||
await dialog.getByRole('button', { name: '模型选项 1' }).click()
|
||||
expect(await image.isChecked()).toBe(true)
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
@@ -530,7 +531,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
} finally {
|
||||
await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }])
|
||||
await edit.waitFor({ state: 'detached' })
|
||||
await page.getByRole('dialog', { name: '选择要添加的模型' }).waitFor({ state: 'detached' })
|
||||
await page.getByRole('dialog', { name: '选择模型' }).waitFor({ state: 'detached' })
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
@@ -27,6 +27,7 @@ const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||||
const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
|
||||
const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
|
||||
const ID_SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-id-results.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url))
|
||||
const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md')
|
||||
@@ -202,9 +203,20 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
|
||||
await search.fill('WATERFALL')
|
||||
const resultTree = page.getByRole('tree', { name: 'Search results' })
|
||||
const result = resultTree.getByRole('treeitem')
|
||||
// The seed log carries its id only in the header, so this mid-id,
|
||||
// differently cased fragment can match only through local id matching.
|
||||
await search.fill('PANES-WEB')
|
||||
await expect.poll(() => result.count(), { timeout: 10_000 }).toBe(1)
|
||||
await page.getByRole('status', { name: 'Searching session history…' }).waitFor({ state: 'detached', timeout: 30_000 })
|
||||
await compareOrRefreshGolden(
|
||||
ID_SEARCH_EXPECTED,
|
||||
await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
await search.fill('WATERFALL')
|
||||
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
|
||||
timeout: 10_000,
|
||||
@@ -551,7 +563,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -38,7 +38,8 @@ describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => {
|
||||
expect(scaffold.ctx.sessions.list()).toEqual([])
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
|
||||
await page.getByRole('button', { name: '新建会话', exact: true }).first().click()
|
||||
// The capsule: on a local build the wordmark's fallback copies the version.
|
||||
await page.getByRole('button', { name: '新建会话', exact: true }).last().click()
|
||||
const notice = page.getByRole('alert').filter({ hasText: '新建会话失败' })
|
||||
await notice.waitFor()
|
||||
await compareOrRefreshGolden(NOTICE_EXPECTED, await notice.ariaSnapshot(), MODE)
|
||||
|
||||
@@ -584,8 +584,27 @@ describe('web e2e: plugin manager', () => {
|
||||
await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
|
||||
expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
|
||||
.toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
|
||||
expect(await panel.getByText('实验性', { exact: true }).count())
|
||||
.toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length)
|
||||
const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all()
|
||||
expect(await Promise.all(cards.map(async card => [
|
||||
await card.getAttribute('data-plugin-package'),
|
||||
await card.getByText('实验性', { exact: true }).count(),
|
||||
]))).toEqual([
|
||||
['@deepseek-ai/dsh-experimental-agent-team-profile', 1],
|
||||
['@deepseek-ai/dsh-experimental-voice-input-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-auto-review', 1],
|
||||
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
|
||||
['@deepseek-ai/dsh-cua-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
|
||||
['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-terminal-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-session-search', 1],
|
||||
['@deepseek-ai/dsh-experimental-session-titles-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-ralph-bundle', 1],
|
||||
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
|
||||
['@deepseek-ai/dsh-subagent-codex', 0],
|
||||
['@deepseek-ai/dsh-subagent-claude-code', 0],
|
||||
])
|
||||
expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
|
||||
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2)
|
||||
@@ -690,13 +709,13 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('shows an optional feature preview before activation and leaves other plugin details unchanged', async () => {
|
||||
it('shows a bundle detail document image before activation and leaves other plugin details unchanged', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preview'))
|
||||
const panel = await openPluginsPanel()
|
||||
await panel.getByRole('button', { name: '更多', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '查看 DSH 徽章技能', exact: true }).click()
|
||||
const detail = panel.locator('[data-plugin-detail]')
|
||||
const preview = detail.getByRole('img', { name: 'DSH 徽章技能功能预览', exact: true })
|
||||
const preview = detail.locator('[data-detail-document]').getByRole('img', { name: 'DSH 徽章技能功能预览', exact: true })
|
||||
await preview.waitFor()
|
||||
await preview.evaluate(async (image: HTMLImageElement) => { await image.decode() })
|
||||
expect(await preview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640)
|
||||
@@ -709,8 +728,6 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(fits).toBe(true)
|
||||
await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor()
|
||||
await detail.getByText(badgeVersion, { exact: true }).waitFor()
|
||||
// The bundle's README is its detail document, shown between its components and its source.
|
||||
await detail.locator('[data-detail-document] h1').waitFor()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'),
|
||||
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
|
||||
replacements: [[badgeVersion, '{{version}}']],
|
||||
@@ -718,7 +735,8 @@ describe('web e2e: plugin manager', () => {
|
||||
const chineseSource = await preview.getAttribute('src')
|
||||
try {
|
||||
await setLanguage('en')
|
||||
const englishPreview = detail.getByRole('img', { name: 'DSH badge skill feature preview', exact: true })
|
||||
const englishPreview = detail.locator('[data-detail-document]')
|
||||
.getByRole('img', { name: 'DSH badge skill feature preview', exact: true })
|
||||
await englishPreview.waitFor()
|
||||
await englishPreview.evaluate(async (image: HTMLImageElement) => { await image.decode() })
|
||||
expect(await englishPreview.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(640)
|
||||
@@ -728,10 +746,12 @@ describe('web e2e: plugin manager', () => {
|
||||
}
|
||||
expect(await preview.getAttribute('src')).toBe(chineseSource)
|
||||
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
|
||||
// Detail icons have alt=""; only feature previews expose an image role.
|
||||
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click()
|
||||
// A bundle without meta.detail still shows its README; this one has no images.
|
||||
await panel.locator('[data-detail-document] h1').waitFor()
|
||||
expect(await panel.getByRole('img').count()).toBe(0)
|
||||
await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '返回插件', exact: true }).click()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
@@ -747,6 +767,9 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1)
|
||||
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
|
||||
await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
|
||||
// The README detail document loads asynchronously after the description.
|
||||
const readmeHeading = panel.locator('[data-detail-document]').getByRole('heading', { name: 'Fixture bundle', exact: true })
|
||||
await readmeHeading.waitFor()
|
||||
expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
|
||||
await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
|
||||
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
|
||||
@@ -757,6 +780,7 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
|
||||
expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(0)
|
||||
expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
|
||||
await readmeHeading.waitFor()
|
||||
await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
|
||||
replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']],
|
||||
}), MODE)
|
||||
|
||||
@@ -611,6 +611,21 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
|
||||
await cards.first().waitFor({ timeout: 30_000 })
|
||||
const listed = await cards.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-plugin-package')))
|
||||
expect(listed.filter(name => name !== null && OPTIONAL_BUNDLES.includes(name)).length).toBeGreaterThan(0)
|
||||
// Package assets resolve from the Worker image: ui-chat's injected mask is a Blob URL that decodes.
|
||||
const mask = await page.evaluate(async () => {
|
||||
const styles = [...document.querySelectorAll('style[data-plugin="@deepseek-ai/dsh-client-ui-chat"]')]
|
||||
.map(style => style.textContent).join('\n')
|
||||
const url = /url\((blob:[^)]+)\)/u.exec(styles)?.[1]
|
||||
const unresolved = styles.includes('plugins/@deepseek-ai/dsh-client-ui-chat/assets/')
|
||||
if (url === undefined) return { url: null, width: 0, unresolved }
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode()
|
||||
return { url, width: image.naturalWidth, unresolved }
|
||||
})
|
||||
expect(mask.url).toMatch(/^blob:/u)
|
||||
expect(mask.unresolved).toBe(false)
|
||||
expect(mask.width).toBeGreaterThan(0)
|
||||
const result = await page.evaluate(async () => {
|
||||
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { message: string } } }
|
||||
const transport = (globalThis as typeof globalThis & {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeCo
|
||||
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
|
||||
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
|
||||
/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */
|
||||
const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/
|
||||
|
||||
class PausedReasoningAdapter extends LlmAdapter {
|
||||
override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] }
|
||||
@@ -96,14 +98,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
|
||||
const imageSize = await animatedWhale.evaluate(async (element) => {
|
||||
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
|
||||
const imageSize = await animatedWhale.evaluate(async (element, mask) => {
|
||||
const source = getComputedStyle(element).maskImage.match(new RegExp(mask))?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the ui-chat package asset URL')
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
return [image.naturalWidth, image.naturalHeight]
|
||||
})
|
||||
}, WHALE_MASK.source)
|
||||
expect(imageSize).toEqual([28, 28])
|
||||
expect(await restingWhale.isVisible()).toBe(false)
|
||||
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
|
||||
|
||||
@@ -468,8 +468,8 @@ function assertLeanChildRecord(agent: Agent): void {
|
||||
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
|
||||
* sandbox row confines, `mcp_*` servers spawn outside `ctx.shell`, and `ralph`
|
||||
* runs unsupervised rounds whose completion is a worker self-report.
|
||||
* `web_fetch` is present because public-address enforcement and one-shot
|
||||
* approval now confine its model-selected request target. The composition
|
||||
* `web_fetch` is present because it adds no network reach beyond the
|
||||
* mounted shell tools. The composition
|
||||
* Agent Note owns the rationale and its sources.
|
||||
*/
|
||||
const EXPECTED_TOOLS = [
|
||||
|
||||
@@ -208,7 +208,7 @@ export function probeFreePort(): Promise<number> {
|
||||
*/
|
||||
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('textbox', { name: 'Choose workspace' }).click()
|
||||
await page.getByRole('button', { name: 'Add workspace', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
||||
@@ -234,7 +234,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
|
||||
*/
|
||||
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
await page.getByRole('button', { name: '添加工作区', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '编辑路径' }).click()
|
||||
|
||||
@@ -12,6 +12,8 @@ import { newEnglishPage } from './support.ts'
|
||||
|
||||
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
|
||||
const expected = fileURLToPath(new URL('./expected/voice-download.expected.md', import.meta.url))
|
||||
// The input-device picker on the same page shows its own alert when headless Chromium offers no microphone.
|
||||
const DOWNLOAD_ALERT = '[role="alert"]:has-text("Download source")'
|
||||
|
||||
it('shows the failed asset, actual download source and recovery advice, then retries on request', async () => {
|
||||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
@@ -45,14 +47,14 @@ it('shows the failed asset, actual download source and recovery advice, then ret
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click()
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
|
||||
const alert = page.getByRole('alert')
|
||||
const alert = page.getByRole('alert').filter({ hasText: 'Download source' })
|
||||
await alert.waitFor()
|
||||
expect(await alert.textContent()).toContain('HTTP 503')
|
||||
expect(await alert.textContent()).toContain(`Download source: ${origin}`)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toContain('/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')
|
||||
await compareOrRefreshGolden(expected,
|
||||
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(origin, 'https://model-mirror.example'), webSnapshotMode())
|
||||
(await captureStableAria(page, DOWNLOAD_ALERT, scaffold.workspaceCwd)).replaceAll(origin, 'https://model-mirror.example'), webSnapshotMode())
|
||||
status = 404
|
||||
await page.getByRole('button', { name: 'Retry preparation', exact: true }).click()
|
||||
await expect.poll(() => alert.textContent()).toContain('HTTP 404')
|
||||
@@ -108,7 +110,7 @@ it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0
|
||||
await picker.waitFor()
|
||||
if (manual) await picker.selectOption(origins[winner]!)
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
|
||||
const alert = page.getByRole('alert')
|
||||
const alert = page.getByRole('alert').filter({ hasText: 'Download source' })
|
||||
await alert.waitFor()
|
||||
expect(downloads).toEqual(manual ? [winner] : [winner, 1 - winner])
|
||||
expect(requestedPaths).toHaveLength(manual ? 1 : 4)
|
||||
@@ -118,7 +120,7 @@ it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0
|
||||
expect(await alert.textContent()).toContain(manual ? 'HTTP 503' : 'HTTP 502')
|
||||
expect(await alert.textContent()).toContain(`Download source: ${failedOrigin}`)
|
||||
await compareOrRefreshGolden(manual ? expected : fileURLToPath(new URL('./expected/voice-download-fallback.expected.md', import.meta.url)),
|
||||
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'),
|
||||
(await captureStableAria(page, DOWNLOAD_ALERT, scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'),
|
||||
webSnapshotMode())
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
@@ -50,6 +50,16 @@ function resultSnippet(queryIndex: number, ordinal: number): string {
|
||||
return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.`
|
||||
}
|
||||
|
||||
/** One provider result's multi-line model-facing summary, by 1-based provider order. */
|
||||
function resultSummary(queryIndex: number, ordinal: number): string {
|
||||
return `Snapshot search ${queryIndex + 1} summary ${ordinal}:\n\nthe model reads this longer excerpt\nfrom the Search API.`
|
||||
}
|
||||
|
||||
/** The rendered summary: whitespace collapsed to single spaces. */
|
||||
function renderedSummary(queryIndex: number, ordinal: number): string {
|
||||
return resultSummary(queryIndex, ordinal).replace(/\s+/gu, ' ')
|
||||
}
|
||||
|
||||
/** One provider result's publication date, by 1-based provider order (July 2026 days 01..12). */
|
||||
function resultPageAge(ordinal: number): string {
|
||||
return `2026-07-${String(ordinal).padStart(2, '0')}`
|
||||
@@ -66,6 +76,11 @@ const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, que
|
||||
publishedAt: resultPageAge(ordinal),
|
||||
}))).slice(0, WEB_SEARCH_MAX_RESULTS)
|
||||
|
||||
/** Model-visible source lines for the kept sources: the summary replaces the display snippet. */
|
||||
const KEPT_SOURCE_LINES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) =>
|
||||
`- [${resultTitle(queryIndex, ordinal)}](${resultUrl(queryIndex, ordinal)}) — ${renderedSummary(queryIndex, ordinal)} (${resultPageAge(ordinal)})`,
|
||||
)).slice(0, WEB_SEARCH_MAX_RESULTS)
|
||||
|
||||
/** URLs omitted after the combined source cap is reached. */
|
||||
const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map(
|
||||
(_query, queryIndex) => resultUrl(queryIndex, ordinal),
|
||||
@@ -107,6 +122,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s
|
||||
url: resultUrl(queryIndex, ordinal),
|
||||
title: resultTitle(queryIndex, ordinal),
|
||||
snippet: resultSnippet(queryIndex, ordinal),
|
||||
summary: resultSummary(queryIndex, ordinal),
|
||||
content: '', site_name: null, site_icon: null,
|
||||
date: resultPageAge(ordinal),
|
||||
})),
|
||||
@@ -212,16 +228,18 @@ describe('web e2e: opt-in Search API web search', () => {
|
||||
const rendered = message.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
// The tool interleaves sources from both seam results before applying the
|
||||
// combined cap, so each query remains represented in model-visible output.
|
||||
for (const source of KEPT_SOURCES) {
|
||||
expect(rendered).toContain(`[${source.title}](${source.url})`)
|
||||
for (const line of KEPT_SOURCE_LINES) {
|
||||
expect(rendered).toContain(line)
|
||||
}
|
||||
expect(rendered).not.toContain(resultSnippet(0, 1))
|
||||
for (const url of DROPPED_SOURCE_URLS) {
|
||||
expect(rendered).not.toContain(url)
|
||||
}
|
||||
expect(rendered).toContain(
|
||||
`(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`,
|
||||
)
|
||||
expect(searchResult.data.meta).toMatchObject({
|
||||
// The card keeps the display snippet; the summary reaches only the model.
|
||||
expect(searchResult.data.meta).toEqual({
|
||||
sources: KEPT_SOURCES,
|
||||
truncated: true,
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/collapse-timing.e2e.ts",
|
||||
"tests/notice-wake-tail.e2e.ts",
|
||||
"tests/fold-blank-matrix.e2e.ts",
|
||||
"tests/chat-fold-opened-group.e2e.ts",
|
||||
"tests/chat-flow-layout.e2e.ts",
|
||||
"tests/layout-bottom.e2e.ts",
|
||||
"tests/shortcuts-desktop.e2e.ts",
|
||||
|
||||
@@ -1430,7 +1430,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the local filesystem backend. */
|
||||
@@ -2799,8 +2799,8 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-schedule`
|
||||
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionController` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:72`](../packages/schedule/schedule/src/index.ts)
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:83`](../packages/schedule/schedule/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the Host Schedule domain. */
|
||||
@@ -4258,6 +4258,8 @@ export interface Config {
|
||||
searchMaxResults?: number
|
||||
/** Upper bound on queries accepted by one `web_search` call. */
|
||||
searchMaxQueries?: number
|
||||
/** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */
|
||||
searchSummaryMaxChars?: number
|
||||
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
@@ -4715,7 +4717,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
@@ -4765,6 +4767,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) |
|
||||
|
||||
@@ -1432,7 +1432,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts)
|
||||
- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the local filesystem backend. */
|
||||
@@ -2801,8 +2801,8 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-schedule`
|
||||
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionController` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:72`](../packages/schedule/schedule/src/index.ts)
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:83`](../packages/schedule/schedule/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the Host Schedule domain. */
|
||||
@@ -4260,6 +4260,8 @@ export interface Config {
|
||||
searchMaxResults?: number
|
||||
/** Upper bound on queries accepted by one `web_search` call. */
|
||||
searchMaxQueries?: number
|
||||
/** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */
|
||||
searchSummaryMaxChars?: number
|
||||
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
|
||||
fetchTimeoutMs?: number
|
||||
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
|
||||
@@ -4717,7 +4719,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
@@ -4767,6 +4769,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) |
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 0894bd9f35430753
|
||||
zh: 2d28090e71134edd
|
||||
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
|
||||
en: 68b463cd3d2e5fb0
|
||||
zh: 80ea797c5770c573
|
||||
en: ed501a72ab8ba6e7
|
||||
zh: 8ad775b735887a10
|
||||
/cookbook-adding-a-workspace-package/6-verify:
|
||||
en: 80c7fb90b1175aae
|
||||
zh: 2ba37962dbc57299
|
||||
@@ -147,7 +147,7 @@ Export `<package name>/locale/en.json` for locale lookup; expose `<package name>
|
||||
|
||||
For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork.
|
||||
|
||||
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
|
||||
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; keep its images beside it, and list its top-level directory, such as `docs`, in `files` right after the locale resources, as `check-workspace-constraints` requires. The named file must exist in a subdirectory of the package; a root-level document is rejected because its neighboring images would not be published. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
|
||||
|
||||
Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins.
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
|
||||
|
||||
要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。
|
||||
|
||||
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;将该文件及其图片加入 `files`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
|
||||
组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;图片放在该文件旁边,并按 `check-workspace-constraints` 的要求,在 `files` 中紧接语言资源之后列出该文件的顶层目录,例如 `docs`。该文件必须位于包的子目录中且已存在;包根目录下的文档会被拒绝,因为其旁边的图片不会被发布。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
|
||||
|
||||
已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
# 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 docs/cookbook/adding-a-plugin-preview.md
|
||||
/cookbook-add-a-built-in-plugin-feature-preview:
|
||||
en: 1038debc95283b43
|
||||
zh: a41eae3d9203c400
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/1-decide-whether-an-image-helps:
|
||||
en: d2b84c3a4d590f43
|
||||
zh: b064b42f148bd323
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/2-draw-one-fixed-size-svg:
|
||||
en: 1abb5284f499ae7e
|
||||
zh: b4dacd16d8f04728
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/3-register-the-internal-pair:
|
||||
en: e79355a7184d82d3
|
||||
zh: 92a0e188dee2717c
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/4-keep-the-page-contract:
|
||||
en: 095cb97e16c2232e
|
||||
zh: 2ed9b60710ed522c
|
||||
/cookbook-add-a-built-in-plugin-feature-preview/5-validate-and-review:
|
||||
en: 053b2105cc00b8d0
|
||||
zh: d06467ded2ccac42
|
||||
/cookbook-add-a-plugin-feature-preview:
|
||||
en: c37096cd3cdb886c
|
||||
zh: fc6850d68c638898
|
||||
/cookbook-add-a-plugin-feature-preview/1-decide-whether-an-image-helps:
|
||||
en: ee84f52d834cbccf
|
||||
zh: 7bf3a425a231b5e6
|
||||
/cookbook-add-a-plugin-feature-preview/2-draw-one-fixed-size-svg:
|
||||
en: 3a28640aa605dce8
|
||||
zh: c5336958f92b5a70
|
||||
/cookbook-add-a-plugin-feature-preview/3-put-it-at-the-top-of-the-detail-document:
|
||||
en: 17e2ace32a89c419
|
||||
zh: c635a890e86a41ec
|
||||
/cookbook-add-a-plugin-feature-preview/4-page-behavior:
|
||||
en: 20ce358cb110104c
|
||||
zh: abb8d72fed81f3c8
|
||||
/cookbook-add-a-plugin-feature-preview/5-validate-and-review:
|
||||
en: 2888c02bac267662
|
||||
zh: 5b0e95611c8d5d94
|
||||
@@ -1,52 +1,45 @@
|
||||
# Cookbook: Add a built-in plugin feature preview
|
||||
# Cookbook: Add a plugin feature preview
|
||||
|
||||
English | [中文](adding-a-plugin-preview.zh.md)
|
||||
|
||||
Plugin Manager owns optional feature illustrations for built-in bundles. Follow this guide when editing its internal `BUNDLE_PREVIEWS` map. Packages cannot declare illustrations through their manifest or exports; third-party plugins have no preview interface.
|
||||
A feature preview is an optional illustration at the top of a bundle's detail document, the long description that its page in **Plugins** shows first. Follow this guide when drawing one for an official bundle. The detail document itself is declared through [`meta.detail`](adding-a-package.md#plugin-display-metadata).
|
||||
|
||||
## 1. Decide whether an image helps
|
||||
|
||||
Show one concrete result or recognizable capability. Omit the map entry when the description is enough: no image is required, and omission reserves no space. Essential instructions stay in localized text or configuration outside the illustration.
|
||||
Show one concrete result or recognizable capability. Omit the image when the description is enough: no image is required. Essential instructions stay in the document's text or in configuration outside the illustration.
|
||||
|
||||
## 2. Draw one fixed-size SVG
|
||||
|
||||
| Item | Requirement |
|
||||
|---|---|
|
||||
| Count | Zero or one logical feature image per bundle. English and Chinese files are alternatives for that image, not a gallery or carousel. |
|
||||
| Format | SVG only: `width="640" height="300" viewBox="0 0 640 300"` (32:15). Raster preview files and other dimensions are unsupported. |
|
||||
| Languages | Supply both `en` and `zh`. Translate headings, annotations, and example content; preserve official brand assets and names. No single strings, arrays, or extra languages. |
|
||||
| Count | Zero or one feature image per bundle. English and Chinese files are alternatives for that image, not a gallery or carousel. |
|
||||
| Format | SVG: `width="640" height="300" viewBox="0 0 640 300"` (32:15). |
|
||||
| Languages | Supply both an English and a Chinese file. Translate headings, annotations, and example content; preserve official brand assets and names. |
|
||||
| Composition | One main example, optionally with one close-up. Keep important content about 32 canvas units from the edges. Review text at the page's actual size. |
|
||||
| Typography | Outline lettering into paths using a suitable font. Match the feature and neighboring UI; do not rely on browser font substitution. |
|
||||
| Theme | Compare light and dark themes. One illustration serves both; avoid transparent dark text that disappears on dark backgrounds. |
|
||||
|
||||
The SVG is self-contained: no fonts, remote resources, scripts, handlers, animation, stylesheets, `text`, or `foreignObject`. An `image` element may embed an unchanged official PNG/JPEG/WebP brand asset as a base64 data URL. Small plugin icons remain geometry-only. This exception preserves brand artwork and does not permit raster preview files. Validation checks the data-URL envelope, not decoded pixels; the page renders the SVG through `<img>`. Both raw language files together must stay within 1 MiB. They are embedded as base64 data URLs in the Plugin Manager client bundle, so keep them small.
|
||||
The SVG is self-contained: no fonts, remote resources, scripts, handlers, animation, stylesheets, `text`, or `foreignObject`. An `image` element may embed an unchanged official PNG/JPEG/WebP brand asset as a base64 data URL. Small plugin icons remain geometry-only. The page renders the SVG through `<img>`. Keep each file small; the Host refuses images above plugin-manager's `detailImageMaxBytes`, 5 MiB by default.
|
||||
|
||||
## 3. Register the internal pair
|
||||
## 3. Put it at the top of the detail document
|
||||
|
||||
Put `<feature>-preview.svg` and `<feature>-preview.zh.svg` under [Plugin Manager assets](../../packages/client/ui-plugin-manager/src/client/assets/). Import both into [PluginManagerPage.tsx](../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx), then add one exact built-in package name to `BUNDLE_PREVIEWS`:
|
||||
Save the pair as `docs/preview.svg` and `docs/preview.zh.svg` in the bundle package, beside `docs/DETAIL.md` and `docs/DETAIL.zh.md`, and make the image the first line of each document:
|
||||
|
||||
```ts ignore-check
|
||||
import examplePreview from './assets/example-preview.svg'
|
||||
import examplePreviewZh from './assets/example-preview.zh.svg'
|
||||
|
||||
// One entry in the existing Map<string, Readonly<Record<'en' | 'zh', string>>>.
|
||||
['@deepseek-ai/dsh-example-bundle', { en: examplePreview, zh: examplePreviewZh }]
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
Keep the map's required `en` and `zh` type. Do not add package metadata, a Host read method, exported preview resources, or a third-party declaration path. Use the [badge skill](../../packages/client/ui-plugin-manager/src/client/assets/badge-skill-preview.svg) and [voice input](../../packages/client/ui-plugin-manager/src/client/assets/voice-input-preview.svg) illustrations as examples. The voice transcript is explanatory sample content, not a recording result.
|
||||
The Chinese document references `preview.zh.svg` with Chinese alternative text. Use the [badge skill](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md) and [voice input](../../packages/experimental/voice-input-bundle/docs/DETAIL.md) documents as examples. The voice transcript is explanatory sample content, not a recording result.
|
||||
|
||||
## 4. Keep the page contract
|
||||
## 4. Page behavior
|
||||
|
||||
| Item | Current behavior |
|
||||
|---|---|
|
||||
| Position | Below the description and status notices, above components and source information; aligned with the description. |
|
||||
| Spacing | 24 px above the image; 32 px before the following details section. |
|
||||
| Display | One `<img>`, natural 640×300 canvas, at most the available width and 320 px high; proportional scaling. The page supplies border and 16 px corners. |
|
||||
| Language | The current UI language selects one of the required files. Alternative text uses the localized plugin name. |
|
||||
| Availability | Visible before activation. Decode failure shows a localized notice without blocking controls. Lists and component rows retain their small icons. |
|
||||
| Position | First in the detail document, which is the first section of the bundle page, below the description and status notices. |
|
||||
| Display | The detail document renders the image at most its own width, inside its height-bounded scroll area. |
|
||||
| Language | The document for the UI language references its own file. |
|
||||
| Availability | Visible before activation. Lists and component rows keep their small icons. |
|
||||
|
||||
## 5. Validate and review
|
||||
|
||||
Run `pnpm exec vitest run scripts/builtin-plugin-preview.spec.ts packages/client/ui-plugin-manager/tests/components.client.spec.tsx scripts/client-bundle-css.spec.ts`, then `pnpm run build`. The asset test checks every registered pair, fixed SVG dimensions, self-contained content, and combined size. Type checking requires both languages. These checks enforce format; visual quality and equivalent meaning in both languages still require review.
|
||||
|
||||
Open the real plugin details in English and Chinese before activation. Decode the image and confirm 640×300 natural dimensions, placement, light/dark readability, and no horizontal overflow at 390 px. Also check a built-in without artwork and a third-party plugin. Follow [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) for PR evidence; record from the final clean commit and label the demonstrated flow accurately.
|
||||
`scripts/plugin-preview-assets.spec.ts` checks every preview pair in a bundle's `docs` directory: the fixed canvas, absent scripts, stylesheets, text elements, and external references, the 1 MiB pair limit, and English and Chinese files that differ. Then open the real plugin details in English and Chinese before activation and confirm the image loads, its placement, light/dark readability, and no horizontal overflow at 390 px; review owns visual quality and equivalent meaning in both languages. Follow [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) for PR evidence; record from the final clean commit and label the demonstrated flow accurately.
|
||||
@@ -1,52 +1,45 @@
|
||||
# Cookbook: 添加内置插件功能配图
|
||||
# Cookbook: 添加插件功能配图
|
||||
|
||||
[English](adding-a-plugin-preview.md) | 中文
|
||||
|
||||
插件管理拥有内置组合包的可选功能配图。编辑其内部 `BUNDLE_PREVIEWS` 映射时遵循本指引。包不能通过 manifest 或 exports 声明配图;第三方插件没有配图接口。
|
||||
功能配图是位于组合包详细介绍文档顶部的可选插图;详细介绍文档是该组合包在**插件**中的页面最先显示的长描述。为官方组合包绘制配图时遵循本指引。详细介绍文档本身通过 [`meta.detail`](adding-a-package.zh.md#plugin-display-metadata) 声明。
|
||||
|
||||
## 1. 判断是否需要配图
|
||||
|
||||
展示一个具体结果或容易辨认的能力。文字说明足够时不添加映射项:配图不是必需的,省略时不占空间。必要操作说明仍放在图外的本地化文字或配置中。
|
||||
展示一个具体结果或容易辨认的能力。文字说明足够时省略配图:配图不是必需的。必要操作说明仍放在文档文字或图外的配置中。
|
||||
|
||||
## 2. 绘制一张固定尺寸 SVG
|
||||
|
||||
| 项目 | 要求 |
|
||||
|---|---|
|
||||
| 数量 | 每个组合包零张或一张逻辑配图。英文和中文文件是同一张图的替代版本,不是图库或轮播。 |
|
||||
| 格式 | 仅 SVG:`width="640" height="300" viewBox="0 0 640 300"`(32:15)。不支持位图配图文件或其他尺寸。 |
|
||||
| 语言 | 同时提供 `en` 和 `zh`。翻译标题、标注和示例内容,保留官方品牌素材和名称。不使用单个字符串、数组或额外语言。 |
|
||||
| 数量 | 每个组合包零张或一张配图。英文和中文文件是同一张图的替代版本,不是图库或轮播。 |
|
||||
| 格式 | SVG:`width="640" height="300" viewBox="0 0 640 300"`(32:15)。 |
|
||||
| 语言 | 同时提供英文和中文文件。翻译标题、标注和示例内容,保留官方品牌素材和名称。 |
|
||||
| 构图 | 一个主体示例,可选一个局部特写。重要内容距边缘约 32 个画布单位;按页面实际显示尺寸检查文字。 |
|
||||
| 字体 | 选择合适字体并把文字转成路径,匹配功能及相邻界面,不依赖浏览器字体替换。 |
|
||||
| 主题 | 对比浅色和深色主题。同一张图服务两种主题,避免透明底深色文字在深色背景中消失。 |
|
||||
|
||||
SVG 必须自包含:不使用字体、远程资源、脚本、事件处理器、动画、样式表、`text` 或 `foreignObject`。`image` 元素可以通过 base64 data URL 嵌入未改动的官方 PNG/JPEG/WebP 品牌素材。小插件图标仍仅使用几何图形。此例外用于保留品牌素材,不允许直接使用位图配图文件。校验检查 data URL 结构,不解码像素;页面通过 `<img>` 渲染 SVG。两种语言的原始文件合计不超过 1 MiB。图片会以 base64 data URL 嵌入插件管理 client bundle,因此应控制大小。
|
||||
SVG 必须自包含:不使用字体、远程资源、脚本、事件处理器、动画、样式表、`text` 或 `foreignObject`。`image` 元素可以通过 base64 data URL 嵌入未改动的官方 PNG/JPEG/WebP 品牌素材。小插件图标仍仅使用几何图形。页面通过 `<img>` 渲染 SVG。每个文件都应控制大小;Host 会拒绝超过 plugin-manager 的 `detailImageMaxBytes`(默认 5 MiB)的图片。
|
||||
|
||||
## 3. 注册内部双语配图
|
||||
## 3. 放在详细介绍文档顶部
|
||||
|
||||
将 `<feature>-preview.svg` 和 `<feature>-preview.zh.svg` 放在[插件管理素材目录](../../packages/client/ui-plugin-manager/src/client/assets/)下。在 [PluginManagerPage.tsx](../../packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx) 中导入两者,再向 `BUNDLE_PREVIEWS` 添加一个精确的内置包名:
|
||||
将这对文件保存为组合包中的 `docs/preview.svg` 和 `docs/preview.zh.svg`,与 `docs/DETAIL.md` 和 `docs/DETAIL.zh.md` 放在一起,并把图片作为每份文档的第一行:
|
||||
|
||||
```ts ignore-check
|
||||
import examplePreview from './assets/example-preview.svg'
|
||||
import examplePreviewZh from './assets/example-preview.zh.svg'
|
||||
|
||||
// One entry in the existing Map<string, Readonly<Record<'en' | 'zh', string>>>.
|
||||
['@deepseek-ai/dsh-example-bundle', { en: examplePreview, zh: examplePreviewZh }]
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
保留映射中必需的 `en` 和 `zh` 类型。不添加包元数据、Host 读取方法、导出的配图资源或第三方声明入口。可参考[徽章技能](../../packages/client/ui-plugin-manager/src/client/assets/badge-skill-preview.svg)和[语音输入](../../packages/client/ui-plugin-manager/src/client/assets/voice-input-preview.svg)配图。语音图中的转写文字是说明性示例,不是录音结果。
|
||||
中文文档引用 `preview.zh.svg`,并使用中文替代文字。可参考[徽章技能](../../packages/experimental/badge-skill-bundle/docs/DETAIL.md)和[语音输入](../../packages/experimental/voice-input-bundle/docs/DETAIL.md)文档。语音图中的转写文字是说明性示例,不是录音结果。
|
||||
|
||||
## 4. 保持页面契约
|
||||
## 4. 页面行为
|
||||
|
||||
| 项目 | 当前行为 |
|
||||
|---|---|
|
||||
| 位置 | 在描述和状态提示下方、组件与来源信息上方,与说明左边缘对齐。 |
|
||||
| 间距 | 图片上方 24 px,后续详情区前 32 px。 |
|
||||
| 显示 | 一个 `<img>`,原始画布 640×300,最大宽度为可用宽度,最大高度 320 px,等比缩放;页面提供边框及 16 px 圆角。 |
|
||||
| 语言 | 当前 UI 语言选择必需的两份文件之一;替代文字使用本地化插件名。 |
|
||||
| 可用性 | 启用前可见;解码失败显示本地化提示,不阻塞操作;列表和组件行保留小图标。 |
|
||||
| 位置 | 位于详细介绍文档开头;该文档是组合包页面的第一个区域,在描述和状态提示下方。 |
|
||||
| 显示 | 详细介绍文档以不超过文档自身宽度的尺寸渲染图片,图片位于有高度上限的滚动区域内。 |
|
||||
| 语言 | 当前 UI 语言对应的文档引用其自己的文件。 |
|
||||
| 可用性 | 启用前可见;列表和组件行保留小图标。 |
|
||||
|
||||
## 5. 校验与验收
|
||||
|
||||
运行 `pnpm exec vitest run scripts/builtin-plugin-preview.spec.ts packages/client/ui-plugin-manager/tests/components.client.spec.tsx scripts/client-bundle-css.spec.ts`,再运行 `pnpm run build`。素材测试检查每个注册条目的双语文件、固定 SVG 尺寸、自包含内容和合计大小;类型检查要求同时提供两种语言。这些检查约束格式;视觉质量及双语含义一致仍需人工审阅。
|
||||
|
||||
在真实插件详情页分别使用英文和中文,启用前解码图片,确认原始尺寸 640×300、插入位置、浅色及深色主题的可读性,以及 390 px 宽度下没有横向溢出。同时检查一个无配图的内置插件和一个第三方插件。按 [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) 制作 PR 证据,从最终干净提交录制,并准确说明演示范围。
|
||||
`scripts/plugin-preview-assets.spec.ts` 检查组合包 `docs` 目录中的每一对配图:固定画布;不使用脚本、样式表、`text` 元素或外部引用;每对文件合计不超过 1 MiB;英文和中文文件不相同。然后在真实插件详情页分别使用英文和中文,启用前确认图片能加载,并检查插入位置、浅色及深色主题的可读性,以及 390 px 宽度下没有横向溢出;审阅负责检查视觉质量及双语含义是否一致。按 [record-browser-gif](../../.agents/skills/record-browser-gif/SKILL.md) 制作 PR 证据,从最终干净提交录制,并准确说明演示范围。
|
||||
@@ -63,6 +63,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
@@ -82,7 +83,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
@@ -84,7 +85,7 @@
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 08386e96eebf06d9
|
||||
zh: c6d2b41199b0c466
|
||||
/bilingual-documentation/scope-and-exclusions:
|
||||
en: db70e758813d339b
|
||||
zh: 3dcea283a08c47cb
|
||||
en: eeda71b7ce01c121
|
||||
zh: a6361361c1e3d578
|
||||
/bilingual-documentation/division-of-labor:
|
||||
en: 88097c97cb72f3ba
|
||||
zh: 78a18000626cc818
|
||||
@@ -47,6 +47,8 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
|
||||
|
||||
**Scope**: the root `CONTRIBUTING.md`, `BRAND_GUIDELINES.md`, and `SAFETY.md` documents, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
|
||||
|
||||
**Outside scope**: a bundle's detail documents under `packages/<group>/<package>/docs/`, which locale `meta.detail` names for the plugin page. The page shows the file for the active UI language, so these documents carry no in-document language switcher and no consistency record; [`verify-md-links`](../../scripts/verify-md-links.ts) and [`verify-md-wrap`](../../scripts/verify-md-wrap.ts) still check both languages, and review keeps them in step.
|
||||
|
||||
Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. A generator puts its generated data in generated regions and writes each region into both pages, so regeneration never requires a manual Chinese update or a new record; only the prose outside the regions is translated and recorded. Freshness and pairing gates enforce their respective invariants independently.
|
||||
|
||||
Shared generated regions, such as the Cordis subsystem regions and the per-package regions of the [config catalog](../config-catalog.md), keep every generated byte identical across languages except localized paired-document paths; labels inside a region are code identifiers rather than translated words. A full-page generator can also render source-owned paired prose, such as [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts), from the same structural data with matching Markdown structure and identical code blocks. It regenerates both pages and their consistency record together; review still verifies the paired prose's meaning and terminology.
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
|
||||
**范围**:根目录 `CONTRIBUTING.md`、`BRAND_GUIDELINES.md` 与 `SAFETY.md` 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
|
||||
|
||||
**范围外**:组合包 `packages/<group>/<package>/docs/` 下由 locale `meta.detail` 指定给插件页的详情文档。插件页只显示当前界面语言对应的文件,因此这些文档不带文档内的语言切换行,也没有一致性记录;[`verify-md-links`](../../scripts/verify-md-links.ts) 与 [`verify-md-wrap`](../../scripts/verify-md-wrap.ts) 仍会检查两种语言,两侧是否同步由评审把关。
|
||||
|
||||
有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器把生成数据放在生成区域中,并把每个区域写入两种语言的页面,因此重新生成不需要手工更新中文,也不需要重新记录;只有区域外的说明文字需要翻译并计入记录。新鲜度门禁与配对门禁各自独立强制其约束。
|
||||
|
||||
共享生成区域(例如 Cordis 子系统区域和[配置目录](../config-catalog.zh.md)中每个包的区域)在两种语言间保持每个生成字节相同,仅配对文档路径按语言本地化;区域内的标签使用代码标识符而非译文。整页生成器也可以从相同的结构数据渲染源码维护的双语说明,例如 [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts),并保持 Markdown 结构对应、代码块完全相同。它同时重新生成两页及其一致性记录;评审仍须验证双语说明的含义与术语。
|
||||
|
||||
+18
-13
@@ -241,6 +241,7 @@ flowchart TD
|
||||
pkg_deepseek_account_platform["deepseek-account-platform"]
|
||||
end
|
||||
subgraph group_deliverables["packages/deliverables"]
|
||||
pkg_deliverable_file_references["deliverable-file-references"]
|
||||
pkg_tool_present["tool-present"]
|
||||
pkg_workspace_changes["workspace-changes"]
|
||||
end
|
||||
@@ -488,7 +489,6 @@ flowchart TD
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_web_fetch_http --> pkg_http_proxy
|
||||
pkg_web_fetch_http --> pkg_timeout
|
||||
pkg_web_fetch_http --> pkg_web
|
||||
pkg_web_search_exa --> pkg_launch_environment
|
||||
@@ -503,6 +503,7 @@ flowchart TD
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_hmr --> pkg_app_boot
|
||||
pkg_hmr --> pkg_cmdline
|
||||
pkg_deliverable_file_references --> pkg_system_prompt
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
@@ -1366,6 +1367,7 @@ flowchart TD
|
||||
pkg_sdk_protocol --> pkg_llm
|
||||
pkg_sdk_protocol --> pkg_session
|
||||
pkg_sdk_protocol --> pkg_subagent
|
||||
pkg_sdk_protocol --> pkg_user_questions
|
||||
pkg_workflow_ptc --> pkg_agent
|
||||
pkg_workflow_ptc --> pkg_llm
|
||||
pkg_workflow_ptc --> pkg_ptc_runtime
|
||||
@@ -1397,15 +1399,6 @@ flowchart TD
|
||||
pkg_sdk_client --> pkg_llm
|
||||
pkg_sdk_client --> pkg_sdk_protocol
|
||||
pkg_sdk_client --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
pkg_client_test_runtime --> pkg_api_gateway
|
||||
pkg_client_test_runtime --> pkg_api_session_controller
|
||||
pkg_client_test_runtime --> pkg_api_workspace_controller
|
||||
@@ -1435,6 +1428,17 @@ flowchart TD
|
||||
pkg_tool_schedule --> pkg_schedule
|
||||
pkg_tool_schedule --> pkg_subagent
|
||||
pkg_tool_schedule --> pkg_tools
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_schedule
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED module-graph:packages -->
|
||||
@@ -1595,13 +1599,14 @@ flowchart TD
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
|
||||
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
|
||||
@@ -1778,13 +1783,13 @@ flowchart TD
|
||||
| [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
+18
-13
@@ -243,6 +243,7 @@ flowchart TD
|
||||
pkg_deepseek_account_platform["deepseek-account-platform"]
|
||||
end
|
||||
subgraph group_deliverables["packages/deliverables"]
|
||||
pkg_deliverable_file_references["deliverable-file-references"]
|
||||
pkg_tool_present["tool-present"]
|
||||
pkg_workspace_changes["workspace-changes"]
|
||||
end
|
||||
@@ -490,7 +491,6 @@ flowchart TD
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_skill_office --> pkg_skill
|
||||
pkg_web_fetch_http --> pkg_http_proxy
|
||||
pkg_web_fetch_http --> pkg_timeout
|
||||
pkg_web_fetch_http --> pkg_web
|
||||
pkg_web_search_exa --> pkg_launch_environment
|
||||
@@ -505,6 +505,7 @@ flowchart TD
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_hmr --> pkg_app_boot
|
||||
pkg_hmr --> pkg_cmdline
|
||||
pkg_deliverable_file_references --> pkg_system_prompt
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use
|
||||
pkg_experimental_webworker_runtime --> pkg_client_connection
|
||||
pkg_experimental_webworker_runtime --> pkg_client_modules
|
||||
@@ -1368,6 +1369,7 @@ flowchart TD
|
||||
pkg_sdk_protocol --> pkg_llm
|
||||
pkg_sdk_protocol --> pkg_session
|
||||
pkg_sdk_protocol --> pkg_subagent
|
||||
pkg_sdk_protocol --> pkg_user_questions
|
||||
pkg_workflow_ptc --> pkg_agent
|
||||
pkg_workflow_ptc --> pkg_llm
|
||||
pkg_workflow_ptc --> pkg_ptc_runtime
|
||||
@@ -1399,15 +1401,6 @@ flowchart TD
|
||||
pkg_sdk_client --> pkg_llm
|
||||
pkg_sdk_client --> pkg_sdk_protocol
|
||||
pkg_sdk_client --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
pkg_client_test_runtime --> pkg_api_gateway
|
||||
pkg_client_test_runtime --> pkg_api_session_controller
|
||||
pkg_client_test_runtime --> pkg_api_workspace_controller
|
||||
@@ -1437,6 +1430,17 @@ flowchart TD
|
||||
pkg_tool_schedule --> pkg_schedule
|
||||
pkg_tool_schedule --> pkg_subagent
|
||||
pkg_tool_schedule --> pkg_tools
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm
|
||||
pkg_sdk_jsonrpc_server --> pkg_llm_deepseek_api_key
|
||||
pkg_sdk_jsonrpc_server --> pkg_schedule
|
||||
pkg_sdk_jsonrpc_server --> pkg_scope
|
||||
pkg_sdk_jsonrpc_server --> pkg_sdk_protocol
|
||||
pkg_sdk_jsonrpc_server --> pkg_session
|
||||
pkg_sdk_jsonrpc_server --> pkg_subagent
|
||||
pkg_sdk_jsonrpc_server --> pkg_user_questions
|
||||
pkg_sdk_jsonrpc_server --> pkg_working_directory
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED module-graph:packages -->
|
||||
@@ -1597,13 +1601,14 @@ flowchart TD
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
|
||||
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
|
||||
| [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) |
|
||||
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
|
||||
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
|
||||
@@ -1780,13 +1785,13 @@ flowchart TD
|
||||
| [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -9,11 +9,11 @@
|
||||
en: 96b95cdb015c1af6
|
||||
zh: 01e4144d91551c13
|
||||
/client-modules/the-scan:
|
||||
en: 91ce66fab388a8a2
|
||||
zh: 77428943582ae3f6
|
||||
en: 811c0e8a3a370399
|
||||
zh: 4a49a3999d35503a
|
||||
/client-modules/the-bundle-route-and-index-injection:
|
||||
en: d653be500d3a0d67
|
||||
zh: fffb1901ce8ba7d9
|
||||
en: 82c089fbd9ffd535
|
||||
zh: 30ac52d01b4fc074
|
||||
/client-modules/the-service:
|
||||
en: 000882ed7f09ad3f
|
||||
zh: c17c05f087d9574d
|
||||
en: b1df1bf2b8ee3ad1
|
||||
zh: 6373d1b8cae22d02
|
||||
@@ -95,11 +95,11 @@ A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional
|
||||
|
||||
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
|
||||
|
||||
Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
|
||||
Each reconciliation reads the package manifest and the bundle metadata again. A fiber restart from the same source that finds the same declaration and bundle metadata keeps its row and rev and reads no bundle bytes; a changed declaration, client export, or bundle is published by that reconciliation. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()`.
|
||||
|
||||
## The bundle route and index injection
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. `/plugins/<package>/assets/<path>` serves a package's published images; the [package README](../../packages/client/modules/README.md#package-assets) owns that route. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
|
||||
|
||||
## The service
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` returns the same responses as the HTTP route: the lazy bundle responses, and package assets read on each request. `rebuilt(id)` publishes a bundle rebuilt without an entry restart: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies; an entry restart publishes through reconciliation instead. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
|
||||
|
||||
[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
|
||||
|
||||
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -150,10 +150,13 @@ graph(): WebBootGraph
|
||||
clientPath(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
rebuilt(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
|
||||
@@ -95,11 +95,11 @@ interface WebBootGraph {
|
||||
|
||||
扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
|
||||
|
||||
包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启会原样复用其 row 与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
|
||||
每次对账都会重新读取包 manifest 与 bundle 元数据。同一来源的 fiber 重启若读到相同的声明与 bundle 元数据,就保留其 row 与 rev,且不读取 bundle 字节;声明、client 导出或 bundle 发生变化时,由这次对账发布。没有伴随条目重启的 bundle 重建经 `rebuilt()` 到达图。
|
||||
|
||||
## bundle 路由与 index 注入
|
||||
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。`/plugins/<package>/assets/<path>` 提供包发布的图片,该路由由[包 README](../../packages/client/modules/README.zh.md#package-assets) 说明。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
|
||||
|
||||
## 服务
|
||||
|
||||
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
|
||||
}
|
||||
```
|
||||
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 返回与 HTTP 路由相同的响应:惰性的 bundle 响应,以及每次请求都读取的包资源。`rebuilt(id)` 发布没有伴随条目重启的 bundle 重建:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知;条目重启则经对账发布。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
|
||||
|
||||
随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
|
||||
|
||||
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.clientModules` — `ClientModuleRegistry`
|
||||
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -150,10 +150,13 @@ graph(): WebBootGraph
|
||||
clientPath(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Serve an advertised revisioned bundle or source map without a Web server.
|
||||
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
|
||||
* returns the same immutable headers without materializing a body. Each body
|
||||
* is built once on its first `GET`; script construction never reads maps.
|
||||
* Serve an advertised revisioned bundle, source map, chunk, or package asset
|
||||
* without a Web server. Unknown URLs return 404, unsupported methods return
|
||||
* 405, and `HEAD` returns the same immutable headers without materializing a
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
|
||||
/**
|
||||
* Publish one completed bundle generation (the HMR watch's registration
|
||||
* hook — the only entry point through which build changes reach the graph).
|
||||
* hook; an entry restart publishes through reconciliation instead).
|
||||
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
|
||||
* @param id - entry id (package name).
|
||||
* @returns the current artifact rev, or undefined for an unknown id.
|
||||
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
|
||||
rebuilt(id: string): string | undefined
|
||||
|
||||
/**
|
||||
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
|
||||
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
|
||||
* entry restart publishes reaches only {@link onGraphChanged} listeners.
|
||||
* @param listener - receives the entry id and its new bundle rev.
|
||||
* @returns the unsubscriber.
|
||||
*/
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
en: 077aa6573d9ede1a
|
||||
zh: 0acea592a6e3630a
|
||||
/host-wide-schedule/host-delivery:
|
||||
en: b6d522d24b9b97ba
|
||||
zh: 84df8ea09bedf51c
|
||||
en: d5fc5b853529b139
|
||||
zh: 4cc5eba14f9660e6
|
||||
@@ -431,7 +431,7 @@ New tasks initialize an empty history. Older task rows without history expose on
|
||||
|
||||
## Host delivery
|
||||
|
||||
The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session through the Session controller, including cold Sessions. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch.
|
||||
The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session, including cold Sessions, through `schedule/resolve-agent`. The embedding owns activation: the SDK handler waits for initialization and retains its Session handle and model options; without a handler, the Web Session controller restores the Session. The composition waits for its activation provider before starting Schedule. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch.
|
||||
|
||||
Plugin-sourced `followup()` synchronously appends the message to the Session inbox. After a successful Session flush, one task-record put appends a saved delivery with its instruction snapshot and stores the latest `ScheduleDeliveryReceipt` together with the one-shot's `inactive` status or the recurring task's next target and status. Recurring tasks in the same batch share the receipt's `messageId`; each receipt retains that task's delivered occurrence time. Delivery never steers or cancels the current turn and does not wait for model completion.
|
||||
|
||||
@@ -547,4 +547,24 @@ Durable task set changed; clients refetch global task and Session-active catalog
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
<a id="scheduleresolve-agent--waterfall"></a>
|
||||
|
||||
#### `schedule/resolve-agent` — waterfall
|
||||
|
||||
Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Restore a reminder's Session through its embedding's owning controller.
|
||||
* Call next() to use the Web Session controller.
|
||||
* @param sessionId - Original Session receiving the reminder.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise<Agent>): Promise<Agent>
|
||||
```
|
||||
|
||||
Types: [Agent](core.md) · [SessionId](core.md)
|
||||
|
||||
Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -431,7 +431,7 @@ type ScheduleDeliveryHistoryResult =
|
||||
|
||||
## 宿主投递
|
||||
|
||||
宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递时通过 Session controller 获取原 Session,包括尚未加载的 Session。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。
|
||||
宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递通过 `schedule/resolve-agent` 解析原 Session,包括尚未激活的会话。嵌入方拥有激活过程:SDK 处理方等待初始化,并保留其 Session handle 和模型选项;没有处理方时由 Web Session controller 恢复会话。组合等待其激活提供方就绪后再启动 Schedule。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。
|
||||
|
||||
插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后,一次任务记录 put 追加带有指令快照的发送记录,并同时存储最新的 `ScheduleDeliveryReceipt`,以及一次性任务的 `inactive` 状态或重复任务的下一目标和状态。同一批次的重复任务共享回执的 `messageId`;每条回执保留该任务已投递的发生时点。投递不会中途引导或取消当前轮次,也不等待模型完成。
|
||||
|
||||
@@ -547,4 +547,24 @@ Durable task set changed; clients refetch global task and Session-active catalog
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
<a id="scheduleresolve-agent--waterfall"></a>
|
||||
|
||||
#### `schedule/resolve-agent` — waterfall
|
||||
|
||||
Restore a reminder's Session through its embedding's owning controller. Call next() to use the Web Session controller.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Restore a reminder's Session through its embedding's owning controller.
|
||||
* Call next() to use the Web Session controller.
|
||||
* @param sessionId - Original Session receiving the reminder.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'schedule/resolve-agent'(sessionId: SessionId, next: () => Promise<Agent>): Promise<Agent>
|
||||
```
|
||||
|
||||
Types: [Agent](core.zh.md) · [SessionId](core.zh.md)
|
||||
|
||||
Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
|
||||
/session-projections:
|
||||
en: e9330151d3eb579a
|
||||
zh: 53e47acd1d810da9
|
||||
en: 45e5f8cb8ca18d6b
|
||||
zh: 4ecfad40ec9e221c
|
||||
/session-projections/the-unit:
|
||||
en: d48814d3ab0e997f
|
||||
zh: d0337d8eba097a63
|
||||
|
||||
@@ -26,6 +26,8 @@ interface ProjectionHandle<S> {
|
||||
}
|
||||
```
|
||||
|
||||
The [compatibility rules](../../packages/session/session-projection/README.md#deprecated-plugin-compatibility) define legacy disposal, keyed reads, and their once-per-key deprecation warnings.
|
||||
|
||||
## The unit
|
||||
|
||||
`ProjectionDefinition<K, S>` carries each unit's host state type locally; registration infers `S` from `stateSchema` and returns `ProjectionHandle<S>`. Only client-visible keys merge into `SessionProjectionMap`, which types their whole values. A `wire` block makes a unit client-visible, and rendering belongs to the slot system, never this layer:
|
||||
@@ -314,7 +316,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
|
||||
* Retained handles remain readable after release.
|
||||
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
|
||||
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
||||
* @returns a ready handle with synchronous state reads and the effect disposer.
|
||||
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
|
||||
*/
|
||||
register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): Promise<ProjectionHandle<S>>
|
||||
|
||||
@@ -323,7 +325,7 @@ register<K extends keyof SessionProjectionMap, S>( definition: Omit<ProjectionDe
|
||||
* and always checkpointed like every other unit.
|
||||
* Callers must await registration before publishing consumers; registration may perform asynchronous work.
|
||||
* @param definition - key, state schema, pure unit functions, and stateVersion.
|
||||
* @returns a ready handle with synchronous state reads and the effect disposer.
|
||||
* @returns an awaitable yielding a ready handle; the callable runtime value is deprecated.
|
||||
*/
|
||||
register<K extends string, S>( definition: ProjectionDefinition<K, S> & { key: Exclude<K, keyof SessionProjectionMap> }, ): Promise<ProjectionHandle<S>>
|
||||
|
||||
|
||||
Loaded 100 of 559 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user