Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a7f68b09 | ||
|
|
a60af51e80 | ||
|
|
07ad70817f | ||
|
|
c76217a356 | ||
|
|
490f569044 | ||
|
|
7c8a44ac31 | ||
|
|
b09dd94827 | ||
|
|
f64c57dbb2 | ||
|
|
51d70c5f5c | ||
|
|
96a4607ba2 | ||
|
|
6943f9fee6 | ||
|
|
5a32aae571 | ||
|
|
1b55f5bea3 | ||
|
|
4987e90b32 | ||
|
|
ac77cefba7 | ||
|
|
d6fb1351b3 | ||
|
|
015a9b202c | ||
|
|
b660f72d4e | ||
|
|
fb8bb16345 | ||
|
|
312341970c | ||
|
|
9b510d89a5 | ||
|
|
896c81d796 | ||
|
|
747c98b0db | ||
|
|
5ea77078d6 | ||
|
|
2c67633990 | ||
|
|
19d6df2b2f | ||
|
|
e0e8327992 | ||
|
|
7eb9629e0a | ||
|
|
4aa207c5fe | ||
|
|
e4234651ee | ||
|
|
c5dc0853d6 | ||
|
|
bd2a49a2ca | ||
|
|
45f5cd34b0 | ||
|
|
62aef4c583 | ||
|
|
4839239954 | ||
|
|
e68fb0bc8d | ||
|
|
4cfd292a7b | ||
|
|
dc0ae81905 | ||
|
|
a8776afa87 | ||
|
|
219db5454a | ||
|
|
3675e91cf9 | ||
|
|
5e25475857 | ||
|
|
f4c6b012aa | ||
|
|
fe4d18fda3 | ||
|
|
907a241b61 | ||
|
|
e5e9b5b982 | ||
|
|
7fe9c06fa6 | ||
|
|
c920174964 | ||
|
|
3776169373 | ||
|
|
19675fd061 | ||
|
|
536764c8b4 | ||
|
|
b66b602c72 | ||
|
|
913aa2289b | ||
|
|
701026b5bf | ||
|
|
5840898e07 | ||
|
|
b2817d77fb | ||
|
|
a60d4df027 | ||
|
|
833835d95c | ||
|
|
74511b3dcb | ||
|
|
d3086e6571 | ||
|
|
d550f1c4af | ||
|
|
ccaa0dc11c | ||
|
|
19844ea9ad | ||
|
|
a2278b460c | ||
|
|
80e4da0e3d | ||
|
|
f132684c81 | ||
|
|
3d6d46b8a6 | ||
|
|
dd004b4221 | ||
|
|
38596d5c70 | ||
|
|
8833ef9f63 | ||
|
|
995caa05ed | ||
|
|
10292baeff | ||
|
|
bbeef5f250 | ||
|
|
d52424042c | ||
|
|
2ea753960c | ||
|
|
2b44a4b657 | ||
|
|
4face54ea7 | ||
|
|
0b62921f37 | ||
|
|
86e3e0e448 | ||
|
|
61936aa113 | ||
|
|
3b8be2928f | ||
|
|
3c20f401d2 | ||
|
|
a56e82e5dd | ||
|
|
a96d45af40 | ||
|
|
63cc821e7e | ||
|
|
df7fa8945c | ||
|
|
edd0a438bf | ||
|
|
02383832df | ||
|
|
5082e05f71 | ||
|
|
c4955b1900 | ||
|
|
354da32a38 | ||
|
|
f546068553 | ||
|
|
3bf6d738f1 | ||
|
|
5124a2a310 | ||
|
|
2aa3a469db | ||
|
|
3f07e34b5e | ||
|
|
d7b45fb415 | ||
|
|
75d74a055e | ||
|
|
f68d4b5aba | ||
|
|
752953d2b6 | ||
|
|
2b9923fede | ||
|
|
c3afd488aa | ||
|
|
3f0022a8fa | ||
|
|
040f47a822 | ||
|
|
c68c6db9ca | ||
|
|
0067efc8ae | ||
|
|
d40dbef91b | ||
|
|
5bc57902d5 | ||
|
|
dc374b8879 | ||
|
|
14a062acb7 | ||
|
|
ceb997cdef | ||
|
|
60b5800816 | ||
|
|
0edfe83573 | ||
|
|
4bf9d2e0c0 | ||
|
|
9095678c8c | ||
|
|
9fcb76d9bc | ||
|
|
e8d5278c46 | ||
|
|
6a07033145 | ||
|
|
5925925d20 | ||
|
|
a6b909c767 | ||
|
|
b73031be9b | ||
|
|
fd784e4429 | ||
|
|
969c3df8c1 | ||
|
|
1498b01923 | ||
|
|
bf3de68213 | ||
|
|
82a7dd9c3f | ||
|
|
5b600047d2 | ||
|
|
20e7407ff1 | ||
|
|
d6e5b7e41a | ||
|
|
fac0d8d877 | ||
|
|
03294cb653 | ||
|
|
ee0048558d | ||
|
|
700b636508 | ||
|
|
bfc772bb47 | ||
|
|
749f05e9fa | ||
|
|
db4bfb4c96 | ||
|
|
1a21e958df | ||
|
|
7befda1147 | ||
|
|
b7d9c0f6a4 | ||
|
|
843e1b97af | ||
|
|
95611fbc63 | ||
|
|
2426d398b5 | ||
|
|
d08aac9db2 | ||
|
|
5ca4fea9b5 | ||
|
|
44900be005 | ||
|
|
68e43b173e | ||
|
|
552235aab6 | ||
|
|
38dbb56237 | ||
|
|
1dc518f217 | ||
|
|
a2487e34b7 | ||
|
|
cbd6562506 | ||
|
|
b79b64f788 | ||
|
|
06ef26a80f | ||
|
|
6613660223 | ||
|
|
40edba6a8d | ||
|
|
3515c48a37 |
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
|
||||
2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9
|
||||
2026-08-09-client-conversation-node-assembly.md: c8773b77fe761c3a566e02f0bd6aeb18f26e0a89
|
||||
2026-08-09-client-conversation-node-assembly.zh.md: 076ad35178996e01eae26b3909feae8d967414c1
|
||||
@@ -14,6 +14,8 @@ Business events also use different correlation models. Tool has call IDs, Assist
|
||||
|
||||
## Decision
|
||||
|
||||
The [three-stage Tool lifecycle](2026-09-22-tool-call-three-phases.md) owns transient initialization, repeated start matches for one ID, and tool preparation; this record's other node-assembly responsibilities remain applicable.
|
||||
|
||||
Client Runtime provides a target-neutral Conversation Node assembly engine. Business plugins register Event Definitions, and view plugins register per-Session View Builders. `ui-conversation` registers the first built-in Definitions and the `chat` builder; Session only submits the current contiguous `SessionEventLikeEntry` window to the engine and publishes its snapshot instead of interpreting individual conversation businesses. The entry's outer discriminator distinguishes standard and packed records, while both carry an aligned inner `SessionEventLike` for Definition dispatch.
|
||||
|
||||
This Note retains the derivation, business-by-business validation, responsibilities, algorithms, and trade-offs that remain relevant after implementation.
|
||||
|
||||
+2
@@ -14,6 +14,8 @@ Client Session 既维护传输窗口、连接状态和待处理交互,也在
|
||||
|
||||
## 决策
|
||||
|
||||
start 的瞬态创建入口、同 ID 多次 start 及工具准备阶段以[工具调用三阶段](2026-09-22-tool-call-three-phases.zh.md)为准;本记录的其他节点组装职责继续适用。
|
||||
|
||||
Client Runtime 提供 target-neutral 的 Conversation Node 组装引擎,业务插件注册 Event Definition,视图插件注册 per-Session View Builder。`ui-conversation` 注册第一批内建 Definition 和 `chat` builder;Session 只负责把当前连续 `SessionEventLikeEntry` window 送入引擎并发布它的 snapshot,且不解释具体 conversation 业务。entry 的外层 discriminator 区分标准与 packed record,两者都携带字段对齐的内部 `SessionEventLike`,供 Definition dispatch。
|
||||
|
||||
本 Note 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
|
||||
2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be
|
||||
2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe
|
||||
2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8
|
||||
2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2
|
||||
@@ -14,7 +14,9 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
|
||||
|
||||
**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store.
|
||||
|
||||
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
|
||||
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../bug-fix/2026-09-23-bounded-pnpm-runs.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
|
||||
|
||||
**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling.
|
||||
|
||||
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
|
||||
|
||||
@@ -36,8 +38,10 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
|
||||
|
||||
## Consequences
|
||||
|
||||
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card.
|
||||
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand`, `inspectTimeoutMs` and `githubConnectionTimeoutMs`. The dialog is four screens over one subject card.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry.
|
||||
|
||||
`github-connection.spec.ts` uses real Git against owned loopback transports to cover immediate failure, timeout, cancellation and noninteractive authentication. `manager.spec.ts` also installs a local repository through real Git and pnpm for HTTPS, GitHub shorthand and SSH specs, isolating pnpm's HTTPS probe through an owned proxy and verifying its SSH fallback. `plugin-install-github.e2e.ts` checks the connection error and timeout dialogs, child cleanup, and switching to a mirror without retrying the old GitHub address.
|
||||
@@ -14,7 +14,9 @@ Status: implemented
|
||||
|
||||
**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
|
||||
|
||||
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
|
||||
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../bug-fix/2026-09-23-bounded-pnpm-runs.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
|
||||
|
||||
**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。
|
||||
|
||||
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
|
||||
|
||||
@@ -36,8 +38,10 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
|
||||
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand`、`inspectTimeoutMs` 与 `githubConnectionTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。
|
||||
|
||||
`github-connection.spec.ts` 用真实 Git 和测试自有的本机网络端点覆盖立即失败、超时、取消与非交互认证。`manager.spec.ts` 还通过真实 Git 与 pnpm 安装本地仓库,覆盖 HTTPS、GitHub 简写和 SSH 地址,用自有代理隔离 pnpm 的 HTTPS 请求并验证其 SSH 回退。`plugin-install-github.e2e.ts` 检查连接失败及超时弹窗、子进程清理,以及切换镜像时不重试原 GitHub 地址。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md
|
||||
2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b
|
||||
2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853
|
||||
2026-09-19-remote-duplex-stream.md: ea8e8cebcc8285c97ebdb70f4e37805d3ce8bfe9
|
||||
2026-09-19-remote-duplex-stream.zh.md: 2e119bf054e20d076e22758178b5e0168092be18
|
||||
@@ -276,6 +276,12 @@ Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optiona
|
||||
| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open |
|
||||
| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket |
|
||||
|
||||
### Cancellation wait lifetime
|
||||
|
||||
`cancellableStream`, `UplinkDecoder.next()`, and the Client uplink pump each create a cancellation or stop promise per read. `Promise.race` does not remove the losing input's reaction when a read completes: reusing a stream-wide pending promise retains completed races and their item results until that promise settles or becomes unreachable. Read-local promises keep retained cancellation state independent of the number of delivered items.
|
||||
|
||||
The Host downlink clears its current reject callback after each read and checks cancellation before reading again. The uplink decoder retains only pending reads: abort rejects them, while normal close resolves all of them with `done`, including overlapping reads. The Client pump drops its stop promise after each read and does not start another read after stopping. Source iterators retain their existing release ownership; closing the Host uplink still precedes returning the method iterator.
|
||||
|
||||
### Ordering guarantees
|
||||
|
||||
Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A shared cancellation controller for all three reading loops.** Each loop already has an owner with distinct close behavior. Read-local promises bound retained state without moving those responsibilities into a new abstraction.
|
||||
|
||||
**Requiring source iterators to cancel their own `next()`.** Gateway accepts arbitrary iterables, whose pending reads need not respond to cancellation or `return()`. Gateway must still wake its own waiters independently of source cleanup.
|
||||
|
||||
**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable<In>` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused.
|
||||
|
||||
**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place.
|
||||
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## Testing
|
||||
|
||||
Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` count live iterator results after a full GC while the streams remain open. Lifecycle cases cover normal close waking multiple pending uplink reads, cancellation between reads, synchronous abort followed by a source exception, and termination during a Client send.
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier.
|
||||
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
|
||||
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
|
||||
|
||||
@@ -276,6 +276,12 @@ export type RemoteStreamServerMessage =
|
||||
| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 |
|
||||
| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 |
|
||||
|
||||
### 取消等待的生命周期
|
||||
|
||||
`cancellableStream`、`UplinkDecoder.next()` 和 Client 上行泵各自为单次读取创建取消或停止 Promise。读取完成后,`Promise.race` 不会移除另一输入上的 reaction:复用流级未完成 Promise 会保留已完成的竞争及其读取结果,直到该 Promise 完成或不可达。单次读取的 Promise 使取消状态的保留量不随已交付项数增长。
|
||||
|
||||
Host 下行在每次读取后清除当前拒绝回调,并在再次读取前检查取消状态。上行解码器只保留尚未完成的读取:取消时拒绝这些读取,正常关闭则以 `done` 唤醒全部读取,包括重叠的读取。Client 泵在每次读取后移除停止 Promise,停止后不再发起读取。源迭代器的释放仍由原有所有者负责;Host 仍先关闭上行,再返回方法迭代器。
|
||||
|
||||
### 顺序保证
|
||||
|
||||
同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。
|
||||
@@ -538,6 +544,10 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**三处读取循环共用一个取消控制器。** 每处循环已有自己的所有者和不同的关闭行为。单次读取的 Promise 能限制保留状态,不需要把这些职责迁入新抽象。
|
||||
|
||||
**要求源迭代器自行取消 `next()`。** Gateway 接受任意 iterable,其待处理读取不一定响应取消或 `return()`。Gateway 仍需独立于源清理来唤醒自己的等待者。
|
||||
|
||||
**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable<In>`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。
|
||||
|
||||
**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。
|
||||
@@ -572,6 +582,8 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
## 测试
|
||||
|
||||
`gateway-stream.host.spec.ts` 和 `gateway.client.spec.ts` 中的保留对象用例在流保持打开时进行全量 GC,再统计存活的迭代结果。生命周期用例覆盖正常关闭唤醒多个待处理上行读取、两次读取之间的取消、源同步取消后抛错,以及 Client 发送期间流结束。
|
||||
|
||||
- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。
|
||||
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
|
||||
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md
|
||||
2026-09-22-tool-call-three-phases.md: f43eaf1a685358f251a446889b6ac25f5fdf0144
|
||||
2026-09-22-tool-call-three-phases.zh.md: accbd2e966595ae7f124138147d191f97cb763ee
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent Note: Tool calls own preparation, dispatch, and results by callId
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-tool-call-three-phases.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The model often provides a tool name and call ID while it is still generating arguments, but `tool/call` arrives only after the entire Assistant stream ends. When one response contains several calls, an earlier call waits for later calls even after its own arguments are complete. File content is part of the arguments to `write`, so this delay can last several seconds.
|
||||
|
||||
Creating tool nodes only at `tool/call` presents that interval as no tool activity. Putting a preparing call in an Assistant part instead requires the Group to switch references between Assistant and Tool nodes. Preparation and dispatch belong to one tool lifecycle and do not require two cooperating nodes.
|
||||
|
||||
## Decision
|
||||
|
||||
### One Tool owns three stages
|
||||
|
||||
The [Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) groups calls by callId and supplies explicit stage data to tool components.
|
||||
|
||||
| Stage | Creation or update evidence | Available data and presentation |
|
||||
|---|---|---|
|
||||
| `preparing` | A live delta with a call ID and tool name | Call identity, name, and time; no complete arguments, and one non-expandable row. An opt-in Hook can read the raw argument prefix. |
|
||||
| `start` | `tool/call` | Complete arguments; enables the tool's existing call presentation. |
|
||||
| `result` | `tool/result` | The result and any complete arguments paired within the loaded window. |
|
||||
|
||||
An argument block ending does not trigger dispatch presentation. Showing preparation changes neither model requests, tool dispatch timing, Session events, nor the persisted format. An empty argument string does not stand in for a dispatched call.
|
||||
|
||||
### Start marks an initialization candidate
|
||||
|
||||
The [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) retains its match/start/update interface and existing roles. Durable events and transient deltas may both match as start. The earliest start in the current event sequence initializes State; every later Match, including another start for the same ID, goes through update.
|
||||
|
||||
A named tool delta and `tool/call` are both initialization candidates. A live delta can create preparation; a historical `tool/call` creates the dispatched stage directly. Matching still reads only the current event, without Context lookup, additional roles, registries, or separate caches.
|
||||
|
||||
Successful stream settlement publishes the final Assistant message while retaining transient deltas until `step/end`. Each `tool/call` updates its existing callId; the Assistant keeps its live ordering anchor until the same cleanup. Failed, interrupted, or abandoned streams retire their deltas immediately. On retirement the Assembler reselects starts and recomputes State from remaining Matches. Undispatched preparations hide without synthetic execution results.
|
||||
|
||||
### Converge on final state without replaying preparation
|
||||
|
||||
Historical pages do not expand settled Assistant messages into live deltas. Reconnection restores only an attempt that is still live. Live presentation may follow preparing → start → result, while history follows start → result directly. Equal durable events produce equal final state; intermediate presentation and preparation-group identity need not match.
|
||||
|
||||
Groups include Tool nodes from preparation onward and classify and count them by tool name. AssistantNodeView does not search tool blocks, and Groups neither encode tool groupPart values nor take over tool lifecycles. Grouping retains the [node-input grouping decision](2026-09-21-conversation-build-groups.md); the Builder does not interpret preparation business rules.
|
||||
|
||||
### Shared rows reuse their existing presentation
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) reuses the existing icon, title, and row component across all stages. The shared row model selects the tool title and combines any generic tool-name prefix with the available argument summary. Preparation has no complete arguments or result, and the shared argument parser returns null without parsing JSON. The existing slot Hook binding optionally exposes a call-scoped raw prefix; it adds no preparation registration mechanism. ToolRow prevents preparation rows from expanding.
|
||||
|
||||
Read, read_image, write/edit, search, web, todo, question, details, and the generic fallback use this path. Custom rows such as Bash, Skill, Present, and Cordis keep their own preparation branches. No automatic/custom registration declaration or second tool-classification list in ToolTree is introduced.
|
||||
|
||||
### Relationship to existing records
|
||||
|
||||
This decision replaces the [business-node assembly](2026-08-09-client-conversation-node-assembly.md) restriction that starts must be durable and a Context may receive only one start. That record's stable IDs, business Definitions, Contexts, Locations, predecessor dependencies, and target Builder responsibilities remain applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Create nodes only at `tool/call`.** This cannot show a known tool identity while arguments are being generated and leaves a visible wait in multi-call responses.
|
||||
|
||||
**Transfer presentation through Assistant parts and Groups.** A tool would need two presentation sources, additional deduplication, and identity transfer. Lifecycle ownership becomes distributed, and renderers must recover Assistant content from part identifiers.
|
||||
|
||||
**Persist or replay preparing.** Preparation describes current generation; durable calls and results already suffice to reconstruct the final presentation. Reproducing the same intermediate process adds unnecessary state and event interpretation.
|
||||
|
||||
**Add a preparation registration mechanism.** Generic tools already share ToolRow and argument models, including reusable icons and titles. Additional declarations or configuration for this stage would maintain another representation of tool presentation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A tool may appear before its arguments are complete, but preparation neither implies execution nor exposes interactions requiring arguments.
|
||||
- Multiple starts with one ID belong to one lifecycle. Independent calls must use distinct IDs; a second-start error no longer detects identity reuse.
|
||||
- The shared row primitive is reused across stages. Write/edit separate preparing and dispatched components so only the former subscribes to raw arguments; custom rows may also switch internal components by stage.
|
||||
- Partial JSON parsing and `useToolCallDelta` are not provided. `useToolCallArgumentsPartial` exposes the existing Step's raw prefix without a second accumulator; write/edit use only its length for preparation progress.
|
||||
- Assembly tests separately cover live preparation, withdrawal, active-stream reconstruction, durable history, and paged convergence. Component tests cover argument-free presentation and stage changes; a recorded session covers browser preparation and reload results.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Agent Note: 工具调用按 callId 管理准备、派发与结果
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-tool-call-three-phases.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
模型生成工具参数时,工具名和调用 ID 往往已经可用,但 `tool/call` 要等整条 Assistant 流结束后才到达。一次响应包含多个调用时,前一个调用即使已经生成完整参数,仍要等待后续调用。文件正文也属于 `write` 参数,这段等待可能持续数秒。
|
||||
|
||||
只从 `tool/call` 创建工具节点,会把这段时间表现为没有工具活动;把准备中的调用放在 Assistant 片段里,又需要 Group 在 Assistant 与正式 Tool 节点之间切换引用。准备态和正式调用属于同一个工具生命周期,不应由两个节点配合表达。
|
||||
|
||||
## 决策
|
||||
|
||||
### 同一个 Tool 拥有三个阶段
|
||||
|
||||
[Tool Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts) 按 callId 聚合调用,向工具组件提供明确的阶段数据。
|
||||
|
||||
| 阶段 | 创建或更新依据 | 可用数据与展示 |
|
||||
|---|---|---|
|
||||
| `preparing` | 带调用 ID 和工具名的实时 delta | 调用身份、名称和时间;不提供完整参数,只显示不可展开的一行。可选钩子可读取原始参数前缀。 |
|
||||
| `start` | `tool/call` | 完整参数;启用工具原有的调用展示。 |
|
||||
| `result` | `tool/result` | 调用结果,以及当前窗口内可配对的完整参数。 |
|
||||
|
||||
参数块结束不触发派发阶段。展示准备态不改变模型请求、工具派发时机、Session 事件或持久化格式,也不把空参数字符串伪装成正式调用。
|
||||
|
||||
### start 表示可创建入口
|
||||
|
||||
沿用 [Conversation Definition](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) 的 `match/start/update` 接口和既有角色。持久事件与瞬态 delta 都可以匹配为 start;当前事件序列中最早的 start 初始化 State,后续 Match,包括同 ID 的其他 start,都交给 update。
|
||||
|
||||
Tool 的具名 delta 和 `tool/call` 都是创建入口。实时先收到 delta,就从 preparing 创建;历史先收到 `tool/call`,就从 start 创建。匹配函数仍然只读取当前事件,不查询 Context,不增加角色、注册表或旁路缓存。
|
||||
|
||||
成功流结束时发布最终 Assistant 消息,瞬态 delta 保留到 `step/end`。每个 `tool/call` 更新已有 callId;Assistant 的实时排序锚点也保留到同一次清理。失败、中断或废弃的流立即移除 delta。清理时,Assembler 从剩余 Match 重新选择 start 并重算 State;未派发的准备节点隐藏,不合成执行结果。
|
||||
|
||||
### 最终状态收敛,不重现准备过程
|
||||
|
||||
历史分页不把已结束 Assistant 消息展开为实时 delta。重连只恢复仍在生成的活跃流。实时展示可以经历 preparing → start → result,历史回放可以直接经历 start → result;相同持久事件得到相同最终状态即可,不要求中间过程或准备态分组身份一致。
|
||||
|
||||
Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。AssistantNodeView 不查找工具块,Group 不编码工具 groupPart,也不接替工具生命周期。分组仍遵循[节点输入的分组决策](2026-09-21-conversation-build-groups.zh.md),Builder 不解释准备态业务。
|
||||
|
||||
### 通用行复用现有外壳
|
||||
|
||||
[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型选择工具标题,并组合通用工具名前缀与已有参数摘要。准备阶段没有完整参数或结果,共享参数解析入口直接返回 null,不解析 JSON。既有 slot 钩子绑定可选地提供当前调用的原始前缀,不增加准备态注册机制;ToolRow 禁止准备行展开。
|
||||
|
||||
read、read_image、write/edit、search、web、todo、question、details 和通用回退使用这条路径。Bash、Skill、Present、Cordis 等自定义外壳保留自己的准备态分支。不增加自动/自管注册声明,不在 ToolTree 维护另一份工具分类名单。
|
||||
|
||||
### 与已有记录的关系
|
||||
|
||||
本记录替代[业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md)中“start 只能是持久事件,同一 Context 只能收到一次 start”的限制。该记录的稳定 ID、业务 Definition、Context、Location、前序依赖和目标 Builder 职责继续适用。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只在 `tool/call` 创建节点。** 无法展示参数生成期间已经确定的工具身份,多调用响应仍有明显空等。
|
||||
|
||||
**通过 Assistant 片段和 Group 转接。** 工具需要两种展示来源、额外去重和身份衔接;生命周期归属分散,渲染器还要根据片段标识反查 Assistant 内容。
|
||||
|
||||
**保存或回放 preparing。** 准备态只描述当前生成过程,持久调用和结果已经足以重建最终展示;恢复相同中间过程会增加不必要的状态和事件解释。
|
||||
|
||||
**新增准备态注册机制。** 通用工具已经共享 ToolRow 和参数模型,已有图标与标题可以直接复用。仅为这一阶段增加声明或配置,会多维护一套工具展示信息。
|
||||
|
||||
## 影响
|
||||
|
||||
- 工具可以在参数完整前出现,但准备展示不表示工具已经执行,也不暴露依赖参数的交互。
|
||||
- 同一 ID 的多个 start 被视为同一个生命周期;业务必须为独立调用提供不同 ID,不能再依赖第二个 start 报错检测身份复用。
|
||||
- 各阶段复用通用行基础组件。write/edit 分离准备态与派发后组件,只有前者订阅原始参数;自定义外壳也可按阶段切换内部组件。
|
||||
- 不提供部分 JSON 解析或 `useToolCallDelta`。`useToolCallArgumentsPartial` 暴露既有 Step 的原始前缀,不另建累积器;write/edit 只使用其长度展示准备进度。
|
||||
- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md
|
||||
2026-09-22-windows-open-through-shell-resolution.md: 7bfe559e7f26b96fa37d3bbed44f0403d4e51b59
|
||||
2026-09-22-windows-open-through-shell-resolution.zh.md: ff33455da1653f9d24f9ddcc21c1594e8875bb5e
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Windows opens go through explorer.exe
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-22-windows-open-through-shell-resolution.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Windows desktop client's settings sheet offers **Open configuration file**, which prepares the active profile's `cordis.patch.yml` and hands the path to the native text-document opener (issue #4428). On the reporting machine the gesture opened nothing and reported nothing. The opener ran `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath '<path>'"`, so an association resolved inside the host process decided the outcome, and `Invoke-Item` exits 0 when that resolution finds no application.
|
||||
|
||||
That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest` (progid `VSCode.yml`), with no `UserChoice` record and no `HKCU\Software\Classes\.yml` default. Explorer opens VS Code on a double-click of the same file. Run from the host process, `Invoke-Item`, `cmd /c start`, the `Shell.Application` default verb, and `AssocQueryString` all reported no application for that extension, and `cmd /c start` displayed the shell's own application chooser instead of opening one. `Invoke-Item` still exited 0, so `SettingsController.openSettingsDocument` answered `{ opened: true }` and the sheet rendered no error.
|
||||
|
||||
## Decision
|
||||
|
||||
`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas and equals signs percent-encoded, because Explorer parses its own command line and splits fields at both — a raw path containing either opens a different target without reporting it. Nothing else is escaped: Explorer rejects percent-encoded non-ASCII in a file URI and falls back to Documents, so those characters reach it literally. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence.
|
||||
|
||||
`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents.
|
||||
|
||||
Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep `Invoke-Item`.** Rejected. It asks a different resolver than the shell does, so a user whose default Explorer honors can still get a silent no-op, and its zero exit code removes the error the sheet would otherwise render.
|
||||
|
||||
**Resolve the default inside the host process.** Rejected. Reading `UserChoiceLatest`, then `UserChoice`, then the classes default, and launching the winning progid's `shell\open\command` reimplements an order Windows owns, including the `UserChoice` hash Windows validates; the shell's own answer is the definition of the application a double-click uses.
|
||||
|
||||
**`cmd /c start <path>`.** Rejected. It needs a command shell for the same resolution Explorer already performs, and it reported no application on the machine that reproduced the defect.
|
||||
|
||||
**Fall back to a fixed editor when no application resolves.** Rejected. A Windows installation always ships a usable editor, but choosing it in the opener would override the user's association and turn a missing association into a silently wrong application.
|
||||
|
||||
## Consequences
|
||||
|
||||
The settings sheet's open gesture and the artifact and workspace opens share one Windows behavior: the application a double-click would launch. A machine whose recorded default Explorer honors but `AssocQueryString` does not — the reported state — now opens the configured editor.
|
||||
|
||||
The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared.
|
||||
|
||||
An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` invokes no association and still exits 1, so that exit code does not tell this case apart from a delegated open: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second, where an `Invoke-Item` command resolved the association in-process. A Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented.
|
||||
|
||||
Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe <dir>` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's.
|
||||
|
||||
## Verification
|
||||
|
||||
`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window. A later pass on the same build read the shell's own window and selection state instead of pixels: the encoded `/select,` target selected the named file; an unencoded `=` opened Documents instead of the file; and a target whose non-ASCII characters stay percent-encoded opened Documents where the literal characters opened the file.
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Windows 打开经 explorer.exe 完成
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-22-windows-open-through-shell-resolution.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Windows 桌面客户端设置页的「打开配置文件」会准备当前 profile 的 `cordis.patch.yml`,并把该路径交给原生文本编辑器打开器(issue #4428)。在报告者机器上,这个动作既没有打开窗口,也没有任何提示。打开器执行的是 `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath '<path>'"`,因此由宿主进程内的关联解析决定结果,而 `Invoke-Item` 在解析不到应用时同样以退出码 0 结束。
|
||||
|
||||
该机器的 `.yml` 默认值只记录在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest`(progid 为 `VSCode.yml`),既没有 `UserChoice` 记录,也没有 `HKCU\Software\Classes\.yml` 默认值。对同一个文件双击,资源管理器会打开 VS Code。而在宿主进程中执行时,`Invoke-Item`、`cmd /c start`、`Shell.Application` 的默认动词和 `AssocQueryString` 都报告该扩展名没有应用,`cmd /c start` 则弹出 shell 自己的应用选择框而不是打开某个应用。`Invoke-Item` 仍然返回 0,于是 `SettingsController.openSettingsDocument` 回答 `{ opened: true }`,设置页不渲染任何错误。
|
||||
|
||||
## 决策
|
||||
|
||||
`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。
|
||||
|
||||
`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。
|
||||
|
||||
Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**继续使用 `Invoke-Item`。** 已否决。它询问的解析器与 shell 不同,因此即使某个用户的默认值能被资源管理器识别,仍会得到静默的无效操作;而且它的零退出码正好抹掉了设置页本应渲染的错误。
|
||||
|
||||
**在宿主进程内自行解析默认值。** 已否决。依次读取 `UserChoiceLatest`、`UserChoice` 和 classes 默认值,再启动胜出 progid 的 `shell\open\command`,等于重新实现一套 Windows 自己拥有的顺序,其中还包括 Windows 会校验的 `UserChoice` 哈希;而双击所用应用的定义就是 shell 自己的答案。
|
||||
|
||||
**使用 `cmd /c start <path>`。** 已否决。它为资源管理器本已执行的同一套解析引入一个命令 shell,并且在复现该缺陷的机器上同样报告没有应用。
|
||||
|
||||
**解析不到应用时回退到固定编辑器。** 已否决。Windows 安装总是带有可用的编辑器,但在打开器里选择它等于覆盖用户的关联,并把「缺少关联」变成「静默地打开错误应用」。
|
||||
|
||||
## 后果
|
||||
|
||||
设置页的打开动作与产物、工作区的打开动作共享同一个 Windows 行为:双击会启动的那个应用。对于「资源管理器认、`AssocQueryString` 不认」这一被报告的状态,现在会打开已配置的编辑器。
|
||||
|
||||
打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。
|
||||
|
||||
交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何关联,且同样以退出码 1 返回,因此该退出码无法与「已委派」区分:Windows 11 ARM64 build 26200 的 SSH session 0 实测约 1 秒后返回退出码 1,而同一会话里 `Invoke-Item` 命令能在进程内解析关联。这样启动的 Host 会失去能力且要付出等待,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。
|
||||
|
||||
目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe <dir>` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。
|
||||
|
||||
## 验证
|
||||
|
||||
`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现。同一 build 的后续一轮改为直接读取 shell 自己的窗口与选中状态:编码后的 `/select,` 目标选中了目标文件;未编码的 `=` 会打开「文档」而不是文件;非 ASCII 字符保持百分号编码时同样打开「文档」,改为字面字符则正常打开。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md
|
||||
2026-09-23-bounded-pnpm-runs.md: 7ce6b1776e4587240072201781c9da5bc78650e8
|
||||
2026-09-23-bounded-pnpm-runs.zh.md: 1cb91132cca22a1130fdc48ce3d3dbd354fa5d16
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: Bounding a pnpm run that stops progressing
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-23-bounded-pnpm-runs.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A profile package operation could hold the profile write lock forever. A `dsh web` process held `~/.dsh/profiles/web/package.json.lock` for 22 minutes after its pnpm child printed `Done in 2s`: that child never exited, so the operation awaited an end that never came, the lock was never released, and every later management call in the process queued behind it. pnpm 11.13.0 consumes its one-shot worker-pool teardown once, re-creates the pool lazily afterwards, and unreferences a worker only during destruction, so the pool keeps the parent process alive with no repository-side change able to fix it.
|
||||
|
||||
A second cause reaches the same end by another route: a lifecycle script that inherited pnpm's stdout and stderr keeps those pipes open after pnpm exits, so a reader waiting for end of file never sees one, and the lock stays held while that reader waits.
|
||||
|
||||
## Decision
|
||||
|
||||
- A run completes when its process exits, not when its pipes end. The operation awaits the raw child's `exit` event and races execa's promise, which resolves only once the piped stdio closes.
|
||||
- The pipes then drain under a fixed 2000 ms grace period, after which this operation closes them. Failure classification reads a tail pnpm writes before it exits, so the grace period belongs to finishing a run rather than to deployment configuration.
|
||||
- Only the close this drain causes is ignored. A reading that failed before the cut still surfaces as the run's failure, and the log records that the tail was cut short.
|
||||
- A run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` beside its own exit status, and is not asked of the next registry, which bounds how long one operation can hold the lock.
|
||||
- A terminated run is classified `timeout` whatever exit status the signal left behind. Installation and removal report failure instead of success, so a manifest pnpm had half written is restored rather than activated.
|
||||
- A terminated run stops its whole process tree and waits for it to disappear before the caller restores the profile and releases the lock, because an approved lifecycle script runs as a child of pnpm and outlives it. execa's `killDescendants` addresses the tree on every kill path, including cancellation, and is passed only for a captured run, so a run that inherits the terminal keeps the caller's process group.
|
||||
- `dsh plugin` inherits the caller's terminal and captures nothing, so no silence bound applies to it and its operator keeps the ability to interrupt.
|
||||
|
||||
## Why a silence bound rather than a duration bound
|
||||
|
||||
A capture-only run that prints nothing for ten minutes is stuck: pnpm reports its own progress and its scripts' output there. A total duration bound would instead kill legitimate work, because a non-TTY pnpm prints a build script's output when that script finishes, and a native build on a slow machine compiles silently for longer than any fixed budget we could justify for it. Silence separates a stalled run from a slow one without guessing a duration, and 600000 ms leaves an approved build ten times the slowest silent stretch maintainers expect from it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Wait for execa's promise.** execa resolves after the piped stdio ends, which is the end a held pipe withholds, so the completion signal had to move to the process itself.
|
||||
|
||||
**Bound total duration instead of silence.** A stalled run and a compiling run are indistinguishable by duration alone, and the bound would destroy legitimate work whenever the machine is slow enough.
|
||||
|
||||
**Terminate only the pnpm process.** An approved `postinstall` keeps writing into the profile after the operation reports a timeout and releases the lock, so a later restoration races that script. Reproduced with a local dependency whose postinstall writes a file, and with pnpm 11.13.0.
|
||||
|
||||
**Detach the run and reap the tree later.** Rollback and lock release must happen after the tree stopped writing, and deferring the reap leaves open exactly the window this decision closes.
|
||||
|
||||
**Keep the pipes as the completion signal and bound only that wait.** The drain does bound the wait, but a stalled worker pool never ends its pipes, so the process exit remains the completion signal.
|
||||
|
||||
## Testing
|
||||
|
||||
| Evidence | Behaviour |
|
||||
|---|---|
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | Silence-bound termination, a run that trapped the signal and exited 0, the bounded drain over a held pipe, a reading failure that still surfaces through the cut, a rejection reason that is not an Error, and the cut notice. |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | Real descendants: a pipe that outlives its process still drains, and a stalled run's tree is stopped before the operation returns, so a marker its script would have written never appears. |
|
||||
| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | Platform group-leadership decisions, the probe target each platform addresses, and the wait bound. |
|
||||
| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | A terminated run that exited 0 restores the manifest, reports `timeout`, and is not activated; a terminated removal reports failure. |
|
||||
|
||||
The real-process cases cover POSIX only: Windows cannot stage a descendant that keeps inherited pipes open past its parent, and the drain itself is platform-independent. With those cases skipped, the mocked cases still hold the per-file coverage gate on Windows.
|
||||
|
||||
## Related
|
||||
|
||||
[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) remains the owner of what an installation or removal does with a finished run: the manifest snapshot it restores, the classification it renders, and the cancellation contract. This note owns only how a run ends and how long it may hold the profile lock.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A stalled run is reported up to `idleTimeoutMs` late and the profile lock is held for that long. A silent but healthy native build longer than the default is killed and classified `timeout`; a profile that compiles such a dependency raises `idleTimeoutMs`, trading slower stall detection for tolerance.
|
||||
- A cut tail is incomplete, so the failure classification and the returned output see only what pnpm wrote inside the grace period. The diagnostic log records the cut.
|
||||
- Terminating a run kills its whole tree, so a lifecycle script that had already started is stopped instead of being allowed to finish.
|
||||
- The bound is a workaround for a third-party defect (issue #4981, PR #4982) and becomes unnecessary once pnpm tears down its worker pool before a command returns.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Agent Note: 为停止推进的 pnpm 运行设界
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-23-bounded-pnpm-runs.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
一次 profile 包操作可能永久持有 profile 写锁。某个 `dsh web` 进程在其 pnpm 子进程打印 `Done in 2s` 之后仍持有 `~/.dsh/profiles/web/package.json.lock` 达 22 分钟:该子进程从未退出,于是操作等待一个永不到来的结束点,锁始终未释放,进程内后续每一次管理调用都排在它后面。pnpm 11.13.0 只消费一次性的 worker 池收尾,之后又惰性重建该池,并且只在销毁时才对 worker 取消引用,因此该池会让父进程一直存活,仓库侧无从修复。
|
||||
|
||||
第二条成因以另一条路径导致同一结果:继承了 pnpm stdout 与 stderr 的生命周期脚本会在 pnpm 退出后继续持有这些管道,于是等待文件结束的读取方永远等不到,锁在读等期间一直被持有。
|
||||
|
||||
## 决策
|
||||
|
||||
- 运行的完成点是进程退出,而非管道结束。操作等待原始子进程的 `exit` 事件,并与 execa 的 promise 竞速;后者只在管道化 stdio 关闭后才 resolve。
|
||||
- 随后管道在一个固定的 2000 毫秒宽限窗口内排空,窗口结束后由本次操作关闭它们。失败分类读取的是 pnpm 退出前写入的尾部,因此该宽限属于「完成一次运行」,而不是部署配置项。
|
||||
- 只有本次排空造成的关闭才被忽略。切断之前的读取失败仍作为该运行的失败上报,日志会记录尾部被切断。
|
||||
- 捕获输出的运行若在 `idleTimeoutMs` 内没有任何输出即被终止,在自身退出状态之外报告 `timedOut`,且不再询问下一个注册表,从而使单次操作持有锁的时长有上界。
|
||||
- 被终止的运行不论信号留下什么退出状态都归类为 `timeout`。安装与删除都报告失败而非成功,因此 pnpm 只写了一半的 manifest 会被还原,而不会被启用。
|
||||
- 被终止的运行会停止整棵进程树并等待其消失,之后调用方才还原 profile 并释放锁,因为获批的生命周期脚本是 pnpm 的子进程且存活更久。execa 的 `killDescendants` 覆盖每一条 kill 路径(含取消),且只对捕获输出的运行启用,因此继承终端的运行仍留在调用方的进程组内。
|
||||
- `dsh plugin` 继承调用方终端、不捕获输出,因此不受静默上界约束,其操作者仍可中断。
|
||||
|
||||
## 为何用静默上界而非时长上界
|
||||
|
||||
只捕获输出、十分钟没有任何输出的运行已经卡住:pnpm 自身的进度与脚本输出都写在那里。而总时长上界会误杀正当工作,因为非 TTY 的 pnpm 只在该脚本结束时才打印它的输出,慢机器上的原生构建以超过任何可辩护固定预算的时间静默编译。静默把卡住的运行与较慢的运行区分开,无需猜测时长;600000 毫秒给获批构建留下的静默余量,是维护者预期的最慢静默时长的十倍。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**等待 execa 的 promise。** execa 在管道化 stdio 结束后才 resolve,而那正是被持有管道扣住不发的结束点,因此完成信号必须移到进程本身。
|
||||
|
||||
**改为对总时长设界。** 仅凭时长无法区分卡住的运行与正在编译的运行,而且只要机器够慢,这个上界就会破坏正当工作。
|
||||
|
||||
**只终止 pnpm 进程。** 获批的 `postinstall` 会在操作报告超时并释放锁之后继续写入 profile,于是后续的还原与该脚本互相竞争。已用 pnpm 11.13.0 与一个 postinstall 会写文件的本地依赖复现。
|
||||
|
||||
**让运行脱离父进程、稍后再回收进程树。** 还原与释放锁必须在进程树停止写入之后发生,推迟回收恰好留下本决策要关闭的那个窗口。
|
||||
|
||||
**保留管道作为完成信号,只对该等待设界。** 排空确实为等待设了界,但卡住的 worker 池永不结束其管道,因此进程退出仍是完成信号。
|
||||
|
||||
## 验证
|
||||
|
||||
| 证据 | 覆盖行为 |
|
||||
|---|---|
|
||||
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | 静默上界终止、捕获信号后以 0 退出的运行、持有管道下的有界排空、经切断仍上报的读取失败、非 Error 的拒绝原因,以及切断提示。 |
|
||||
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 真实孙进程:存活超过其父进程的管道仍能排空;卡住运行的进程树在操作返回前被停止,因此其脚本本应写入的标记不会出现。 |
|
||||
| [run-tree.spec.ts](../../../../packages/boot/plugin-manager/tests/run-tree.spec.ts) | 各平台的进程组归属判定、各平台探测的目标,以及等待上界。 |
|
||||
| [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) | 以 0 退出的被终止运行会还原 manifest、报告 `timeout` 且不被启用;被终止的删除报告失败。 |
|
||||
|
||||
真进程用例仅覆盖 POSIX:Windows 无法搭出「孙进程在其父进程退出后仍持有继承管道」的前提,而排空本身与平台无关。跳过这些用例时,mock 用例仍能在 Windows 上满足逐文件覆盖率门禁。
|
||||
|
||||
## 相关
|
||||
|
||||
[引导式插件安装](../architecture/2026-09-15-guided-plugin-installation.zh.md)仍是「安装或删除如何处理一次已结束运行」的归属:它还原的 manifest 快照、渲染的分类与取消约定。本记录只负责运行如何结束,以及它可以持有 profile 锁多久。
|
||||
|
||||
## 后果
|
||||
|
||||
- 卡住的运行最迟在 `idleTimeoutMs` 之后才被报告,profile 锁在这段时间内一直被持有。静默但健康、且超过默认值的原生构建会被终止并归类为 `timeout`;需要编译此类依赖的 profile 应提高 `idleTimeoutMs`,以更慢的卡住检测换取容忍度。
|
||||
- 被切断的尾部不完整,因此失败分类与返回输出只看到 pnpm 在宽限窗口内写入的内容。诊断日志记录了这次切断。
|
||||
- 终止运行会杀掉整棵进程树,因此已经开始的生命周期脚本会被停止,而不会被允许跑完。
|
||||
- 该上界是针对第三方缺陷的变通(issue #4981、PR #4982);一旦 pnpm 在命令返回前完成其 worker 池收尾,它就不再必要。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
|
||||
2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc
|
||||
2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f
|
||||
2026-08-05-agent-teams.md: 6333e511ba1e71823cfa55c27ba46b2665d1ef15
|
||||
2026-08-05-agent-teams.zh.md: 3fed1302146406f71d63c7ff2df8f031a91c9804
|
||||
@@ -54,6 +54,14 @@ All members use the same cwd and observe writes immediately. The policy tells me
|
||||
|
||||
Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts.
|
||||
|
||||
## Web projection
|
||||
|
||||
The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data.
|
||||
|
||||
The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it.
|
||||
|
||||
Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner.
|
||||
|
||||
@@ -54,6 +54,14 @@ Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/messag
|
||||
|
||||
Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。
|
||||
|
||||
## Web projection
|
||||
|
||||
Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。
|
||||
|
||||
传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。
|
||||
|
||||
发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md
|
||||
2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f
|
||||
2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96
|
||||
2026-08-25-promote-open-anywhere-plugin.md: e5a435d24c2400b68fad8cccb094b176ad83f590
|
||||
2026-08-25-promote-open-anywhere-plugin.zh.md: 108091f3e7c2736063d67bc6b2d7fe0c039b35db
|
||||
@@ -21,7 +21,7 @@ The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `pac
|
||||
- **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a <resolved bundle>`; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open <path>` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell).
|
||||
- **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime).
|
||||
- **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph.
|
||||
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe <dir>` spawn does not reliably raise a window. Closed unions end in `assertNever`.
|
||||
- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a <bundle>` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, which hands the directory to the OS shell with the full parent environment, not a detached scrubbed spawn), because a detached, credential-scrubbed `explorer.exe <dir>` spawn does not reliably raise a window; the opener's own `explorer.exe` handoff does ([Windows open resolution](../bug-fix/2026-09-22-windows-open-through-shell-resolution.md)). Closed unions end in `assertNever`.
|
||||
- **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory.
|
||||
- **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent.
|
||||
- **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one.
|
||||
|
||||
@@ -21,7 +21,7 @@ Status: implemented
|
||||
- **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open <path>` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。
|
||||
- **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。
|
||||
- **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。
|
||||
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe <dir>` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。
|
||||
- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a <bundle>`)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,把目录交给 OS shell 并携带完整父环境,而非 detached 的清理环境 spawn),因为 detached 且经过凭据清洗的 `explorer.exe <dir>` 派生进程不能可靠地弹出窗口;打开器自身的 `explorer.exe` 交接可以([Windows 打开解析](../bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md))。封闭 union 以 `assertNever` 收尾。
|
||||
- **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。
|
||||
- **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。
|
||||
- **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md
|
||||
2026-09-14-composer-selection-keyboard.md: fb353a7072e7c2245aeeb22af9018edd9a6009f0
|
||||
2026-09-14-composer-selection-keyboard.zh.md: f51e5ca5096785ae6d059e3c8497292483490437
|
||||
2026-09-14-composer-selection-keyboard.md: 675e7239108604c51f4e9a82b98e164833ff9173
|
||||
2026-09-14-composer-selection-keyboard.zh.md: 2b296a609cdd76a407fd4ddcc5855eef2fcfa047
|
||||
@@ -20,6 +20,8 @@ The model seat's dropdown (`ModelSelect`, whose Effort row opens the reasoning-l
|
||||
|
||||
`ModelSelect` keeps `↑`/`↓` as its focus walk over the rows of the shown pane (wrapping; a step taken while the trigger still holds focus enters at the near end). Tab settles: it activates the row the keyboard is on, and with focus still on the trigger it enters the menu at the row of the value in use instead. Escape and Shift+Tab leave a drilled pane first and otherwise close back to the trigger. A pane switch replaces the row that had focus, so each switch names where the keyboard lands: drilling focuses the row of the value in use (its checked row, or the first row when none is marked), and returning to the root pane focuses the cell that opened the pane left. Without that handoff the focus the unmounted row left on the page body sits outside the card's subtree, where its key handling never sees a keystroke.
|
||||
|
||||
Mouse presses on the `ModelSelect` trigger or portaled menu buttons prevent the browser’s default focus transfer: WebKit can otherwise blur the focused row and close the menu before `click`. Native `click` still owns selection and cancellation. Opening the menu or closing it through the trigger puts focus on the trigger. Selection requests return focus there before disabling the option rows, so a rejected request leaves keyboard navigation available.
|
||||
|
||||
`Menu`, the shared anchored dropdown behind the composer's permission seat, the preset chip, file cards, and the settings rows, carries the same pairing: while its list is open, Tab settles the focused row — from the anchor's button, Tab enters the list instead — and Escape or Shift+Tab close it and return focus to the anchor's first button. Only a keyboard already on the anchor or inside the list is intercepted, so Tab presses elsewhere on the page keep the browser's traversal even while a menu is open. The row's focus fill is the row's own indication: the browser's default ring would double it, and the fill is the same one the row shows under the pointer, so the visual language stays one per state. Selecting a row hands the keyboard back to the anchor unless the owner moved it itself — a card that focuses its own preview keeps it. The arrows, Home, and End walk the list whether or not `autoFocus` is set — that option now decides only whether opening focuses the first row — the row the keyboard is on carries the same fill a hovered row gets, and closing on Escape or Shift+Tab returns focus to the anchor whenever the menu held it.
|
||||
|
||||
A dismissed menu stays dismissed: `InputTriggerController` records the identity of the hit the user closed — Escape or Shift+Tab, a pointer dismissal, or a settling pick — and a re-track of that same token with the same query leaves the menu closed, so restoring the caret after a dismissal (or a settled command closing the surface it opened) cannot revive it. A new query or another token re-arms it.
|
||||
@@ -42,7 +44,7 @@ The row contracts are unchanged. Producers already mark their current row `activ
|
||||
|
||||
## Verification
|
||||
|
||||
[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives a real Chromium against the shipped bundles: it opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet.
|
||||
[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives real Chromium and WebKit against the shipped bundles: both verify native mouse selection, cancellation, focus after rejected requests, and keyboard navigation. Each opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab
|
||||
|
||||
`ModelSelect` 的 `↑`/`↓` 仍是在所显示面板行间移动焦点(循环;焦点仍在触发器上时从近端进入)。Tab 执行接受:它激活键盘所在的那一行;焦点仍在触发器上时,改为进入菜单并把焦点放在正在使用的那一行。Escape 与 Shift+Tab 都先退出已下钻的面板,否则关闭并回到触发器。面板切换会替换持有焦点的那一行,因此每次切换都指明键盘的落点:下钻落在正在使用的值那一行(其选中行;没有标记行时为首行),返回根面板则落在打开该面板的那个格子上。没有这一步,已卸载的行留在页面 body 上的焦点就位于卡片自身子树之外,挂在那里的按键处理永远收不到键盘事件。
|
||||
|
||||
在 `ModelSelect` 触发按钮或 portal 菜单按钮上按下鼠标时,阻止浏览器的默认焦点转移:否则 WebKit 可能先让聚焦行失焦,并在 `click` 前关闭菜单。选择和取消仍由原生 `click` 决定。打开菜单或通过触发按钮关闭菜单时,焦点落在触发按钮上。选择请求在禁用选项行前将焦点交回该按钮,因此请求被拒绝后仍可使用键盘导航。
|
||||
|
||||
`Menu`——权限档位、预设芯片、文件卡与设置行背后的共享锚定下拉——沿用同一组配对:列表打开期间,Tab 选定聚焦行(键盘在锚点按钮上时,Tab 改为进入列表),Escape 或 Shift+Tab 关闭并把焦点还给锚点的第一个按钮。只拦截已经位于锚点或列表内的键盘,因此页面其它位置按下的 Tab 即使在菜单打开时也仍归浏览器。行的焦点填充就是行自己的焦点提示:浏览器默认轮廓会与之叠加,而这一填充与指针悬停时用的是同一个,视觉语言因此一态一义。选定一行会把键盘还给锚点——除非拥有者自己移动了焦点(例如文件卡把自己的预览按钮设为焦点)。`↑`/`↓` 与 Home/End 无论是否设置 `autoFocus` 都在列表中走位——该选项如今只决定打开时是否聚焦首行——键盘所在的那一行使用与悬停行相同的填充(否则键盘走位看不出当前行),而 Escape 或 Shift+Tab 关闭时,只要键盘原本在菜单里就把焦点归还锚点。
|
||||
|
||||
被关闭的菜单保持关闭:`InputTriggerController` 记下用户关闭的那个命中(Escape/Shift+Tab、指针关闭或一次落定选定)的身份,同一 token 以同一查询重新 track 时菜单保持关闭,因此关闭后恢复光标、或已选定命令关闭它打开的界面,都无法把它召回来;新的查询或另一个 token 才会重新武装。
|
||||
@@ -42,7 +44,7 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab
|
||||
|
||||
## 验证
|
||||
|
||||
[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 驱动已发布的 bundle:打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。
|
||||
[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 与 WebKit 驱动已发布的 bundle:两者都验证原生鼠标选择、取消、请求被拒绝后的焦点以及键盘导航。每种引擎都会打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。
|
||||
|
||||
## 影响
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md
|
||||
2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d
|
||||
2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386
|
||||
2026-09-16-browser-excel-preview.md: 8a4bbd5dc838bc79e61f9b412af43b912a8fe019
|
||||
2026-09-16-browser-excel-preview.zh.md: f04170b28fe4c6d267b12958f47a392638bfaadb
|
||||
@@ -24,7 +24,7 @@ The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@
|
||||
|
||||
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
|
||||
|
||||
The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, and escaped table copying when upgrading FortuneSheet.
|
||||
The core patch also skips drawing freeze dividers when `allowEdit === false`, retaining the frozen-pane calculations and editable-mode dividers. These dividers are Canvas strokes, so scoped CSS cannot change them. Preview-scoped CSS hides the row and column freeze drag handles. The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, escaped table copying, and frozen-pane rendering and scrolling when upgrading FortuneSheet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。
|
||||
|
||||
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
|
||||
|
||||
ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本以及经过转义的表格复制。
|
||||
core 补丁还在 `allowEdit === false` 时跳过冻结分隔线的绘制,保留冻结窗格计算和编辑模式下的分隔线。这些分隔线由 Canvas 绘制,因此局部 CSS 无法修改。预览区域内的 CSS 隐藏行列冻结拖拽条。ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本、经过转义的表格复制,以及冻结窗格的绘制和滚动。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
|
||||
2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d
|
||||
2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee
|
||||
2026-07-26-ci-failover-runbook.md: d46f9d140860580011e06aa3b51bb8618a23a114
|
||||
2026-07-26-ci-failover-runbook.zh.md: a229d46f16d959356e9108842edd557420e699ae
|
||||
@@ -20,7 +20,7 @@ The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) gove
|
||||
|
||||
### What the in-house pool is
|
||||
|
||||
`vm-backup`: one shared VM with multiple always-on systemd-managed runner instances. Registrations share its CPU, memory, and disk; their count is not a count of independent machines. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
|
||||
`vm-backup`: one shared VM with multiple always-on systemd-managed runner instances. Registrations share its CPU, memory, and disk; their count is not a count of independent machines. Its image must preinstall the Linux system packages for Playwright Chromium and WebKit; CI downloads the lockfile-selected browsers but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite.
|
||||
|
||||
#### Windows pool
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Status: implemented
|
||||
|
||||
### 自有池是什么
|
||||
|
||||
`vm-backup`:一台共享虚拟机,运行多个常驻 systemd 管理的运行器实例。注册实例共享 CPU、内存和磁盘;实例数量不代表独立机器数量。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。
|
||||
`vm-backup`:一台共享虚拟机,运行多个常驻 systemd 管理的运行器实例。注册实例共享 CPU、内存和磁盘;实例数量不代表独立机器数量。其镜像必须预装 Playwright Chromium 和 WebKit 所需的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。
|
||||
|
||||
#### Windows 池
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: b08d99a7d750bdeabac3dcbb7007e922d32dbdcd
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: fbb92a094bbd14042d33e5ebd20d2d3ffd490d43
|
||||
2026-07-24-web-gui-browser-e2e-lane.md: eb79591e7b94fccb8eb92aec4024738825d214fc
|
||||
2026-07-24-web-gui-browser-e2e-lane.zh.md: f715f4ca93b202f72e632e5ddae0d13559a927a7
|
||||
@@ -10,7 +10,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin
|
||||
|
||||
## Decision
|
||||
|
||||
`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures.
|
||||
`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replay through `@deepseek-ai/dsh-llm-replay` against the real in-process web composition, with normalized aria goldens for user-visible states and in-process assertions for durable world state. Chromium runs the full lane; the [model and reasoning picker scenario](../../../../apps/web/tests/declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The supporting product contracts are `dsh-llm-replay` pacing, consumption checks, and validated indexed override patches; cross-package `dsh-llm` failures retain validated provider facts through own data properties; and the shipped web composition mounts `llm-retry` for transient model failures.
|
||||
|
||||
### Scaffold: `apps/web/tests/scaffold.ts`
|
||||
|
||||
@@ -32,7 +32,7 @@ No single-shot transient-DOM assertions: every hop from replay yield to React co
|
||||
|
||||
Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.
|
||||
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
|
||||
Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one browser per file and engine, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
|
||||
|
||||
### Expected outputs
|
||||
|
||||
@@ -50,7 +50,7 @@ The lane covers three behavior families. Live-turn scenarios pin ordinary tool e
|
||||
|
||||
### CI stance
|
||||
|
||||
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium, restores its OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
|
||||
The lane is a required compare-only gate for Linux pull requests under the [browser snapshot CI decision](2026-07-30-web-browser-snapshot-ci-gate.md). The `node 24 / snapshots and artifacts` consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), installs the lockfile-selected Chromium and WebKit, restores their OS-and-lockfile-keyed cache, and runs the lane with `DSH_SNAPSHOT=replay`. This is an intentional plane split: the host and specs use the [tsx source-launch contract](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md), while the browser consumes `apps/web/dist` and package `lib/client.js` artifacts, so the gate depends on `built-package-invariants` for those client artifacts. The self-hosted default-branch Linux serial standby runs the same gate; there is no hosted Linux serial producer of the browser cache consumed by pull requests, and the persistent self-hosted pool needs no hosted cache. CI never records or refreshes goldens. Scenarios remain POSIX-oriented and stay outside the Windows and macOS matrices.
|
||||
|
||||
High-cardinality performance diagnostics use the separate opt-in `apps/web/tests/**/*.perf.ts` inventory selected only by `vitest.web.perf.config.ts`. The isolated `complex-history.perf.ts` cases reuse the real scaffold: the workspace case seeds 1,000 compact sessions plus one 500-turn history containing 500 tool calls, exhausts and remounts that history in Chat, and reports Chromium main-thread, DOM, listener, heap, paging, search, and Trajectory measurements. Two continuation cases seed the same long history but compare the default 24-turn Chat window with all 500 turns expanded before each continues eight identical turns through the real composer, agent loop, SSE wire, tools, and persistence; two turns execute a real `bash` call and assert its durable result, while the final turn fills an 8,232-character mixed-language prompt and replays 120 paced text deltas. A separate soak case starts from a blank session, drives 100 consecutive real composer turns with a `bash` call and result every tenth turn, forces GC every ten turns, and reports ten-turn latency windows plus retained browser state. It then submits a 101st text-only turn with a trusted browser click and measures browser-clock send-to-transcript-DOM and send-to-post-paint latency, excluding the composer's draft mirrors, separately from full-turn completion. Per-turn diagnostics cover composer fill, click-to-user-echo, click-to-first-chunk, completion, browser mutations, persisted chunks, and tool events; the synthetic replay model has enough context capacity to keep fixture cardinality stable instead of consuming scripted calls through compaction. Structural assertions pin the intended load, stream, and tool shapes, but timing remains threshold-free because machine speed is not a correctness contract. The required `vitest.web.config.ts` inventory remains limited to `*.e2e.ts` and `*.snapshot.ts`, so neither `test:web:built` nor its CI gate collects performance cases.
|
||||
|
||||
@@ -95,4 +95,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
|
||||
|
||||
## Consequences
|
||||
|
||||
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.
|
||||
The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium and WebKit provisioning and the browser lane so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.
|
||||
@@ -10,9 +10,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
## 决策
|
||||
|
||||
布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
|
||||
|
||||
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
|
||||
`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。Chromium 运行整个车道;[模型与推理强度选择场景](../../../../apps/web/tests/declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
|
||||
|
||||
### Scaffold:`apps/web/tests/scaffold.ts`
|
||||
|
||||
@@ -28,11 +26,13 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);禁止轮询持久化文件来充当轮次完成或持久性屏障(NFS 上慢,且被 `whenIdle` 取代),但工具控制的临时就绪标记可以仅作为该完成屏障之前的交互门控进行轮询;`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。导航断言会在页面加载前同时监听 `session.list` 和 `workspace.list` 的初始响应,随后等待播种数据投影到 DOM;仅凭 shell 已挂载不能判定就绪,因为较晚完成的 bootstrap 可能替换受控状态。
|
||||
|
||||
布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
|
||||
|
||||
不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
|
||||
|
||||
分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。
|
||||
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
|
||||
每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每个文件中的每种引擎各启动一个浏览器、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../../archived/feature/2026-07-31-browser-derived-initial-locale.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
|
||||
|
||||
### 预期输出
|
||||
|
||||
@@ -50,7 +50,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
### CI 立场
|
||||
|
||||
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.zh.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。
|
||||
根据[浏览器快照 CI 决策](2026-07-30-web-browser-snapshot-ci-gate.zh.md),该车道是 Linux 拉取请求必需的只比较门禁。`node 24 / snapshots and artifacts` 消费方任务在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,安装锁文件选定的 Chromium 和 WebKit,恢复以操作系统和锁文件为键的缓存,并用 `DSH_SNAPSHOT=replay` 运行该车道。这是有意的平面切分:host 与 spec 使用 [tsx 源码启动约定](../architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md),浏览器则消费 `apps/web/dist` 和包的 `lib/client.js` 产物,因此门禁依赖 `built-package-invariants` 提供这些客户端产物。自托管的默认分支 Linux 串行热备运行同一门禁;不存在托管 Linux 串行生产者生成供 PR 消费的浏览器缓存,持久化自托管池则不需要托管侧缓存。CI 从不录制或刷新预期输出。场景仍面向 POSIX,并继续置于 Windows 和 macOS 矩阵之外。
|
||||
|
||||
高基数性能诊断使用单独按需启用的 `apps/web/tests/**/*.perf.ts` 清单,并且只由 `vitest.web.perf.config.ts` 选中。`complex-history.perf.ts` 的隔离用例复用真实 scaffold:工作区用例播种 1,000 个紧凑会话以及一份包含 500 次工具调用的 500 轮次历史,在 Chat 中穷尽并重新挂载该历史,并报告 Chromium 主线程、DOM、监听器、堆内存、分页、搜索和 Trajectory 测量结果。两个续聊用例播种同一份长历史,但比较默认的 24 轮次 Chat 窗口与展开全部 500 轮次的状态,然后各自通过真实输入框、agent loop、SSE wire、工具和持久化继续进行 8 个相同轮次;其中两轮执行真实 `bash` 调用并断言其持久化结果,最后一轮则填入一条包含 8,232 个字符的混合语言提示词,并回放 120 个带节奏的文本增量。一个单独的 soak 用例从空白会话开始,通过真实输入框连续驱动 100 轮,每第 10 轮执行一次 `bash` 调用并产生结果,每 10 轮强制执行一次 GC,并报告每 10 轮的延迟窗口及保留的浏览器状态。随后它通过受信任的浏览器点击提交第 101 个纯文本轮次,并使用浏览器时钟分别测量发送到 transcript DOM 和发送到绘制后的延迟,排除输入框的草稿镜像,并与完整轮次完成时间分开。逐轮诊断涵盖输入框填入、点击到用户消息回显、点击到首个分片、完成、浏览器变更、持久化分片和工具事件;合成回放模型拥有足够的上下文容量,可使 fixture 基数保持稳定,而不会因压缩(compaction)消耗脚本化调用。结构性断言钉住预期的负载、流和工具形状,但时间仍不设阈值,因为机器速度不属于正确性约定。必需的 `vitest.web.config.ts` 清单仍仅限 `*.e2e.ts` 和 `*.snapshot.ts`,因此 `test:web:built` 及其 CI 门禁都不会收集性能用例。
|
||||
|
||||
@@ -95,4 +95,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
|
||||
|
||||
## 后果
|
||||
|
||||
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。
|
||||
Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 与 WebKit 供给及浏览器车道的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md
|
||||
2026-07-30-web-browser-snapshot-ci-gate.md: d1c8dcdd859d0515804cc6aed0d2f3341beb6dd0
|
||||
2026-07-30-web-browser-snapshot-ci-gate.zh.md: 9d642819e09abc63384f4f4eb105d13261a9eb95
|
||||
2026-07-30-web-browser-snapshot-ci-gate.md: 352072bca6e1a9e1d5b72973d2c3c9e96bc58ad9
|
||||
2026-07-30-web-browser-snapshot-ci-gate.zh.md: cd04f33a5db6ddb3c88a235fb34ea31da0e5a869
|
||||
@@ -12,13 +12,13 @@ The [keyless web browser e2e lane](2026-07-24-web-gui-browser-e2e-lane.md) runs
|
||||
|
||||
For Linux PRs, the `node 24 / snapshots and artifacts` job must run the full web browser replay/compare suite. When `DSH_WEB_SNAPSHOT_WORKERS` is configured, `scripts/run-gates.ts` registers `test:web:ci` as the `ci-consumers` gate and explicitly injects `DSH_SNAPSHOT=replay`; CI never runs in `record` or `refresh` mode, so when the committed goldens disagree with the currently assembled application, the tests fail directly instead of silently rewriting them on the runner and then passing.
|
||||
|
||||
The consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. On hosted runners, CI installs Chromium and its system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns the Linux system packages and CI installs only Chromium, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions.
|
||||
The consumer job owns the [single Linux build](../../archived/process/2026-07-30-independent-ci-consumer-build.md), so `apps/web/dist` and the package `lib/` directories remain in its workspace for the browser suite. Chromium runs the full suite; the model and reasoning picker also runs in WebKit to cover native mouse focus behavior. On hosted runners, CI installs both engines and their system dependencies at the Playwright version in the lockfile. On the persistent failover VM, the image owns both engines’ Linux system packages and CI installs only the browser binaries, avoiding per-run `apt` mutation. Pull requests restore the operating-system-and-lockfile-keyed browser cache without paying compression and upload on the required path, with an operating-system prefix fallback across lockfile changes. No master job produces these hosted caches, so restores hit archived entries until they evict. The self-hosted standby runs the same comparison without hosted cache actions.
|
||||
|
||||
Local `pnpm run test:web` continues to build first and then run the full browser suite serially; `test:web:built` is the serial entry point for existing build artifacts. Developers explicitly run `DSH_SNAPSHOT=refresh pnpm run test:web` only after confirming that user-visible output changed intentionally, review every expected-output diff, and then verify again in replay mode that no files are written.
|
||||
|
||||
CI’s `scripts/run-web-snapshots.ts` runs `hmr-live.e2e.ts` separately because it mutates built workspace state. One six-worker Vitest pool then runs every remaining file, including historical Cordis-card rendering. Every child inherits stdio, and `run-gates` streams its output.
|
||||
|
||||
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision Chromium. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
|
||||
For pull requests, the gate runs only in the Linux consumer job: these scenarios target POSIX, and the other PR jobs do not provision both engines. The self-hosted default-branch Linux serial standby also includes the comparison, while the macOS and Windows serial jobs remain browser-free (there is no hosted Linux serial aggregate). A PR's `all checks passed` verdict already depends on the consumer job, so a browser compare failure blocks the merge without requiring a new branch-protection check name.
|
||||
|
||||
Completed local replays measured the six-worker browser command at about 65–71 seconds. A twelve-worker comparison completed in about 50 seconds, so halving the browser worker budget adds about 15–20 seconds rather than doubling wall time. The gate scheduler starts browser snapshots as soon as `built-package-invariants` succeeds and runs independent gates concurrently, so it needs neither a dedicated job timeout nor a manual YAML ordering rule.
|
||||
|
||||
@@ -36,4 +36,4 @@ Completed local replays measured the six-worker browser command at about 65–71
|
||||
|
||||
## Consequences
|
||||
|
||||
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright/Chromium upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
|
||||
Before merge, every PR proves that the current web assembly matches all committed browser expected outputs; a missing refresh fails in the same PR that changes the assembly. The cost is Chromium and WebKit provisioning, one serial scenario, and one bounded six-worker pool in the consumer job; the consumer-owned build and browser cache avoid duplicate builds and downloads on reruns. Parallel-file failures stream immediately, but a worker-budget change still requires a completed end-to-end measurement rather than an elapsed-time guess. The gate makes no claim of cross-platform browser consistency, and if a Playwright or browser upgrade changes the ARIA format, the upgrade PR must explicitly refresh the expected outputs and review the churn.
|
||||
@@ -12,13 +12,13 @@ Status: implemented
|
||||
|
||||
Linux PR 的 `node 24 / snapshots and artifacts` 必须运行完整 Web 浏览器 replay/compare。配置 `DSH_WEB_SNAPSHOT_WORKERS` 后,`scripts/run-gates.ts` 把 `test:web:ci` 登记为 `ci-consumers` 门禁,并显式注入 `DSH_SNAPSHOT=replay`;CI 永不以 `record` 或 `refresh` 模式运行,因此提交的预期输出与当前组装应用不一致时测试直接失败,不会在 runner 内静默改写后通过。
|
||||
|
||||
消费方 job 在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。在托管运行器上,CI 按锁文件中的 Playwright 版本安装 Chromium 及其系统依赖。在持久化故障切换 VM 上,镜像负责预装 Linux 系统软件包,CI 只安装 Chromium,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。
|
||||
消费方 job 在[消费方独立构建](../../archived/process/2026-07-30-independent-ci-consumer-build.md)中负责唯一一次 Linux 构建,因此 `apps/web/dist` 和包的 `lib/` 目录会保留在其工作区中,供浏览器套件使用。Chromium 运行完整套件;模型与推理强度选择场景还在 WebKit 中运行,以覆盖原生鼠标焦点行为。在托管运行器上,CI 按锁文件中的 Playwright 版本安装这两种引擎及其系统依赖。在持久化故障切换 VM 上,镜像负责预装两种引擎的 Linux 系统软件包,CI 只安装浏览器程序,避免每次运行都通过 `apt` 改动系统。PR 恢复以操作系统和锁文件为键的浏览器缓存,使必需路径无需承担压缩和上传开销,并可在锁文件变化时按操作系统前缀回退。没有任何 master 作业生成这些 hosted 缓存,因此恢复只能命中仍有归档的旧条目,直至其被逐出。自托管热备运行相同的比较,但不执行托管缓存操作。
|
||||
|
||||
本地 `pnpm run test:web` 仍先构建,再串行运行完整浏览器套件;`test:web:built` 是已有构建产物的串行执行入口。开发者只在确认用户可见输出有意变化后显式运行 `DSH_SNAPSHOT=refresh pnpm run test:web`,评审每一处预期输出 diff,再以 replay 模式复验不再写文件。
|
||||
|
||||
CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-live.e2e.ts`。随后由一个六 worker Vitest 池运行其余文件,包括历史 Cordis 卡片渲染。所有子进程继承 stdio,由 `run-gates` 流式转发输出。
|
||||
|
||||
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不安装 Chromium。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
|
||||
对 PR 而言,门禁仅在 Linux 消费方 job 中运行:这些场景面向 POSIX,其他 PR job 不会同时安装这两种引擎。自托管的默认分支 Linux 串行热备也包含该比较,而 macOS 和 Windows 串行 job 仍不使用浏览器(不存在托管的 Linux 串行聚合)。PR 的 `all checks passed` 已依赖消费方 job,因此浏览器比较失败会阻止合并,无需新增 branch-protection check 名称。
|
||||
|
||||
完整本地 replay 中,6-worker 浏览器命令耗时约 65–71 秒。12-worker 对比约为 50 秒,因此把浏览器 worker 预算减半只增加约 15–20 秒,而不是让墙钟时间翻倍。门禁调度器会在 `built-package-invariants` 成功后立即启动浏览器快照,并发运行彼此独立的门禁,因此既不需要专用 job 超时,也不需要手动制定 YAML 顺序规则。
|
||||
|
||||
@@ -36,4 +36,4 @@ CI 的 `scripts/run-web-snapshots.ts` 单独运行会修改构建产物的 `hmr-
|
||||
|
||||
## 后果
|
||||
|
||||
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium、串行运行 2 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright/Chromium 升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
|
||||
每个 PR 都在合并前证明当前 Web 组装与所有已提交的浏览器预期输出一致;漏刷会在改变该组装的同一个 PR 中失败。成本是消费方 job 需要安装 Chromium 和 WebKit、串行运行 1 个场景并执行 1 个有界 6-worker 池;消费方独立构建与浏览器缓存避免重跑时重复构建和下载。并行文件的失败会立即流式显示,但 worker 预算的任何变化仍需要完整端到端测量,而不能依据运行中耗时猜测。门禁不声称跨平台浏览器一致性,Playwright 或浏览器升级若改变 ARIA 格式,升级 PR 必须显式 refresh 并评审 churn。
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: dsh-client-ui-ux
|
||||
description: Design and review DeepSeek Harness client UI changes — visual token discipline, reuse-before-adding, feedback surfaces (toast vs in-place notice vs empty state), overlay and menu safety, platform window adaptation, loading states, and error copy placement. Use when adding or changing product-user-visible GUI behavior in packages/client, or when reviewing such a PR.
|
||||
---
|
||||
|
||||
# DeepSeek Harness Client UI/UX
|
||||
|
||||
This skill is guidance, not a complete checklist. It covers judgment calls that lint, typecheck, and the i18n gate cannot make: where feedback appears, what gets reused, and how surfaces behave at window edges and across platforms. Copy quality itself follows [dsh-prose-standard](../dsh-prose-standard/SKILL.md); locale ownership follows the [locale-owned copy decision](../../notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md); styling ownership and token rules live in [docs/web-styling.md](../../../docs/web-styling.md) and the [ui-primitives component catalog](../../../packages/client/ui-primitives/README.md#component-catalog) — this skill adds judgment on top of those rules, not a second copy of them.
|
||||
|
||||
## Visual foundation
|
||||
|
||||
- **Use existing tokens for color, radius, and spacing.** Before introducing a literal value, search the target page and its nearest established sibling (the Plugins page is the usual reference for full-page surfaces) for the token or value already in use, and adopt it. The same applies to whole treatments — toasts, menus, in-message artifact previews, sidebar tab layout, colors, clearance rules, page padding: reuse the established component or rule wherever one exists instead of inventing a parallel one.
|
||||
- **Verify every color in both light and dark mode.** An ink/background pair must stay readable under both themes before it ships. Dimmed tones are the standing counter-example: they suit decorative surfaces, but on text they routinely fall below readability in one theme — never put a dimmed color on text without checking both.
|
||||
- **Font weight tops out at 500 in feature CSS you add or change.** Do not use 600+ for emphasis; differentiate with size or ink instead. The markdown typography tokens owned by ui-theme are the standing exception.
|
||||
- **Pick font sizes from the theme typography variables when an existing role matches, otherwise from the page's existing scale.** Parallel content shares one size; never invent a size for a single element, and keep the number of distinct sizes on a page small (13px is the established row/detail size). When the page itself has no precedent, borrow the size and spacing ratios of the most similar existing page or element.
|
||||
- **Icons, text, and neighbouring elements stay proportionate and aligned.** Every element lines up with its siblings along a deliberate axis (baseline, vertical center, or shared edge), and an icon's size stays in proportion to the text beside it; never ship an element that aligns to nothing or visibly outweighs its neighbours.
|
||||
|
||||
## Reuse before adding
|
||||
|
||||
- **Extend an existing component, container, or interaction before creating a new one.** A new capability that fits an existing surface (an extra menu item, a new prop on an existing primitive) beats a parallel element stacked beside it.
|
||||
- **Icons come from the existing icon library.** Pick the semantically closest existing icon; a new glyph requires designer approval before it lands — when reviewing a PR that adds one, suggest requesting a designer review.
|
||||
- **Right-sidebar content registers a slot in the existing sidebar**, never a separate sidebar. Every sidebar tab declares a tab icon matching its meaning.
|
||||
- **Icon-only actions whose meaning is not obvious get a Tooltip.** Informational content the pointer must rest on or select uses HoverCard instead. Prefer these shared primitives over ad-hoc title attributes.
|
||||
|
||||
## Feedback surfaces
|
||||
|
||||
Choose the surface by the lifetime of the message relative to the surface that produced it:
|
||||
|
||||
- **Transient operation outcomes use the app-wide Toast primitive**, not in-place notices, and the toast's state and rendering live in a host that outlives the reporting surface (a `shell.overlay` entry, as RowActionToast does) — a toast rendered by the panel itself unmounts with that panel. A deletion can close the panel or tab that requested it; only a toast held outside that surface survives the close. Report success and failure; do not report still-pending states.
|
||||
- **A failed operation keeps the data visible.** A failed deletion keeps its row; the toast announces the failure. Never blank content to show an error for a transient operation.
|
||||
- **In-place notices are for states tied to the surface itself**: a query failure with its Retry action, field validation on the form that owns the field. A populated panel keeps a compact notice beside its retained content; only an empty panel centers a full error state.
|
||||
- **Error copy is plain language, and short.** No internal jargon or technical nouns unless an error code must be exposed for debugging. Prefer one clear sentence; anything explainable in two sentences must not grow into paragraphs. A Chinese notice of at most two sentences omits the trailing 句号(。). The notice must not break the existing layout — reserve its space or overlay it; never shift neighbouring elements.
|
||||
|
||||
## Loading states
|
||||
|
||||
- Lists use their skeleton; every other page-level load centers a bare spinner. Never place a spinner in a corner of the page.
|
||||
- One loading treatment per page/component — concurrent regions must not each flash their own style.
|
||||
- No loading text unless the context needs it (narrow sidebars almost never do).
|
||||
|
||||
## Overlays and menus
|
||||
|
||||
Every menu, popover, and tooltip must be verified for all three before merge:
|
||||
|
||||
- **Dismissable**: outside click (and Escape where focusable) closes it.
|
||||
- **Viewport-fitting**: it slides or flips to stay inside the window with an edge margin, and flips only into a side that actually fits (no oscillation when neither side fits).
|
||||
- **Unclipped**: it escapes `overflow` ancestors and containing blocks (portal to body where needed) and is never covered by neighbouring elements.
|
||||
|
||||
Scrollbars stay inside their container: use the shared scrollbar styles rather than component-specific selectors, and verify the bar neither overflows the container nor gets clipped by its rounded corners or edges — keep a small inset instead of letting it sit flush against the container edge.
|
||||
|
||||
## Platform window adaptation
|
||||
|
||||
- Any new header element or full page must account for macOS and Windows window chrome. On macOS the titlebar clearance is `--dsh-frame-top-clearance`, consumed by Menu, Modal, and overlay primitives as their top safe margin — **never change the global variable to fix one page**. Add a scoped `[data-platform='darwin']` override on the page's own inset instead, and assert the override in the page's stylesheet spec.
|
||||
- State the intended total offset (clearance + page inset) when reviewing, so two pages aligning to "the same top spacing" agree on the sum, not just one addend.
|
||||
|
||||
## Spacing review
|
||||
|
||||
- Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing.
|
||||
- Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value.
|
||||
|
||||
## Design review escalation
|
||||
|
||||
- A PR with extensive UI changes, or any clearly user-perceivable change to UI or interaction, must get a designer or product review. When no approver holds that role, suggest requesting one from the design/product reviewers (roster as of 2026-09: yx zhang, yifffan, zhangziya, gaokaige; update this list here when membership changes).
|
||||
@@ -14,6 +14,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
|
||||
- [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline.
|
||||
- [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings.
|
||||
- [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures.
|
||||
- [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes.
|
||||
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates Agent Note](../../notes/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates.
|
||||
- [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto.
|
||||
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation.
|
||||
|
||||
@@ -165,8 +165,8 @@ jobs:
|
||||
|
||||
# The persistent VM image owns Playwright's Linux system packages; this
|
||||
# step also proves that browser provisioning remains usable for failover.
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
|
||||
- name: Install Playwright Chromium and WebKit
|
||||
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium webkit
|
||||
|
||||
- name: Prepare bubblewrap (unrestrict userns)
|
||||
run: bash scripts/prepare-ci-bubblewrap.sh
|
||||
|
||||
@@ -344,18 +344,18 @@ jobs:
|
||||
r'https://\1/', source.read_text()))
|
||||
PY
|
||||
|
||||
- name: Install Playwright Chromium and hosted dependencies
|
||||
- name: Install Playwright Chromium, WebKit, and hosted dependencies
|
||||
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
# Ephemeral runner images can carry Ubuntu mirrors whose HTTP endpoints are unreachable.
|
||||
run: |
|
||||
sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) -exec sed -i -E 's@http://([a-z]{2}\.)?archive\.ubuntu\.com/ubuntu@https://archive.ubuntu.com/ubuntu@g; s@http://security\.ubuntu\.com/ubuntu@https://security.ubuntu.com/ubuntu@g' {} +
|
||||
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
|
||||
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit
|
||||
|
||||
# The persistent VM image owns Playwright's Linux system packages; do
|
||||
# not mutate the shared host with apt on every failover run.
|
||||
- name: Install Playwright Chromium on the failover VM
|
||||
- name: Install Playwright Chromium and WebKit on the failover VM
|
||||
if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
|
||||
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium webkit
|
||||
|
||||
- name: Build and execute the Linux Office runtime
|
||||
run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads"
|
||||
|
||||
@@ -109,6 +109,9 @@ jobs:
|
||||
- name: Build (lib for the e2e example bins)
|
||||
run: pnpm run build:official
|
||||
|
||||
- name: Prepare Office runtime
|
||||
run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office
|
||||
|
||||
# Real-API end-to-end tests only. The keyless gates (lint/typecheck/
|
||||
# coverage/snapshot/etc.) already run in ci.yml (pull requests) and
|
||||
# ci-master.yml (master push standby).
|
||||
@@ -118,6 +121,7 @@ jobs:
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic
|
||||
DSH_PRIMARY_RUNTIME: ${{ github.workspace }}/.cache/e2e-office/primary-runtime
|
||||
DSH_E2E_MAX_WORKERS: 4
|
||||
DSH_EXAMPLE_MODE: lib
|
||||
run: pnpm run test:e2e
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c
|
||||
README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec
|
||||
README.md: 765440438175083d2dbd765592731df5ec148bcf
|
||||
README.zh.md: 08db105b160199cb9a64276f273b8b00f0d21c09
|
||||
@@ -36,6 +36,8 @@ dsh --help # the launcher's own help
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics.
|
||||
|
||||
Installation and profile startup enforce declared DSH peer ranges against the same runtime version shown by `dsh --version`. Incompatible plugins require an explicitly acknowledged exact-version exemption. The [plugin manager's compatibility reference](../../packages/boot/plugin-manager/README.md#version-compatibility-and-exemptions) documents `version-exemptions`, `allow-version`, `revoke-version`, persistence, and risks.
|
||||
|
||||
The tree composes over an empty root:
|
||||
- each bundle's patch in `dsh.profile.bundles` order
|
||||
- then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`
|
||||
|
||||
@@ -36,6 +36,8 @@ dsh --help # the launcher's own help
|
||||
|
||||
profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。
|
||||
|
||||
安装和 profile 启动会按声明的 DSH peer 范围,检查与 `dsh --version` 显示值相同的运行时版本。不兼容插件需要用户明确确认精确版本豁免。[插件管理器的兼容性参考](../../packages/boot/plugin-manager/README.zh.md#version-compatibility-and-exemptions)说明 `version-exemptions`、`allow-version`、`revoke-version`、持久化规则与风险。
|
||||
|
||||
配置树以空根为起点,依次叠加以下配置层:
|
||||
- `dsh.profile.bundles` 中各组合包的 patch
|
||||
- profile 自身的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
+2
-14
@@ -6,29 +6,17 @@
|
||||
|
||||
/* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
|
||||
import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { parseDshArgs } from './args.ts'
|
||||
import { reportStartupFailure } from './startup-diagnostics.ts'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
// one directory under apps/cli, so the checked-in manifest resolves with the
|
||||
// same relative hop from either artifact.
|
||||
function readVersion(): string {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
||||
) as { version?: unknown }
|
||||
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the public dsh command-line interface.
|
||||
* @returns a promise that settles when the selected command mode finishes.
|
||||
*/
|
||||
export async function runCli(): Promise<void> {
|
||||
const version = readVersion()
|
||||
const version = getDshRuntimeVersion()
|
||||
const invocation = parseDshArgs(process.argv.slice(2), version)
|
||||
|
||||
switch (invocation.mode) {
|
||||
|
||||
+66
-6
@@ -1,23 +1,83 @@
|
||||
/** dsh plugin forwards pnpm through the shared profile package operations. */
|
||||
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
/** Profile package management and explicit, exact-version compatibility approvals. */
|
||||
import { runPluginCommand, setProfileVersionExemption } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { INSTALL_ANCHOR } from './profile-boot.ts'
|
||||
import { resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
|
||||
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Parse only DSH-owned commands; all other arguments remain pnpm's responsibility. */
|
||||
async function versionCommand(profile: string, args: readonly string[]): Promise<number | undefined> {
|
||||
const [command, ...rest] = args
|
||||
if (command !== 'allow-version' && command !== 'revoke-version' && command !== 'version-exemptions') return undefined
|
||||
try {
|
||||
let packageVersion: string | undefined
|
||||
let runtimeVersion: string | undefined
|
||||
let acceptRisk = false
|
||||
const argumentsIterator = rest.values()
|
||||
for (const argument of argumentsIterator) {
|
||||
if (argument === '--accept-risk' && command === 'allow-version' && !acceptRisk) acceptRisk = true
|
||||
else if (argument === '--dsh-version' && runtimeVersion === undefined) runtimeVersion = argumentsIterator.next().value
|
||||
else if (argument.startsWith('--dsh-version=') && runtimeVersion === undefined) runtimeVersion = argument.slice('--dsh-version='.length)
|
||||
else if (!argument.startsWith('-') && packageVersion === undefined) packageVersion = argument
|
||||
else throw new Error(`unexpected argument ${JSON.stringify(argument)}`)
|
||||
}
|
||||
if (command === 'version-exemptions' && rest.length > 0) throw new Error('usage: dsh plugin version-exemptions')
|
||||
let request: { packageVersion: string; runtimeVersion: string } | undefined
|
||||
if (command !== 'version-exemptions') {
|
||||
if (packageVersion === undefined || runtimeVersion === undefined) {
|
||||
throw new Error(`usage: dsh plugin ${command} <package@version> --dsh-version <exact>${command === 'allow-version' ? ' --accept-risk' : ''}`)
|
||||
}
|
||||
request = { packageVersion, runtimeVersion }
|
||||
}
|
||||
if (command === 'allow-version') {
|
||||
process.stderr.write('dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.\n')
|
||||
}
|
||||
const dir = resolveProfileDir(profile)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await withFileLock(join(dir, 'package.json'), async () => {
|
||||
if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES)
|
||||
if (request === undefined) {
|
||||
const { exemptions, warnings } = readProfileCompatibility(dir)
|
||||
for (const warning of warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
|
||||
process.stdout.write(JSON.stringify(exemptions, undefined, 2) + '\n')
|
||||
} else {
|
||||
await setProfileVersionExemption(dir, request.packageVersion, request.runtimeVersion, command === 'allow-version', acceptRisk)
|
||||
process.stdout.write(`dsh: ${command === 'allow-version' ? 'allowed' : 'revoked'} ${request.packageVersion} for DSH ${request.runtimeVersion}\n`)
|
||||
}
|
||||
}, { waitMs: 120000 })
|
||||
return 0
|
||||
} catch (error) {
|
||||
process.stderr.write(`dsh: ${String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Run package management for a profile.
|
||||
* @param profile Profile name.
|
||||
* @param args Pnpm arguments relative to the invoking directory.
|
||||
* @returns Pnpm exit code.
|
||||
* @param args DSH exemption command or pnpm arguments relative to the invoking directory.
|
||||
* @returns Zero on success; nonzero on invalid approval or package-manager failure.
|
||||
*/
|
||||
export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
|
||||
const versionResult = await versionCommand(profile, args)
|
||||
if (versionResult !== undefined) return versionResult
|
||||
const dir = resolveProfileDir(profile)
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
for (const warning of readProfileCompatibility(dir).warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
|
||||
}
|
||||
const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, {
|
||||
execution: 'cli',
|
||||
outputBytes: 16384,
|
||||
lockWaitMs: 120000,
|
||||
lookupTimeoutMs: 120000,
|
||||
onOutput: (text, stream) => { process[stream].write(text) },
|
||||
})
|
||||
if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
|
||||
if (result.exitCode !== 0) process.stderr.write(`dsh: pnpm failed; diagnostics: ${result.logPath}\n`)
|
||||
for (const { name, version, runtimeVersion } of result.incompatible ?? []) {
|
||||
process.stderr.write(`dsh: to accept the risk, run: dsh plugin --profile ${profile} allow-version ${name}@${version} --dsh-version ${runtimeVersion} --accept-risk\n`)
|
||||
}
|
||||
if (result.exitCode !== 0) process.stderr.write(`dsh: plugin command failed; diagnostics: ${result.logPath}\n`)
|
||||
if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
|
||||
process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/** Published CLI acceptance for actual pnpm installation, restart checks, and explicit exemptions. */
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
const bin = fileURLToPath(new URL('../lib/bin.js', import.meta.url))
|
||||
const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
|
||||
it('refuses incompatible installation and startup until an exact risk exemption is granted', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-compatibility-cli-'))
|
||||
onTestFinished(() => { rmSync(home, { recursive: true, force: true }) })
|
||||
const profile = join(home, 'profiles', 'compatibility')
|
||||
const source = join(home, 'fixture-plugin')
|
||||
mkdirSync(profile, { recursive: true })
|
||||
mkdirSync(source)
|
||||
const profileFile = join(profile, 'package.json')
|
||||
const initial = { name: 'compatibility-profile', private: true, dependencies: {}, dsh: { profile: { bundles: [] } } }
|
||||
writeFileSync(profileFile, JSON.stringify(initial))
|
||||
writeFileSync(join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\n')
|
||||
writeFileSync(join(source, 'package.json'), JSON.stringify({
|
||||
name: 'compatibility-fixture', version: '1.0.0', type: 'module',
|
||||
peerDependencies: { '@deepseek-ai/dsh': '999.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
writeFileSync(join(source, 'cordis.patch.yml'), '- insert:\n - id: compatibility-fixture\n name: ./index.mjs\n')
|
||||
writeFileSync(join(source, 'index.mjs'), 'process.stdout.write("PLUGIN_IMPORTED\\n"); export function apply() { process.stdout.write("PLUGIN_STARTED\\n") }\n')
|
||||
const run = async (args: string[]) => {
|
||||
const result = await execa(process.execPath, [bin, ...args], {
|
||||
cwd: home, env: { ...process.env, DSH_HOME: home }, extendEnv: false, reject: false, timeout: 60_000,
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.signal).toBeUndefined()
|
||||
return result
|
||||
}
|
||||
const plugin = (args: string[]) => run(['plugin', '--profile', 'compatibility', ...args])
|
||||
const refused = await plugin(['add', source, '--offline', '--ignore-scripts'])
|
||||
expect(refused.exitCode).toBe(1)
|
||||
expect(refused.stderr).toContain('crashes or data loss')
|
||||
expect(JSON.parse(readFileSync(profileFile, 'utf8'))).toEqual(initial)
|
||||
expect(existsSync(join(profile, 'compatibility.json'))).toBe(false)
|
||||
// The named package is checked before installation, so a refused one never lands on disk.
|
||||
expect(refused.stderr).toContain('nothing was installed')
|
||||
expect(existsSync(join(profile, 'node_modules', 'compatibility-fixture'))).toBe(false)
|
||||
|
||||
// A package installed outside DSH still goes through the independent startup check.
|
||||
cpSync(source, join(profile, 'node_modules', 'compatibility-fixture'), { recursive: true })
|
||||
writeFileSync(profileFile, JSON.stringify({ ...initial,
|
||||
dependencies: { 'compatibility-fixture': `file:${source}` }, dsh: { profile: { bundles: ['compatibility-fixture'] } },
|
||||
}))
|
||||
const deniedStartup = await run(['--profile', 'compatibility'])
|
||||
expect(deniedStartup.stdout).not.toContain('PLUGIN_IMPORTED')
|
||||
expect(deniedStartup.stderr).toContain('incompatible')
|
||||
const missingConsent = await plugin(['allow-version', '[email protected]', '--dsh-version', version])
|
||||
expect(missingConsent.exitCode).toBe(1)
|
||||
const granted = await plugin(['allow-version', '[email protected]', '--dsh-version', version, '--accept-risk'])
|
||||
expect(granted.exitCode).toBe(0)
|
||||
expect(granted.stderr).toContain('break the application or corrupt data')
|
||||
// The exemption is profile metadata of its own; the dependency manifest and bundle list stay untouched.
|
||||
expect(JSON.parse(readFileSync(join(profile, 'compatibility.json'), 'utf8'))).toEqual({ '[email protected]': [version] })
|
||||
expect(JSON.parse(readFileSync(profileFile, 'utf8'))).not.toHaveProperty('dsh.profile.versionExemptions')
|
||||
expect((await plugin(['add', source, '--offline', '--ignore-scripts'])).exitCode).toBe(0)
|
||||
const admittedStartup = await run(['--profile', 'compatibility'])
|
||||
expect(admittedStartup.exitCode).toBe(0)
|
||||
expect(admittedStartup.stdout).toContain('PLUGIN_STARTED')
|
||||
expect((await plugin(['revoke-version', '[email protected]', '--dsh-version', version])).exitCode).toBe(0)
|
||||
expect((await run(['--profile', 'compatibility'])).stdout).not.toContain('PLUGIN_IMPORTED')
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Explicit CLI exemptions use the profile lock and never become pnpm arguments. */
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { getDshRuntimeVersion, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
|
||||
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
|
||||
import { runPlugin } from '../src/plugin.ts'
|
||||
|
||||
vi.mock('../src/profile-boot.ts', () => ({ INSTALL_ANCHOR: '/installation/package.json' }))
|
||||
vi.mock('@deepseek-ai/dsh-plugin-manager/operations', async importOriginal => ({
|
||||
...await importOriginal<typeof import('@deepseek-ai/dsh-plugin-manager/operations')>(),
|
||||
runPluginCommand: vi.fn(),
|
||||
}))
|
||||
|
||||
function fixture() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'cli-version-exemptions-'))
|
||||
vi.stubEnv('DSH_HOME', home)
|
||||
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
onTestFinished(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
vi.mocked(runPluginCommand).mockReset()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
})
|
||||
return { dir: join(home, 'profiles', 'test'), stdout, stderr }
|
||||
}
|
||||
|
||||
it('requires explicit acknowledgement, grants only the exact pair, lists and revokes it without pnpm', async () => {
|
||||
const { dir, stdout, stderr } = fixture()
|
||||
const runtime = getDshRuntimeVersion()
|
||||
expect(await runPlugin('test', ['allow-version', '@example/[email protected]', '--dsh-version', runtime, '--accept-risk'])).toBe(0)
|
||||
expect(stderr.mock.calls.map(call => call[0]).join('')).toMatchInlineSnapshot(`
|
||||
"dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.
|
||||
"
|
||||
`)
|
||||
expect(stderr.mock.invocationCallOrder[0]).toBeLessThan(stdout.mock.invocationCallOrder[0]!)
|
||||
expect(readProfileVersionExemptions(dir)).toEqual({ '@example/[email protected]': [runtime] })
|
||||
expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '@example/[email protected]': [runtime] })
|
||||
expect(await runPlugin('test', ['version-exemptions'])).toBe(0)
|
||||
expect(stdout).toHaveBeenLastCalledWith(JSON.stringify({ '@example/[email protected]': [runtime] }, undefined, 2) + '\n')
|
||||
expect(await runPlugin('test', ['revoke-version', '@example/[email protected]', `--dsh-version=${runtime}`])).toBe(0)
|
||||
expect(readProfileVersionExemptions(dir)).toEqual({})
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT'],
|
||||
['allow-version', '[email protected]', '--accept-risk'],
|
||||
['allow-version', 'plugin@^1.2.3', '--dsh-version', 'CURRENT', '--accept-risk'],
|
||||
['allow-version', '[email protected]', '--dsh-version', '999.0.0', '--accept-risk'],
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk=false'],
|
||||
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk', '--accept-risk'],
|
||||
['revoke-version', '[email protected]'],
|
||||
['version-exemptions', 'extra'],
|
||||
])('rejects malformed or unacknowledged exemption command %j', async (...arguments_) => {
|
||||
const { dir, stderr } = fixture()
|
||||
const args = arguments_.map(value => value === 'CURRENT' ? getDshRuntimeVersion() : value)
|
||||
expect(await runPlugin('test', args)).toBe(1)
|
||||
expect(stderr).toHaveBeenCalled()
|
||||
expect(existsSync(join(dir, 'compatibility.json'))).toBe(false)
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses shipped profile defaults when listing a missing profile', async () => {
|
||||
fixture()
|
||||
expect(await runPlugin('web', ['version-exemptions'])).toBe(0)
|
||||
expect(runPluginCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([0, 1])('forwards ordinary pnpm output and exit status %s', async (exitCode) => {
|
||||
const { stdout, stderr } = fixture()
|
||||
vi.mocked(runPluginCommand).mockImplementation(async (_context, _args, options) => {
|
||||
options.onOutput?.('pnpm output', 'stdout')
|
||||
return { exitCode, output: '', truncated: false, logPath: '/profile/log' }
|
||||
})
|
||||
expect(await runPlugin('test', ['list'])).toBe(exitCode)
|
||||
expect(stdout).toHaveBeenCalledWith('pnpm output')
|
||||
if (exitCode === 0) expect(stderr).not.toHaveBeenCalled()
|
||||
else expect(stderr).toHaveBeenCalledWith('dsh: plugin command failed; diagnostics: /profile/log\n')
|
||||
})
|
||||
|
||||
it('names the exact grant command for each package a compatibility check refused', async () => {
|
||||
const { stderr } = fixture()
|
||||
vi.mocked(runPluginCommand).mockResolvedValue({
|
||||
exitCode: 1, output: '', truncated: false, logPath: '/profile/log',
|
||||
incompatible: [{ name: '@example/plugin', version: '1.2.3', runtimeVersion: '0.1.0', peers: { '@deepseek-ai/dsh': '^9.0.0' } }],
|
||||
})
|
||||
expect(await runPlugin('test', ['add', '@example/plugin'])).toBe(1)
|
||||
expect(stderr.mock.calls.map(call => call[0])).toEqual([
|
||||
'dsh: to accept the risk, run: dsh plugin --profile test allow-version @example/[email protected] --dsh-version 0.1.0 --accept-risk\n',
|
||||
'dsh: plugin command failed; diagnostics: /profile/log\n',
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards all other commands unchanged and retains package diagnostics', async () => {
|
||||
const { stderr } = fixture()
|
||||
vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 127, output: '', truncated: false, logPath: '/profile/log' })
|
||||
const args = ['add', 'github:example/plugin']
|
||||
expect(await runPlugin('test', args)).toBe(127)
|
||||
expect(runPluginCommand).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), args, expect.objectContaining({ execution: 'cli' }))
|
||||
expect(stderr.mock.calls.map(call => call[0]).join('')).toContain('pnpm was not found')
|
||||
})
|
||||
@@ -432,11 +432,11 @@ async function officeFixture(root: string, pythonOnly: boolean) {
|
||||
return { source, paths }
|
||||
}
|
||||
|
||||
it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => {
|
||||
it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'python-only-no-cli', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'sdk-office-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const enabled = mode !== 'unset' && mode !== 'empty'
|
||||
const { source, paths } = await officeFixture(root, mode === 'python-only')
|
||||
const { source, paths } = await officeFixture(root, mode.startsWith('python-only'))
|
||||
if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true })
|
||||
if (mode === 'wrong-type') {
|
||||
await rm(paths.python)
|
||||
@@ -460,9 +460,11 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', '
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture did not bind')
|
||||
const home = join(root, 'home')
|
||||
const cliPatch = join(root, 'cli.patch.yml')
|
||||
await writeFile(cliPatch, JSON.stringify(mode === 'python-only-no-cli' ? [{ id: 'skill-office', config: { assetRoot: join(root, 'resources', 'office-skills'), cli: false } }] : []))
|
||||
const officeLaunch = resolveExampleLaunch({
|
||||
srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib',
|
||||
configArgs: ['--profile', 'sdk'],
|
||||
configArgs: ['--profile', 'sdk', '--patch', cliPatch],
|
||||
env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' || mode === 'bundled' ? undefined : mode === 'empty' ? '' : source + '/',
|
||||
DSH_BUNDLED_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'bundled' || mode === 'empty' ? source : join(root, 'unused-default'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
@@ -492,7 +494,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', '
|
||||
const names = (requests[0]!.tools as { name: string }[]).map(value => value.name)
|
||||
expect(names.includes('load_workspace_dependencies')).toBe(enabled)
|
||||
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
|
||||
expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets')
|
||||
expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets' && mode !== 'python-only')
|
||||
}
|
||||
if (mode === 'wrong-type') {
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -521,6 +523,7 @@ it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', '
|
||||
expect.objectContaining({ role: 'user', content }),
|
||||
]))
|
||||
}
|
||||
if (mode === 'python-only') expect(stderr).toContain('node')
|
||||
if (mode === 'missing-assets') expect(stderr).toContain('check_office.py')
|
||||
await send(3, 'shutdown')
|
||||
const exit = await child
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Agent discovery of the installed Office CLI through the shipped SDK profile.
|
||||
* Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a
|
||||
* relocated Node or Electron carrier; CARRIER=sdk selects the standalone executable.
|
||||
* NODE/CLI/ASSETS under the same prefix supply Desktop resources outside ASAR.
|
||||
* API=openai-completions selects a development gateway instead of the default Messages API.
|
||||
*/
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile, chmod } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { execa } from 'execa'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const repo = fileURLToPath(new URL('../../../../../', import.meta.url))
|
||||
const carrier = process.env.DSH_OFFICE_TEST_CARRIER ?? 'npm'
|
||||
const executable = process.env.DSH_OFFICE_TEST_EXECUTABLE ?? process.execPath
|
||||
const entry = process.env.DSH_OFFICE_TEST_ENTRY ?? join(repo, 'apps/cli/lib/bin.js')
|
||||
|
||||
it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent discovers and executes the ${carrier} Office CLI without Node on PATH`, async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh office discovery '))
|
||||
try {
|
||||
await writeFile(join(root, 'input.docx'), zipSync({
|
||||
'[Content_Types].xml': strToU8('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'),
|
||||
'_rels/.rels': strToU8('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'),
|
||||
'word/document.xml': strToU8('<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Office CLI discovery</w:t></w:r></w:p><w:sectPr/></w:body></w:document>'),
|
||||
}))
|
||||
const patch = join(root, 'office.patch.yml')
|
||||
await writeFile(patch, JSON.stringify([
|
||||
...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []),
|
||||
{ id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } },
|
||||
...carrier === 'sdk' ? [] : [{ insert: [{
|
||||
id: 'office-cli-discovery', name: '@deepseek-ai/dsh-skill-office', config: {
|
||||
...(process.env.DSH_OFFICE_TEST_NODE ? { node: process.env.DSH_OFFICE_TEST_NODE } : {}),
|
||||
...(process.env.DSH_OFFICE_TEST_CLI ? { cli: process.env.DSH_OFFICE_TEST_CLI } : {}),
|
||||
...(process.env.DSH_OFFICE_TEST_ASSETS ? { assetRoot: process.env.DSH_OFFICE_TEST_ASSETS } : {}),
|
||||
},
|
||||
}] }],
|
||||
]))
|
||||
// A private POSIX tool directory also excludes /usr/bin/node on Linux.
|
||||
const path = join(root, 'bin')
|
||||
await mkdir(path)
|
||||
for (const name of ['bash', 'sh', 'cat', 'ls', 'head', 'tail', 'mkdir']) {
|
||||
const binary = (existsSync('/bin/' + name) ? '/bin/' : '/usr/bin/') + name
|
||||
await access(binary)
|
||||
await symlink(binary, join(path, name))
|
||||
}
|
||||
const decoyMarker = join(root, 'system-office-invoked')
|
||||
for (const name of ['libreoffice', 'soffice']) {
|
||||
const decoy = join(path, name)
|
||||
await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n')
|
||||
await chmod(decoy, 0o755)
|
||||
}
|
||||
const lookup = await execa('/bin/sh', ['-c', 'command -v node || command -v libreoffice-kit'], { env: { PATH: path }, reject: false })
|
||||
expect(lookup.exitCode, 'the fixture must not expose Node or the Office CLI through PATH').not.toBe(0)
|
||||
const child = execa(executable, [
|
||||
...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch,
|
||||
], {
|
||||
cwd: root, env: {
|
||||
PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native',
|
||||
DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '',
|
||||
},
|
||||
timeout: 110_000, killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
const lines = createInterface({ input: child.stdout })
|
||||
const records: Record<string, unknown>[] = []
|
||||
let protocolError: Error | undefined
|
||||
const send = (id: number, method: string, params?: object): void => {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
|
||||
}
|
||||
lines.on('line', (line) => {
|
||||
try {
|
||||
const value = JSON.parse(line) as Record<string, unknown>
|
||||
records.push(value)
|
||||
if (value.id === 1) send(2, 'session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text:
|
||||
'Load the appropriate Office skill and convert input.docx to result.pdf. Also save the installed LibreOffice Kit capabilities command output verbatim to capabilities.json. Use the supplied runtime; do not install anything. Report briefly after the files exist.',
|
||||
}] })
|
||||
const params = value.params as { event?: { type?: string } } | undefined
|
||||
if (value.error !== undefined || params?.event?.type === 'turn/end') send(3, 'shutdown')
|
||||
} catch (error) {
|
||||
protocolError = error instanceof Error ? error : new Error(String(error))
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
})
|
||||
try {
|
||||
send(1, 'initialize', { cwd: root, provider: process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? 'office-test' : 'deepseek-official', model: process.env.MODEL_NAME ?? 'deepseek-v4-flash', maxTokens: 8000 })
|
||||
const result = await child
|
||||
const diagnostic = `${result.stderr}\n${result.stdout}`
|
||||
expect(protocolError, diagnostic).toBeUndefined()
|
||||
expect(result.timedOut, diagnostic).toBe(false)
|
||||
expect(result.signal, diagnostic).toBeUndefined()
|
||||
expect(result.exitCode, diagnostic).toBe(0)
|
||||
expect(records.filter(record => record.error !== undefined), diagnostic).toEqual([])
|
||||
const calls = records.flatMap((record) => {
|
||||
const params = record.params as { event?: { type: string; data: { name?: string; arguments?: unknown } } } | undefined
|
||||
return params?.event?.type === 'tool/call' ? [params.event.data] : []
|
||||
})
|
||||
expect(calls.some(call => call.name === 'skill'), diagnostic).toBe(true)
|
||||
const commands = calls.filter(call => call.name === 'bash')
|
||||
expect(commands.length, diagnostic).toBeGreaterThan(0)
|
||||
expect(calls.findIndex(call => call.name === 'skill'), diagnostic).toBeLessThan(calls.findIndex(call => call.name === 'bash'))
|
||||
const commandText = JSON.stringify(commands)
|
||||
for (const fragment of ['cli.js', 'capabilities', 'convert']) expect(commandText, diagnostic).toContain(fragment)
|
||||
expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false)
|
||||
expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu)
|
||||
const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } }
|
||||
expect(capabilities.runtime.version).toBe('0.1.0')
|
||||
expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u)
|
||||
const pdf = await readFile(join(root, 'result.pdf'))
|
||||
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
|
||||
expect(pdf.length).toBeGreaterThan(1000)
|
||||
} finally {
|
||||
lines.close()
|
||||
child.stdin.end()
|
||||
child.kill('SIGKILL')
|
||||
await child
|
||||
}
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -10,6 +10,7 @@
|
||||
"lib/index.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/libreoffice-kit": "^0.1.0",
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh": "workspace:*",
|
||||
"@deepseek-ai/dsh-agent": "workspace:*",
|
||||
|
||||
@@ -71,6 +71,7 @@ async function main(): Promise<void> {
|
||||
const { ctx } = await application
|
||||
control.updateTasks = installDesktopUpdateTaskControl(ctx)
|
||||
await ctx.plugin(desktopOffice, {
|
||||
runtimeDir,
|
||||
source: process.argv[4] ?? join(runtimeDir, '..', 'runtime', 'primary-runtime'),
|
||||
root: join(resolveDshHome(), 'dsh-runtimes', 'dsh-primary-runtime'),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Desktop Office skills and bundled authoring dependencies. */
|
||||
|
||||
import { dirname, join } from 'node:path'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { runtimeArchivePath } from './office-engine.ts'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import * as officeSkills from '@deepseek-ai/dsh-skill-office'
|
||||
import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
@@ -13,6 +16,8 @@ export interface Config {
|
||||
readonly source: string
|
||||
/** Harness-home directory where workspace dependencies are installed. */
|
||||
readonly root: string
|
||||
/** Prepared or ASAR-contained application dependency directory. */
|
||||
readonly runtimeDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,5 +27,12 @@ export interface Config {
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
await ctx.plugin(workspaceDependencies, config)
|
||||
await ctx.plugin(officeSkills, { assetRoot: join(dirname(config.source), 'office-skills') })
|
||||
const archive = runtimeArchivePath(config.runtimeDir) === undefined ? undefined : dirname(realpathSync(config.runtimeDir))
|
||||
const manifest = fileURLToPath(import.meta.resolve('@deepseek-ai/libreoffice-kit/package.json'))
|
||||
const packageRoot = dirname(archive === undefined ? manifest : join(`${archive}.unpacked`, relative(archive, manifest)))
|
||||
await ctx.plugin(officeSkills, {
|
||||
assetRoot: join(dirname(config.source), 'office-skills'),
|
||||
node: join(config.source, 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node'),
|
||||
cli: join(packageRoot, 'lib', 'cli.js'),
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
@@ -12,12 +12,15 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as desktopOffice from '../src/office.ts'
|
||||
|
||||
it('loads Desktop Office skills without a document renderer and removes them on disposal', async () => {
|
||||
it('loads Desktop Office CLI paths and removes them on disposal', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-office-'))
|
||||
const ctx = new Context()
|
||||
try {
|
||||
const assets = join(root, 'runtime', 'office-skills')
|
||||
await cp(new URL('../../../packages/skill/skill-office/assets/', import.meta.url), assets, { recursive: true })
|
||||
const nodeDirectory = join(root, 'runtime', 'primary-runtime', 'dependencies', 'node', 'bin')
|
||||
await mkdir(nodeDirectory, { recursive: true })
|
||||
await cp(process.execPath, join(nodeDirectory, process.platform === 'win32' ? 'node.exe' : 'node'))
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
@@ -38,13 +41,16 @@ it('loads Desktop Office skills without a document renderer and removes them on
|
||||
await writeFile(config, [
|
||||
'- name: agents', '- name: systemPrompt', '- name: tools', '- name: skills', '- name: office',
|
||||
' config:', ` source: ${JSON.stringify(join(root, 'runtime', 'primary-runtime'))}`,
|
||||
` root: ${JSON.stringify(join(root, 'installed'))}`, '',
|
||||
` root: ${JSON.stringify(join(root, 'installed'))}`,
|
||||
` runtimeDir: ${JSON.stringify(join(root, 'dsh'))}`, '',
|
||||
].join('\n'))
|
||||
await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } })
|
||||
await ctx.loader.await()
|
||||
for (const entry of ctx.loader.entries()) await entry.fiber?.await()
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['office-docx', 'office-pptx', 'office-xlsx'])
|
||||
expect((await ctx.skills.get('office-pptx'))?.resourceBase).toEqual({ kind: 'directory', path: join(assets, 'office-pptx') })
|
||||
expect((await ctx.skills.get('office-pptx'))?.content).toContain('libreofficeKit')
|
||||
expect((await ctx.skills.get('office-pptx'))?.content).toContain(JSON.stringify(nodeDirectory).slice(1, -1))
|
||||
expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['load_workspace_dependencies'])
|
||||
const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'office')
|
||||
expect(entry).toBeDefined()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e
|
||||
README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c
|
||||
README.md: ec3f2f44d5e49db00bd595588a0594bcee0a1538
|
||||
README.zh.md: 50fba340a0edc114a75d4b6363b8e6c42be0c45f
|
||||
@@ -185,9 +185,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
|
||||
|
||||
Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files.
|
||||
|
||||
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
|
||||
The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in).
|
||||
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
|
||||
@@ -399,7 +399,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha
|
||||
|
||||
## Low-level development overrides
|
||||
|
||||
An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
|
||||
An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile.
|
||||
|
||||
## Known limitations
|
||||
|
||||
@@ -408,6 +408,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
|
||||
- Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment.
|
||||
- Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog.
|
||||
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
|
||||
- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime.
|
||||
|
||||
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS
|
||||
|
||||
Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。
|
||||
|
||||
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
|
||||
[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。
|
||||
|
||||
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
|
||||
|
||||
@@ -399,7 +399,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
|
||||
## 底层开发覆盖项
|
||||
|
||||
未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
|
||||
未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。
|
||||
|
||||
## 已知限制
|
||||
|
||||
@@ -408,6 +408,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
|
||||
- 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。
|
||||
- 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。
|
||||
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
|
||||
- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。
|
||||
|
||||
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -36,6 +36,17 @@ export function resolveDesktopBuildTarget(
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
|
||||
|
||||
/**
|
||||
* Return the platform and architecture of the payload one release target prepares.
|
||||
* Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine.
|
||||
* @param target - Supported Desktop target name.
|
||||
* @returns Platform and architecture of the prepared payload.
|
||||
*/
|
||||
export function desktopTargetPlatform(target: DesktopAutoUpdateTarget): {
|
||||
readonly platform: 'darwin' | 'win32'
|
||||
readonly arch: 'arm64' | 'x64'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the paths owned by the target selected in a packaging environment.
|
||||
* @param env - Packaging environment.
|
||||
@@ -48,3 +59,16 @@ export function resolveDesktopTargetBuildPaths(
|
||||
hostPlatform?: NodeJS.Platform,
|
||||
hostArch?: string,
|
||||
): DesktopTargetBuildPaths
|
||||
|
||||
/**
|
||||
* Resolve the primary-runtime directory an unpackaged development launch uses.
|
||||
* @param env - Packaging environment.
|
||||
* @param hostPlatform - Build-host platform used when no target override exists.
|
||||
* @param hostArch - Build-host architecture used when no target override exists.
|
||||
* @returns Primary-runtime directory prepared for the selected target.
|
||||
*/
|
||||
export function developmentRuntimeDirectory(
|
||||
env?: NodeJS.ProcessEnv,
|
||||
hostPlatform?: NodeJS.Platform,
|
||||
hostArch?: string,
|
||||
): string
|
||||
@@ -29,15 +29,19 @@ export function resolveDesktopBuildTarget(
|
||||
return /** @type {'mac-arm64' | 'mac-x64' | 'win-x64'} */ (target)
|
||||
}
|
||||
|
||||
function assertSupportedTarget(target) {
|
||||
if (!SUPPORTED_TARGETS.has(target)) {
|
||||
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the mutable preparation and artifact directories owned by one release target.
|
||||
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
|
||||
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target) {
|
||||
if (!SUPPORTED_TARGETS.has(target)) {
|
||||
throw new Error(`desktop build paths: unsupported target ${String(target)}`)
|
||||
}
|
||||
assertSupportedTarget(target)
|
||||
const root = join(BUILD_ROOT, 'targets', target)
|
||||
const packed = join(root, 'packed')
|
||||
return {
|
||||
@@ -56,6 +60,20 @@ export function desktopTargetBuildPaths(target) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the platform and architecture of the payload one release target prepares.
|
||||
* Windows is prepared as x64 only, so this differs from the build host on an arm64 Windows machine.
|
||||
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
|
||||
* @returns {{ platform: 'darwin' | 'win32', arch: 'arm64' | 'x64' }} Platform and architecture of the prepared payload.
|
||||
*/
|
||||
export function desktopTargetPlatform(target) {
|
||||
assertSupportedTarget(target)
|
||||
return {
|
||||
platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'),
|
||||
arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the paths owned by the target selected in a packaging environment.
|
||||
* @param {NodeJS.ProcessEnv} env - Packaging environment.
|
||||
@@ -70,3 +88,20 @@ export function resolveDesktopTargetBuildPaths(
|
||||
) {
|
||||
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the primary-runtime directory an unpackaged development launch uses.
|
||||
* The build target fixes Windows to x64, so the shell cannot derive this directory from
|
||||
* the architecture of the process that launched it.
|
||||
* @param {NodeJS.ProcessEnv} env - Packaging environment.
|
||||
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no target override exists.
|
||||
* @param {string} hostArch - Build-host architecture used when no target override exists.
|
||||
* @returns {string} Primary-runtime directory prepared for the selected target.
|
||||
*/
|
||||
export function developmentRuntimeDirectory(
|
||||
env = process.env,
|
||||
hostPlatform = process.platform,
|
||||
hostArch = process.arch,
|
||||
) {
|
||||
return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime')
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import type { DesktopRelease } from '../src/release.ts'
|
||||
import { developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
|
||||
import { prepareDevelopmentProject } from './development-project.ts'
|
||||
import { prepareDevelopmentApp } from './development-app.ts'
|
||||
import { preparePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
@@ -67,6 +68,7 @@ async function launchElectron(): Promise<void> {
|
||||
const environment: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
DSH_DESKTOP_PRIMARY_RUNTIME_DIR: process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR ?? developmentRuntimeDirectory(),
|
||||
DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort),
|
||||
DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1',
|
||||
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
|
||||
@@ -115,6 +117,7 @@ async function main(): Promise<void> {
|
||||
hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'),
|
||||
dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'),
|
||||
release,
|
||||
target: resolveDesktopBuildTarget(),
|
||||
})
|
||||
await preparePrimaryRuntime()
|
||||
await launchElectron()
|
||||
|
||||
@@ -16,6 +16,8 @@ import { dirname, join } from 'node:path'
|
||||
import { createDevelopmentProjectMetadata } from '../src/project-manager.ts'
|
||||
import type { DesktopRelease } from '../src/release.ts'
|
||||
import { DESKTOP_RUNTIME_FILE, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
|
||||
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
|
||||
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
|
||||
|
||||
interface PackageManifest {
|
||||
readonly name?: string
|
||||
@@ -35,6 +37,8 @@ export interface DevelopmentProjectOptions {
|
||||
readonly dependencyDir: string
|
||||
/** Release identity written into the disposable project metadata. */
|
||||
readonly release: DesktopRelease
|
||||
/** Build target whose prepared payload the disposable project runs against. */
|
||||
readonly target: DesktopAutoUpdateTarget
|
||||
}
|
||||
|
||||
function readManifest(path: string): PackageManifest {
|
||||
@@ -115,7 +119,7 @@ function mirrorWorkspaceDependencies(roots: readonly string[], destinationRoot:
|
||||
|
||||
/**
|
||||
* Replace one disposable project with links to the current built workspace.
|
||||
* @param options - Project destination, CLI package, and release identity.
|
||||
* @param options - Project destination, CLI package, release identity, and build target.
|
||||
* @returns the absolute project directory supplied by the caller.
|
||||
*/
|
||||
export function prepareDevelopmentProject(options: DevelopmentProjectOptions): string {
|
||||
@@ -159,7 +163,7 @@ export function prepareDevelopmentProject(options: DevelopmentProjectOptions): s
|
||||
return typeof manifest.version === 'string' ? [{ name, version: manifest.version, path: `node_modules/${name}` }] : []
|
||||
})
|
||||
const runtime: DesktopRuntimeDescriptor = { schemaVersion: 1, release: options.release,
|
||||
platform: process.platform, arch: process.arch, sharedPackages, files: [] }
|
||||
...desktopTargetPlatform(options.target), sharedPackages, files: [] }
|
||||
writeFileSync(join(options.projectDir, DESKTOP_RUNTIME_FILE), `${JSON.stringify(runtime, undefined, 2)}\n`)
|
||||
return options.projectDir
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { officePackageDirectories } from '../../../scripts/libreoffice-packages.mjs'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
@@ -163,6 +164,10 @@ export function createElectronBuilderConfig(
|
||||
writeUpdateInfo: false,
|
||||
},
|
||||
beforePack: async context => {
|
||||
const office = await officePackageDirectories(buildPaths.dsh, { platform: resolvedPlatform, arch: resolvedArch })
|
||||
const patterns = office.map(directory => `**/${relative(buildPaths.dsh, directory).split(sep).join('/')}/**/*`)
|
||||
const existing = context.packager.config.asarUnpack ?? []
|
||||
context.packager.config.asarUnpack = [...(typeof existing === 'string' ? [existing] : existing), ...patterns]
|
||||
if (packagesWindows) windowsCode = await prepareWindowsAsarUnpack(context, buildPaths.dsh)
|
||||
if (windowsSigner !== undefined) {
|
||||
primaryRuntimeDestination = join(context.appOutDir, 'resources', 'runtime', 'primary-runtime')
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from './macos-runtime.ts'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts'
|
||||
import { selectOfficeEngine } from '../../../scripts/libreoffice-engine.ts'
|
||||
import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
|
||||
|
||||
@@ -49,11 +49,30 @@ export async function smokeDesktopRuntime(
|
||||
}))
|
||||
writeFileSync(join(plugin, 'index.js'), `
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { inspect } from 'node:util'
|
||||
import { inspect, promisify } from 'node:util'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
export function apply(ctx) {
|
||||
if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance')
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke',
|
||||
handler(_request, response) { response.end('plugin route ready') } }))
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office-cli',
|
||||
async handler(_request, response) {
|
||||
try {
|
||||
const skill = await ctx.skills.get('office-docx')
|
||||
const json = skill?.content.match(/\\n(\\{\\n[\\s\\S]+)$/u)?.[1]
|
||||
if (json === undefined) throw new Error('Office skill did not supply CLI paths')
|
||||
const { libreofficeKit: { node, cli } } = JSON.parse(json)
|
||||
const options = { cwd: ${JSON.stringify(home)}, env: { ...process.env, PATH: '' }, timeout: 120_000 }
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options)
|
||||
const output = ${JSON.stringify(join(home, 'cli.pdf'))}
|
||||
await promisify(execFile)(node, [cli, 'convert', '--input', ${JSON.stringify(join(home, 'input.docx'))}, '--output', output], options)
|
||||
response.end(JSON.stringify({ capabilities: JSON.parse(capabilities.stdout), pdf: (await readFile(output)).toString('base64') }))
|
||||
} catch (error) {
|
||||
response.statusCode = 500
|
||||
response.end(inspect(error, { depth: 5 }))
|
||||
}
|
||||
} }))
|
||||
for (const input of ${JSON.stringify(inputs)}) {
|
||||
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/desktop-smoke-office/' + input.extension,
|
||||
async handler(_request, response) {
|
||||
@@ -71,7 +90,7 @@ export function apply(ctx) {
|
||||
}
|
||||
}
|
||||
`)
|
||||
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf]\n')
|
||||
writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n inject: [webServer, officeToPdf, skills]\n')
|
||||
const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
dsh: { profile: { bundles: string[] } }
|
||||
@@ -103,7 +122,15 @@ export function apply(ctx) {
|
||||
throw new Error(`desktop runtime: invalid ${extension} PDF output`)
|
||||
}
|
||||
}
|
||||
console.log('desktop runtime: DOCX, XLSX, PPTX to PDF passed')
|
||||
const cliResponse = await fetch(new URL('/desktop-smoke-office-cli', ready.url), {
|
||||
headers: { cookie }, signal: AbortSignal.timeout(120_000),
|
||||
})
|
||||
if (!cliResponse.ok) throw new Error(`desktop runtime: skill CLI failed: ${await cliResponse.text()}`)
|
||||
const cliResult = await cliResponse.json() as { capabilities: { runtime: { cliPath: string } }; pdf: string }
|
||||
if (!cliResult.capabilities.runtime.cliPath.endsWith('cli.js') || Buffer.from(cliResult.pdf, 'base64').subarray(0, 5).toString() !== '%PDF-') {
|
||||
throw new Error('desktop runtime: skill CLI did not return capabilities and a PDF')
|
||||
}
|
||||
console.log('desktop runtime: DOCX, XLSX, PPTX to PDF and skill CLI discovery passed')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
await host.stop()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { createDevelopmentProjectMetadata, createPluginProfile } from '../src/project-manager.ts'
|
||||
import { removeOwnedDirectory } from '../src/owned-directory.ts'
|
||||
import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts'
|
||||
import { desktopTargetPlatform, developmentRuntimeDirectory, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
|
||||
|
||||
const repo = resolve(import.meta.dirname, '../../..')
|
||||
const interactive = process.argv.includes('--interactive')
|
||||
@@ -27,10 +28,11 @@ try {
|
||||
nodeVersion: execFileSync(electron, ['-p', 'process.versions.node'],
|
||||
{ encoding: 'utf8', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, windowsHide: true }).trim(),
|
||||
pnpmVersion: pnpm.version, hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }
|
||||
const target = resolveDesktopBuildTarget()
|
||||
createDevelopmentProjectMetadata(project, release)
|
||||
createPluginProfile(profile)
|
||||
await writeFile(join(project, 'desktop-runtime.json'), JSON.stringify({
|
||||
schemaVersion: 1, release, platform: process.platform, arch: process.arch, files: [],
|
||||
schemaVersion: 1, release, ...desktopTargetPlatform(target), files: [],
|
||||
sharedPackages: ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host']
|
||||
.map(name => ({ name, version: manifest.version, path: `node_modules/${name}` })),
|
||||
}))
|
||||
@@ -43,7 +45,6 @@ try {
|
||||
for (const owner of [application, project]) {
|
||||
await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
}
|
||||
const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`
|
||||
const targetRoot = join(application, '.desktop-build/targets', target)
|
||||
await mkdir(targetRoot, { recursive: true })
|
||||
await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'),
|
||||
@@ -61,7 +62,7 @@ try {
|
||||
...(interactive ? ['--interactive'] : [])], {
|
||||
cwd: root, env: { ...environment, DSH_HOME: join(root, 'home'), USERPROFILE: root, HOME: root,
|
||||
TEMP: root, TMP: root, TMPDIR: root, DSH_WORKSPACE_UPDATE_ROOT: root, DSH_WORKSPACE_UPDATE_TOKEN: randomUUID(),
|
||||
DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
|
||||
DSH_DESKTOP_PRIMARY_RUNTIME_DIR: developmentRuntimeDirectory(), DSH_DESKTOP_OPEN_DEVTOOLS: '0' },
|
||||
// Hiding the GUI process suppresses its first window and can suspend renderer frame callbacks.
|
||||
stdio: 'inherit', windowsHide: false,
|
||||
})
|
||||
|
||||
@@ -139,6 +139,14 @@ function runtimeResources(): RuntimeResources {
|
||||
return { node, nodeBin, pnpm, dsh }
|
||||
}
|
||||
|
||||
function developmentPrimaryRuntime(): string {
|
||||
const directory = process.env.DSH_DESKTOP_PRIMARY_RUNTIME_DIR
|
||||
if (directory === undefined || directory === '') {
|
||||
throw new Error('dsh desktop: DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
|
||||
}
|
||||
return directory
|
||||
}
|
||||
|
||||
function developmentHostInspectPort(enabled: boolean): number | undefined {
|
||||
const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT
|
||||
if (!enabled || configured === undefined || configured === '') return undefined
|
||||
@@ -289,6 +297,9 @@ async function main(): Promise<void> {
|
||||
const resources = runtimeResources()
|
||||
const paths = resolveDesktopPaths()
|
||||
const development = !app.isPackaged
|
||||
const primaryRuntime = development
|
||||
? developmentPrimaryRuntime()
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime')
|
||||
const activeProject = paths.profile
|
||||
const manager = new DesktopProjectManager(paths, resources)
|
||||
let quitting = false
|
||||
@@ -370,8 +381,7 @@ async function main(): Promise<void> {
|
||||
const hostInspectPort = developmentHostInspectPort(development)
|
||||
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
|
||||
hostInspectPort, process.env, onFailure,
|
||||
development ? join(app.getAppPath(), '.desktop-build', 'targets', `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}`, 'runtime', 'primary-runtime')
|
||||
: join(process.resourcesPath, 'runtime', 'primary-runtime'),
|
||||
primaryRuntime,
|
||||
resources, (next) => { platformView.setSession(next) })
|
||||
return {
|
||||
start: async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { join, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
desktopTargetBuildPaths,
|
||||
desktopTargetPlatform,
|
||||
developmentRuntimeDirectory,
|
||||
resolveDesktopBuildTarget,
|
||||
} from '../scripts/desktop-build-paths.mjs'
|
||||
|
||||
@@ -39,6 +41,22 @@ describe('desktop build paths', () => {
|
||||
expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
|
||||
})
|
||||
|
||||
it('resolves the development primary runtime from the build target rather than the host architecture', () => {
|
||||
expect(developmentRuntimeDirectory({}, 'darwin', 'arm64'))
|
||||
.toContain(join('targets', 'mac-arm64', 'runtime', 'primary-runtime'))
|
||||
expect(developmentRuntimeDirectory({}, 'darwin', 'x64'))
|
||||
.toContain(join('targets', 'mac-x64', 'runtime', 'primary-runtime'))
|
||||
expect(developmentRuntimeDirectory({}, 'win32', 'arm64'))
|
||||
.toContain(join('targets', 'win-x64', 'runtime', 'primary-runtime'))
|
||||
})
|
||||
|
||||
it('maps every target to the platform and architecture of the payload it prepares', () => {
|
||||
expect(desktopTargetPlatform('mac-arm64')).toEqual({ platform: 'darwin', arch: 'arm64' })
|
||||
expect(desktopTargetPlatform('mac-x64')).toEqual({ platform: 'darwin', arch: 'x64' })
|
||||
expect(desktopTargetPlatform('win-x64')).toEqual({ platform: 'win32', arch: 'x64' })
|
||||
expect(() => desktopTargetPlatform('linux-x64' as 'mac-x64')).toThrow(/unsupported target/u)
|
||||
})
|
||||
|
||||
it('resolves environment overrides and rejects unsupported targets', () => {
|
||||
expect(resolveDesktopBuildTarget({
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'darwin',
|
||||
|
||||
@@ -46,9 +46,10 @@ describe('desktop development project', () => {
|
||||
writeFileSync(join(host, 'lib/index.js'), '')
|
||||
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'unhoisted', version: '1.2.3' }))
|
||||
symlinkSync(dependency, join(cli, 'node_modules/unhoisted'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release() })
|
||||
const project = prepareDevelopmentProject({ projectDir: join(root, 'runtime'), cliDir: cli, hostDir: host, dependencyDir: hoisted, release: release(), target: 'mac-arm64' })
|
||||
expect(realpathSync(join(project, 'node_modules/unhoisted'))).toBe(realpathSync(dependency))
|
||||
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { sharedPackages: unknown[] }
|
||||
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string; sharedPackages: unknown[] }
|
||||
expect(descriptor).toMatchObject({ platform: 'darwin', arch: 'arm64' })
|
||||
expect(descriptor.sharedPackages).toContainEqual({ name: 'unhoisted', version: '1.2.3', path: 'node_modules/unhoisted' })
|
||||
})
|
||||
|
||||
@@ -76,6 +77,7 @@ describe('desktop development project', () => {
|
||||
hostDir: host,
|
||||
dependencyDir: dependencies,
|
||||
release: release(),
|
||||
target: 'win-x64',
|
||||
})
|
||||
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(realpathSync(cli))
|
||||
expect(realpathSync(join(project, 'node_modules', '@deepseek-ai', 'dsh-desktop-host'))).toBe(realpathSync(host))
|
||||
@@ -88,6 +90,8 @@ describe('desktop development project', () => {
|
||||
}
|
||||
expect(manifest.dependencies['@deepseek-ai/dsh']).toBe('1.2.3')
|
||||
expect(manifest.dependencies['@deepseek-ai/dsh-desktop-host']).toBe('1.2.3')
|
||||
const descriptor = JSON.parse(readFileSync(join(project, 'desktop-runtime.json'), 'utf8')) as { platform: string; arch: string }
|
||||
expect(descriptor).toMatchObject({ platform: 'win32', arch: 'x64' })
|
||||
const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, 'home')), {
|
||||
dsh: project,
|
||||
})
|
||||
@@ -115,6 +119,7 @@ describe('desktop development project', () => {
|
||||
hostDir: host,
|
||||
dependencyDir: dependencies,
|
||||
release: release(),
|
||||
target: 'mac-x64',
|
||||
})).toThrow(/must be @deepseek-ai\/dsh@1\.2\.3/u)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import type { WriteStream } from 'node:fs'
|
||||
import { finished } from 'node:stream/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -30,8 +32,9 @@ afterEach(() => { external.archive.mockReset(); external.signature.mockReset();
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const builderRequire = createRequire(require.resolve('app-builder-lib/package.json'))
|
||||
const { createPackageWithOptions } = builderRequire('@electron/asar') as {
|
||||
createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise<void>
|
||||
const { createPackageWithOptions, uncache } = builderRequire('@electron/asar') as {
|
||||
createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise<WriteStream>
|
||||
uncache: (archive: string) => boolean
|
||||
}
|
||||
const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const
|
||||
const publisher = 'CN=Fixture,O=Fixture,C=CN'
|
||||
@@ -45,6 +48,7 @@ async function fixture(body: (context: {
|
||||
resealRuntime: () => Promise<void>
|
||||
}) => Promise<void>, version: string = versions[0]): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-package-content-'))
|
||||
const archive = join(root, 'payload/resources/app.asar')
|
||||
try {
|
||||
const run = await createInstalledUpdateRun(root, versions, { version: '0.1.5-rc.2', commit: 'a'.repeat(40), dirtyFiles: [] })
|
||||
const manifest = join(run.root, 'run.json')
|
||||
@@ -80,14 +84,20 @@ async function fixture(body: (context: {
|
||||
url: `${run.origin}/${run.feedKey.slice(0, -'nightly.yml'.length)}`, publisherName: [publisher],
|
||||
updaterCacheDirName: `dsh-update-test-${run.id}-updater` }))
|
||||
const seal = async () => {
|
||||
await createPackageWithOptions(source, join(payload, 'resources/app.asar'), { unpack: '**/*.exe' })
|
||||
// ASAR 3 returns the output stream after end(), before its writes finish.
|
||||
const output = await createPackageWithOptions(source, archive, { unpack: '**/*.exe' })
|
||||
await finished(output, { cleanup: true })
|
||||
uncache(archive)
|
||||
}
|
||||
await seal()
|
||||
await body({ manifest, source, payload, version, seal, resealRuntime: async () => {
|
||||
reseal(join(source, 'dsh'))
|
||||
await seal()
|
||||
} })
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
} finally {
|
||||
uncache(archive)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe('installed update archive contents', () => {
|
||||
|
||||
@@ -333,6 +333,7 @@ beforeEach(() => {
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm')
|
||||
vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime')
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', 'test-primary-runtime')
|
||||
vi.stubGlobal('process', { ...process, platform: 'win32', arch: 'x64', resourcesPath: 'desktop-test-resources' })
|
||||
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
|
||||
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined)
|
||||
@@ -1621,13 +1622,24 @@ describe('desktop main startup', () => {
|
||||
harness.prepared.resolve()
|
||||
await harness.hostStarted.promise
|
||||
const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project')
|
||||
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project, profile: 'desktop-test-profile' })
|
||||
expect(harness.hosts[0]).toMatchObject({ node: process.execPath, runtime: project,
|
||||
primaryRuntime: 'test-primary-runtime', profile: 'desktop-test-profile' })
|
||||
expect(harness.applyRelease).toHaveBeenCalledOnce()
|
||||
harness.hosts[0]!.ready.resolve()
|
||||
await harness.navigated.promise
|
||||
expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails an unpackaged launch that receives no primary runtime directory', async () => {
|
||||
harness.app.isPackaged = false
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', undefined)
|
||||
await import('../src/main.ts')
|
||||
await harness.dialogShown.promise
|
||||
const options = harness.dialog.showMessageBox.mock.calls[0]![0] as MessageBoxOptions
|
||||
expect(options.detail).toContain('DSH_DESKTOP_PRIMARY_RUNTIME_DIR is required for an unpackaged launch')
|
||||
expect(harness.hosts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps startup errors in the existing window without a retry handler', async () => {
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
|
||||
@@ -98,8 +98,14 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
|
||||
nodeVersion: process.versions.node,
|
||||
hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
|
||||
},
|
||||
// This suite assembles a synthetic project on hosts that prepare no Desktop target, and
|
||||
// nothing it exercises compares the descriptor's platform/arch outside packaging.
|
||||
target: 'mac-x64',
|
||||
})
|
||||
cpSync(join(repository, 'packages/skill/skill-office/assets'), join(root, 'runtime/office-skills'), { recursive: true })
|
||||
const nodeBin = join(root, 'runtime/primary-runtime/dependencies/node/bin')
|
||||
mkdirSync(nodeBin, { recursive: true })
|
||||
cpSync(process.execPath, join(nodeBin, process.platform === 'win32' ? 'node.exe' : 'node'))
|
||||
const paths = resolveDesktopPaths(home)
|
||||
const manager = new DesktopProjectManager(paths, {
|
||||
dsh: project,
|
||||
|
||||
@@ -148,6 +148,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
|
||||
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
|
||||
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
|
||||
vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh')
|
||||
vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', '/runtime/primary-runtime')
|
||||
vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined)
|
||||
vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0')
|
||||
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
|
||||
|
||||
@@ -74,6 +74,16 @@ async function fixture(external: boolean) {
|
||||
}
|
||||
await writeFile(join(source, 'node_modules', 'foo', 'companion.json'), '{}')
|
||||
await writeFile(join(source, 'node_modules', 'foo', '$xarchy.binary'), 'neighbor')
|
||||
for (const [name, manifest] of [
|
||||
['@deepseek-ai/libreoffice-kit', { name: '@deepseek-ai/libreoffice-kit', optionalDependencies: { '@deepseek-ai/libreoffice-kit-win32-x64': '0.0.4' }, dependencies: { 'office-codec': '1' } }],
|
||||
['@deepseek-ai/libreoffice-kit-win32-x64', { name: '@deepseek-ai/libreoffice-kit-win32-x64' }],
|
||||
['office-codec', { name: 'office-codec' }],
|
||||
] as const) {
|
||||
const directory = join(source, 'node_modules', name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'package.json'), JSON.stringify(manifest))
|
||||
await writeFile(join(directory, 'cli.js'), 'export {}')
|
||||
}
|
||||
const config = {
|
||||
files: [{ from: source, to: 'dsh', filter: ['**/*'] },
|
||||
{ from: join(source, 'node_modules'), to: 'dsh/node_modules', filter: ['**/*'] }],
|
||||
@@ -235,6 +245,9 @@ it.each([false, true])('keeps the complete Office engine outside ASAR with exter
|
||||
await packageFixture(input)
|
||||
const archive = await readAsar(join(input.resources, 'app.asar'))
|
||||
expect(archive.getFile(join('dsh', 'node_modules', '@deepseek-ai', 'libreoffice-kit-wasm', 'package.json')).unpacked).not.toBe(true)
|
||||
for (const name of ['@deepseek-ai/libreoffice-kit', 'office-codec']) {
|
||||
expect(archive.getFile(join('dsh', 'node_modules', name, 'cli.js')).unpacked).toBe(true)
|
||||
}
|
||||
for (const file of files) {
|
||||
expect(archive.getFile(join('dsh', engine, file), false).unpacked).toBe(true)
|
||||
expect(await readFile(join(input.resources, 'app.asar.unpacked', 'dsh', engine, file), 'utf8')).toBe('{}')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.1.7-alpha.2",
|
||||
"version": "0.1.7-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||
README.md: 2744c40c7ff6d90e2d1d6cefadfcf6b55acb7e1b
|
||||
README.zh.md: e7dd1a23b96e5b3eff055e49deec8c86d8e91727
|
||||
README.md: 1dce900ab2ffa16850fe50573f9177088c8201db
|
||||
README.zh.md: fd14377bc01897e7d330d36603601f9dce16e295
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
These tests boot the real web composition in-process and drive it with real browsers over real HTTP. Chromium runs the full lane; the [model and reasoning picker scenario](declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
|
||||
After installing workspace dependencies, install the browsers and their system dependencies from the repository root:
|
||||
|
||||
```sh
|
||||
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit
|
||||
```
|
||||
|
||||
On Linux, `--with-deps` installs dependencies through the system package manager. The persistent CI VM must provide these dependencies through image maintenance; CI installs only the browser binaries, as required by the [failover runbook](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md).
|
||||
|
||||
Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios.
|
||||
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
|
||||
这些测试在进程内启动真实的 web 组合,并用真实浏览器通过真实 HTTP 驱动它。Chromium 运行整个 lane;[模型与推理强度选择场景](declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。
|
||||
|
||||
安装工作区依赖后,在仓库根目录安装浏览器及其系统依赖:
|
||||
|
||||
```sh
|
||||
pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit
|
||||
```
|
||||
|
||||
在 Linux 上,`--with-deps` 会通过系统包管理器安装依赖。持久化 CI VM 必须通过镜像维护提供这些依赖,CI 只安装浏览器程序,遵循[故障切换手册](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md)的要求。
|
||||
|
||||
普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** The preset settings page only selects a preset; creating one starts a Creator-mode task. */
|
||||
/** The preset settings page selects a preset and shows what it declares; creating one starts a Creator-mode task. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
@@ -27,13 +27,13 @@ describe('web e2e: preset roster guidance', () => {
|
||||
}, 120_000)
|
||||
afterAll(async () => { await browser?.close(); await scaffold?.close() })
|
||||
|
||||
it('shows the shipped roster with mode help and no editing actions', async () => {
|
||||
it('shows the shipped roster with mode help and a read-only view, and no editing actions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'preset-roster-section'))
|
||||
await expect.poll(() => page.locator('[data-agent-preset-id]').count()).toBe(4)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'section.expected.md'), snapshot, mode)
|
||||
expect(snapshot).toContain('让 Agent 帮我创建预设模式')
|
||||
expect(snapshot).not.toContain('查看配置')
|
||||
expect(snapshot).toContain('查看配置: 标准模式')
|
||||
expect(snapshot).not.toContain('复制预设')
|
||||
expect(snapshot).not.toContain('编辑插件')
|
||||
expect(snapshot).not.toContain('打开目录')
|
||||
@@ -55,6 +55,27 @@ describe('web e2e: preset roster guidance', () => {
|
||||
expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('views a shipped composition read-only', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'preset-roster-view'))
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.getByRole('button', { name: '查看配置: PTC 模式', exact: true }).click()
|
||||
const viewer = page.getByRole('dialog', { name: '查看配置 · PTC 模式', exact: true })
|
||||
await viewer.waitFor({ timeout: 10_000 })
|
||||
// The real shipped declaration, not a golden: the viewer shows whatever
|
||||
// the deployment ships, and this lane only asserts it is shown read-only
|
||||
// in the Loader's own dialect.
|
||||
const shown = await viewer.locator('pre').textContent()
|
||||
expect(shown).toContain("- id: persona\n name: '@deepseek-ai/dsh-persona'\n")
|
||||
expect(shown).toContain('- id: workflow-ptc\n')
|
||||
expect(shown).toContain("disabled: !!js process.platform === 'win32'\n")
|
||||
expect(shown).not.toContain('__jsExpr')
|
||||
expect(await viewer.getByRole('textbox').count()).toBe(0)
|
||||
// The header X and the footer button share the 关闭 name; the footer one is last.
|
||||
await viewer.getByRole('button', { name: '关闭', exact: true }).last().click()
|
||||
await viewer.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('starts a Creator-mode task from the section entry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'preset-roster-creator'))
|
||||
// The entry stages the self-referential preset and lands a new task on it,
|
||||
|
||||
@@ -389,8 +389,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('Minimal mode')
|
||||
expect(snapshot).toContain('button "1 subagent"')
|
||||
// The preset label leads the header band; delegation navigation follows it.
|
||||
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
|
||||
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
|
||||
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"'))
|
||||
// Static chrome, not a control: the header can only report a composition
|
||||
// the host would refuse to change.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Keyless assembled-browser coverage for the opt-in Agent Teams bundle
|
||||
// over the real Host Typert Remote flow.
|
||||
// over the real Host projection frame flow.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-agent-team'
|
||||
@@ -18,6 +18,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url))
|
||||
const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md')
|
||||
const RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url))
|
||||
const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url))
|
||||
const INSTALL_ANCHORS = [
|
||||
@@ -80,26 +81,29 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('displays agent-owned tasks through a read-only board', async () => {
|
||||
it('displays agent-owned task changes through a read-only board without a refresh action', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
|
||||
const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections')
|
||||
onTestFinished(() => { projectionReads.mockRestore() })
|
||||
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
|
||||
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
await action.getByText('No shared tasks yet').waitFor()
|
||||
await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor()
|
||||
await action.getByText('lead').waitFor()
|
||||
expect(projectionReads).not.toHaveBeenCalled()
|
||||
|
||||
expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0)
|
||||
expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0)
|
||||
|
||||
// Host-side Team mutations reach the open panel through projection frames only.
|
||||
const agent = scaffold.ctx.agents.list()[0]!
|
||||
const task = await scaffold.ctx.agentTeams.createTask(agent, {
|
||||
subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'],
|
||||
})
|
||||
await action.getByRole('button', { name: 'Refresh Team' }).click()
|
||||
await action.getByText('Agent task', { exact: true }).waitFor()
|
||||
await action.getByText('Owner: Unowned', { exact: true }).waitFor()
|
||||
await scaffold.ctx.agentTeams.updateTask(agent, {
|
||||
taskId: task.id, expectedRevision: task.revision, action: 'claim',
|
||||
})
|
||||
await action.getByRole('button', { name: 'Refresh Team' }).click()
|
||||
await action.getByText('In progress', { exact: true }).waitFor()
|
||||
await action.getByText('Owner: lead', { exact: true }).waitFor()
|
||||
expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0)
|
||||
@@ -107,9 +111,10 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
|
||||
const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE)
|
||||
expect(projectionReads).not.toHaveBeenCalled()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await action.getByRole('button', { name: 'Close' }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the panel inside the viewport and closes on outside click or Escape', async () => {
|
||||
@@ -131,35 +136,24 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
.every(([x, y]) => element.contains(document.elementFromPoint(x!, y!)))
|
||||
})).toBe(true)
|
||||
}
|
||||
await panel.getByText('Shared tasks', { exact: true }).click()
|
||||
await panel.getByRole('heading', { name: /Shared tasks/u }).click()
|
||||
expect(await panel.count()).toBe(1)
|
||||
await page.mouse.click(2, 2)
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
await page.setViewportSize(viewport)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor()
|
||||
await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor()
|
||||
expect(await panel.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await panel.getByRole('button', { name: 'Refresh Team' })
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await panel.getByRole('button', { name: 'Close', exact: true })
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor()
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Enter')
|
||||
await panel.waitFor()
|
||||
const outside = page.getByRole('button', { name: 'Settings', exact: true })
|
||||
await outside.focus()
|
||||
expect(await panel.count()).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
await panel.waitFor({ state: 'detached' })
|
||||
expect(await outside.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
} finally {
|
||||
@@ -167,7 +161,50 @@ describe('web e2e: Agent Teams panel', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => {
|
||||
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
|
||||
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
await trigger.hover()
|
||||
await panel.waitFor()
|
||||
await panel.hover()
|
||||
await trigger.hover()
|
||||
// The assertion spans the 120ms dismissal grace while the pointer stays on the trigger.
|
||||
await page.waitForTimeout(180)
|
||||
expect(await panel.count()).toBe(1)
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
it('offers expansion when resizing makes a task description exceed two lines', async () => {
|
||||
const agent = scaffold.ctx.agents.list()[0]!
|
||||
await scaffold.ctx.agentTeams.createTask(agent, {
|
||||
subject: 'Responsive task',
|
||||
description: 'Check the task description before you start work. '.repeat(3),
|
||||
writeScopes: [],
|
||||
})
|
||||
const viewport = page.viewportSize()!
|
||||
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
|
||||
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
|
||||
const card = panel.locator('article').filter({ hasText: 'Responsive task' })
|
||||
await trigger.click()
|
||||
await card.waitFor()
|
||||
expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0)
|
||||
try {
|
||||
await page.setViewportSize({ width: 360, height: 700 })
|
||||
await card.getByRole('button', { name: 'Show more' }).waitFor()
|
||||
expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true)
|
||||
await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE)
|
||||
await card.getByRole('button', { name: 'Show more' }).click()
|
||||
expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true)
|
||||
await card.getByRole('button', { name: 'Show less' }).click()
|
||||
await page.setViewportSize(viewport)
|
||||
await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0)
|
||||
} finally {
|
||||
await page.keyboard.press('Escape')
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspaceZh, expandOwningTurnProcess, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
|
||||
const FIXTURE = join(DIR, 'session.v3.jsonl')
|
||||
@@ -62,6 +62,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let cwd: string
|
||||
let replayRoot: string | undefined
|
||||
let releasePreparations: (() => void) | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
let replayOverride: string | undefined
|
||||
@@ -92,6 +93,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
releasePreparations?.()
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
@@ -106,10 +108,45 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
it('records the edited, created, and shell-appended files with their line counts', async () => {
|
||||
if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const preparations = ['edit', 'write'].map(name => ({
|
||||
name, ready: Promise.withResolvers<{ callId: string; kilobytes: number }>(),
|
||||
release: Promise.withResolvers<undefined>(), held: false,
|
||||
}))
|
||||
const names = new Map<string, string>()
|
||||
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
if (chunk.type !== 'tool-call-delta') continue
|
||||
if (chunk.name !== undefined) names.set(chunk.id, chunk.name)
|
||||
const preparation = preparations.find(value => value.name === names.get(chunk.id))
|
||||
if (preparation === undefined || preparation.held || chunk.argumentsDelta.length === 0) continue
|
||||
preparation.held = true
|
||||
preparation.ready.resolve({ callId: chunk.id, kilobytes: Math.ceil(chunk.argumentsDelta.length / 1024) })
|
||||
await preparation.release.promise
|
||||
}
|
||||
}, { prepend: true })
|
||||
releasePreparations = () => {
|
||||
for (const preparation of preparations) preparation.release.resolve(undefined)
|
||||
dispose()
|
||||
}
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const observations = preparations.map(async (preparation) => {
|
||||
const { callId, kilobytes } = await Promise.race([
|
||||
preparation.ready.promise,
|
||||
settled.then(() => { throw new Error(`No ${preparation.name} argument prefix was streamed`) }),
|
||||
])
|
||||
const row = page.locator(`[data-chat-call-id="${callId}"] [data-state="preparing"]`)
|
||||
await row.waitFor({ state: 'attached' })
|
||||
await expandOwningTurnProcess(page, row)
|
||||
await row.getByText(`正在准备内容 ${kilobytes}KB`, { exact: true }).waitFor()
|
||||
expect(await row.getByRole('button').count()).toBe(0)
|
||||
expect(await row.locator('pre').count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(DIR, `preparing-${preparation.name}.expected.md`), await row.ariaSnapshot(), MODE)
|
||||
preparation.release.resolve(undefined)
|
||||
})
|
||||
const [sessionId] = await Promise.all([settled, ...observations]).finally(() => { releasePreparations?.() })
|
||||
const session = scaffold.ctx.agents.get(sessionId)?.session
|
||||
if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
|
||||
cwd = session.header.cwd
|
||||
@@ -343,6 +380,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
for (const element of elements) element.scrollLeft = 0
|
||||
})
|
||||
}
|
||||
const primaryTextColour = await review.evaluate(element => getComputedStyle(element).color)
|
||||
const addedLine = review.locator('[data-diff-side="right"] [data-diff-line="add"]')
|
||||
const rightContextLine = review.locator('[data-diff-side="right"] [data-diff-line="context"]')
|
||||
const addedRule = await addedLine.evaluate((line) => {
|
||||
@@ -351,7 +389,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
if (number === undefined || text === null) throw new Error('added diff line is incomplete')
|
||||
const left = line.getBoundingClientRect().left
|
||||
return {
|
||||
shadow: getComputedStyle(line).boxShadow,
|
||||
shadow: getComputedStyle(number).boxShadow,
|
||||
markerColour: getComputedStyle(number).color,
|
||||
textColour: getComputedStyle(text).color,
|
||||
numberLeft: number.getBoundingClientRect().left - left,
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
@@ -367,7 +406,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
}
|
||||
})
|
||||
expect(addedRule.shadow).toContain(addedRule.textColour)
|
||||
expect(addedRule.shadow).toContain(addedRule.markerColour)
|
||||
expect(addedRule.textColour).toBe(primaryTextColour)
|
||||
expect(addedRule.numberLeft).toBeCloseTo(rightContextRule.numberLeft, 1)
|
||||
expect(addedRule.textLeft).toBeCloseTo(rightContextRule.textLeft, 1)
|
||||
// The ignored file has no snapshot; its comparison comes from the copies captured around the write call.
|
||||
@@ -402,7 +442,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
if (number === undefined || text === null) throw new Error('deleted diff line is incomplete')
|
||||
const left = line.getBoundingClientRect().left
|
||||
return {
|
||||
shadow: getComputedStyle(line).boxShadow,
|
||||
shadow: getComputedStyle(number).boxShadow,
|
||||
markerColour: getComputedStyle(number).color,
|
||||
textColour: getComputedStyle(text).color,
|
||||
numberLeft: number.getBoundingClientRect().left - left,
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
@@ -418,7 +459,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
textLeft: text.getBoundingClientRect().left - left,
|
||||
}
|
||||
})
|
||||
expect(deletedRule.shadow).toContain(deletedRule.textColour)
|
||||
expect(deletedRule.shadow).toContain(deletedRule.markerColour)
|
||||
expect(deletedRule.textColour).toBe(primaryTextColour)
|
||||
expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1)
|
||||
expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1)
|
||||
await compareTool.click()
|
||||
@@ -470,11 +512,17 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
|
||||
await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
|
||||
const wrappedAddition = review.locator('[data-diff-line="del"] > span').nth(1)
|
||||
const wrappedAdditionRule = await wrappedAddition.evaluate((cell) => {
|
||||
const number = cell.children[0]
|
||||
const text = cell.querySelector<HTMLElement>('[data-diff-code]')
|
||||
if (text === null) throw new Error('wrapped addition has no text')
|
||||
return { shadow: getComputedStyle(cell).boxShadow, textColour: getComputedStyle(text).color }
|
||||
if (number === undefined || text === null) throw new Error('wrapped addition is incomplete')
|
||||
return {
|
||||
shadow: getComputedStyle(number).boxShadow,
|
||||
markerColour: getComputedStyle(number).color,
|
||||
textColour: getComputedStyle(text).color,
|
||||
}
|
||||
})
|
||||
expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.textColour)
|
||||
expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.markerColour)
|
||||
expect(wrappedAdditionRule.textColour).toBe(primaryTextColour)
|
||||
// No desktop, so the tools offer the sidebar file but no native open.
|
||||
expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1)
|
||||
expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0)
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { Browser, Page, Request } from 'playwright'
|
||||
import { chromium, webkit } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -19,9 +19,13 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s
|
||||
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/declared-reasoning', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/ui.expected.md', import.meta.url))
|
||||
const POINTER_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/pointer-menu.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
|
||||
describe.skipIf(MODE === 'record').each([
|
||||
{ name: 'Chromium', engine: chromium },
|
||||
{ name: 'WebKit', engine: webkit },
|
||||
])('web e2e: declared reasoning efforts reach the composer ($name)', ({ engine }) => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -39,15 +43,17 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
name: 'Acme Think',
|
||||
models: [
|
||||
{ id: 'acme-think', name: 'Acme Think' },
|
||||
{ id: 'acme-swift', name: 'Acme Swift' },
|
||||
].map(model => ({
|
||||
...model,
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
}],
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
browser = await engine.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -56,12 +62,15 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('offers exactly the declared levels and records the picked one', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
|
||||
onTestFailed(() => saveFailureShot(page, `web-e2e-declared-reasoning-${engine.name()}`))
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
@@ -125,7 +134,117 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('opens from the pointer with keyboard focus and closes from the trigger in every pane', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, `web-e2e-model-trigger-${engine.name()}`))
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const menu = page.getByRole('menu')
|
||||
for (const pane of ['root', 'model', 'effort']) {
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').focus()
|
||||
await trigger.click()
|
||||
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
if (pane === 'root') {
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect.poll(() => page.getByRole('menuitem', { name: /^模型/ })
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
} else {
|
||||
await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click()
|
||||
await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]')
|
||||
.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('selects model and effort by mouse and keeps keyboard control after cancelled or rejected clicks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, `web-e2e-model-pointer-${engine.name()}`))
|
||||
let selections = 0
|
||||
const countSelection = (request: Request): void => {
|
||||
if (new URL(request.url()).pathname.endsWith('/session/selectModel')) selections++
|
||||
}
|
||||
page.on('request', countSelection)
|
||||
onTestFinished(() => { page.off('request', countSelection) })
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
const menu = page.getByRole('menu')
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true })
|
||||
const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true })
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
|
||||
// Native mousedown must not blur the focused row and unmount the menu before click in WebKit.
|
||||
await target.getByText('Acme Swift', { exact: true }).hover()
|
||||
await page.mouse.down()
|
||||
try {
|
||||
await expect.poll(() => menu.count()).toBe(1)
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.getByText('Acme Gateway', { exact: true }).hover()
|
||||
} finally {
|
||||
await page.mouse.up()
|
||||
}
|
||||
// Later selection counts also include any request from this cancelled press.
|
||||
expect(selections).toBe(0)
|
||||
await page.keyboard.press('ArrowDown')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await target.getByText('Acme Swift', { exact: true }).click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
expect(selections).toBe(1)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()?.model).toBe('acme-swift')
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Max', exact: true }).click()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
expect(selections).toBe(2)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()?.reasoningEffort).toBe('max')
|
||||
|
||||
await page.route('**/api/session/selectModel', async (route) => {
|
||||
const envelope = route.request().postDataJSON() as { rpcId: string }
|
||||
await route.fulfill({
|
||||
json: {
|
||||
type: 'server-response', rpcId: envelope.rpcId,
|
||||
result: {
|
||||
ok: false,
|
||||
error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
}, { times: 1 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await current.click()
|
||||
await page.getByRole('alert').waitFor()
|
||||
expect(selections).toBe(3)
|
||||
await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
|
||||
await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('ArrowUp')
|
||||
await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
||||
await page.locator('[data-composer-input][contenteditable="true"]').focus()
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
await trigger.click()
|
||||
await page.mouse.click(0, 0)
|
||||
await menu.waitFor({ state: 'detached' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'pointer-menu.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -9,12 +9,12 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { realOfficeBytes } from './office-fixture.ts'
|
||||
import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
|
||||
import { excelFixture, excelFreezeFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
|
||||
import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-drawing-fixture.ts'
|
||||
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
|
||||
@@ -77,6 +77,18 @@ async function canvasColor(canvas: Locator): Promise<string> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait for device resolution, subject to the page bitmap allocation limit. */
|
||||
async function expectPdfResolution(canvas: Locator): Promise<void> {
|
||||
await expect.poll(() => canvas.evaluate((node) => {
|
||||
const bitmap = node as HTMLCanvasElement
|
||||
const width = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-width'))
|
||||
const height = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-height'))
|
||||
const expected = Math.min(bitmap.getBoundingClientRect().width * window.devicePixelRatio,
|
||||
Math.sqrt(16_777_216 * width / height))
|
||||
return Math.abs(bitmap.width - expected)
|
||||
})).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
/** Select a workspace file through the Files tab and wait for its preview identity. */
|
||||
async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise<void> {
|
||||
await filesTab.click()
|
||||
@@ -104,15 +116,39 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
|
||||
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
|
||||
/** The canvas fills the space between the formula bar and sheet tabs without a statistics row. */
|
||||
async function expectExcelLayout(excel: Locator): Promise<void> {
|
||||
await expect.poll(() => excel.locator('.fortune-stat-area').isVisible()).toBe(false)
|
||||
expect(await excel.locator('.luckysheet-sheets-item-function:visible').count()).toBe(0)
|
||||
await expect.poll(() => excel.evaluate((node) => {
|
||||
const pane = node.getBoundingClientRect()
|
||||
const canvas = node.querySelector('canvas')!.getBoundingClientRect()
|
||||
const formula = node.querySelector('.fortune-workarea')!.getBoundingClientRect()
|
||||
const tabs = node.querySelector('.luckysheet-sheet-area')!.getBoundingClientRect()
|
||||
const scroller = node.querySelector('.fortune-sheettab-container')!.getBoundingClientRect()
|
||||
const controls = [...node.querySelectorAll('.fortune-sheettab-scroll, .fortune-zoom-button')]
|
||||
.map(control => control.getBoundingClientRect())
|
||||
return Math.max(Math.abs(canvas.left - pane.left), Math.abs(canvas.width - pane.width),
|
||||
Math.abs(canvas.top - formula.bottom), Math.abs(canvas.bottom - tabs.top), Math.abs(tabs.bottom - pane.bottom),
|
||||
Math.abs(scroller.left - tabs.left), scroller.right - controls[0]!.left,
|
||||
...controls.map((control, index) => control.right - (controls[index + 1]?.left ?? tabs.right)))
|
||||
})).toBeLessThanOrEqual(1)
|
||||
expect(await excel.locator('.fortune-sheettab-scroll, .fortune-zoom-button').evaluateAll(controls => controls.every((control) => {
|
||||
const rect = control.getBoundingClientRect()
|
||||
return control.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2))
|
||||
}))).toBe(true)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pane with read-only controls in %s', async (locale) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale, timezoneId: 'Asia/Shanghai' })
|
||||
const consoleErrors = watchConsole(page)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${locale}-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await (locale === 'zh-CN' ? connectFreshWorkspaceZh : connectFreshWorkspace)(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
@@ -120,7 +156,14 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
const sessionId = await settled
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
|
||||
const longCellText = 'Monthly export/import matrix and category comparison. 各商品类别分月出口/进口矩阵与结构对比图(公式汇总) '.repeat(3)
|
||||
await Promise.all([
|
||||
writeFile(join(cwd, 'budget.xlsx'), await excelFixture()),
|
||||
writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()),
|
||||
writeFile(join(cwd, 'legacy.xls'), xlsFixture()),
|
||||
writeFile(join(cwd, 'values.csv'), `${longCellText},2\n3,4\n`),
|
||||
writeFile(join(cwd, 'values.tsv'), '1\t2\n3\t4\n'),
|
||||
])
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
@@ -132,6 +175,16 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
|
||||
const formula = excel.locator('.fortune-fx-input')
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
await page.keyboard.press('Shift+ArrowLeft')
|
||||
const selection = excel.locator('.fortune-name-box')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1:B1')
|
||||
await expectExcelLayout(excel)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
await page.keyboard.press('ControlOrMeta+C')
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('42\t2026-09-16')
|
||||
const arrow = excel.locator('.luckysheet-sheets-item-function').first()
|
||||
await arrow.evaluate((node) => { (node as HTMLElement).focus() })
|
||||
expect(await arrow.evaluate(node => document.activeElement === node)).toBe(false)
|
||||
const canvas = excel.locator('canvas').first()
|
||||
const originalCanvas = await canvas.elementHandle()
|
||||
const original = await canvas.boundingBox()
|
||||
@@ -145,17 +198,273 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(page.viewportSize()).toEqual(viewport)
|
||||
await successShot(page, 'excel-resize-wide')
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-resize-wide-${locale}`)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(360)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await excel.locator('.fortune-sheettab-container').evaluate((node) => {
|
||||
const selected = node.querySelector('.luckysheet-sheets-item-active')!.getBoundingClientRect()
|
||||
return selected.right - node.getBoundingClientRect().right
|
||||
})).toBeLessThanOrEqual(1)
|
||||
await page.setViewportSize({ width: 1680, height: 600 })
|
||||
await expect.poll(async () => Math.round(original.height - (await canvas.boundingBox())!.height)).toBe(400)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await selection.innerText()).toBe('A1:B1')
|
||||
await successShot(page, `excel-resize-narrow-${locale}`)
|
||||
await page.setViewportSize(viewport!)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
|
||||
expect(await formula.innerText()).toBe('46281')
|
||||
await successShot(page, 'excel-resize-restored')
|
||||
expect(await selection.innerText()).toBe('A1:B1')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-resize-restored-${locale}`)
|
||||
const zoom = excel.locator('.fortune-zoom-ratio-current')
|
||||
await excel.locator('.fortune-zoom-button').first().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('90%')
|
||||
await excel.locator('.fortune-zoom-button').last().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('100%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('150%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('100%', { exact: true }).click()
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText(locale === 'zh-CN' ? '文件' : 'Files', { exact: true }) })
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
await openPreviewFile(column, filesTab, preview, 'meeting.xlsx')
|
||||
const tabScroller = excel.locator('.fortune-sheettab-container-c')
|
||||
const activeSheet = excel.locator('.luckysheet-sheets-item-active .luckysheet-sheets-item-name')
|
||||
await expect.poll(() => activeSheet.innerText()).toBe('会议信息')
|
||||
const meetingCanvas = await canvas.elementHandle()
|
||||
if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable')
|
||||
await expectExcelLayout(excel)
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 60, y: 40 } })
|
||||
await expect.poll(() => formula.innerText()).toBe('会议纪要')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1')
|
||||
for (const width of [1000, 360, 1000, 360]) {
|
||||
await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width)
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await selection.innerText()).toBe('A1')
|
||||
await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0)
|
||||
}
|
||||
const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)
|
||||
await tabScroller.hover()
|
||||
await page.mouse.wheel(180, 0)
|
||||
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeGreaterThan(0)
|
||||
await page.mouse.wheel(-1000, 0)
|
||||
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBe(0)
|
||||
expect(await activeSheet.innerText()).toBe('会议信息')
|
||||
expect(await selection.innerText()).toBe('A1')
|
||||
expect(await zoom.innerText()).toBe('100%')
|
||||
expect(await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)).toBe(gridOffset)
|
||||
for (const direction of ['right', 'left'] as const) {
|
||||
const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`)
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
const { offset, maximum } = await tabScroller.evaluate(node => ({
|
||||
offset: node.scrollLeft, maximum: node.scrollWidth - node.clientWidth,
|
||||
}))
|
||||
const target = direction === 'right' ? Math.min(offset + 150, maximum) : Math.max(offset - 150, 0)
|
||||
if (Math.abs(target - offset) <= 1) break
|
||||
await arrow.click()
|
||||
await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeCloseTo(target, 0)
|
||||
}
|
||||
const sheetName = direction === 'right' ? '填写说明' : '会议信息'
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText(sheetName, { exact: true }).click()
|
||||
await expect.poll(() => activeSheet.innerText()).toBe(sheetName)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-navigation-${direction}-${locale}`)
|
||||
}
|
||||
expect(await tabScroller.evaluate(node => Math.abs(
|
||||
node.firstElementChild!.getBoundingClientRect().left - node.getBoundingClientRect().left,
|
||||
))).toBeLessThanOrEqual(1)
|
||||
await excel.locator('.fortune-zoom-button').first().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('90%')
|
||||
await excel.locator('.fortune-zoom-button').last().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('100%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('150%')
|
||||
expect(await activeSheet.innerText()).toBe('会议信息')
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-navigation-zoom-${locale}`)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await meetingCanvas.dispose()
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
for (const name of ['budget.xlsx', 'meeting.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) {
|
||||
await openPreviewFile(column, filesTab, preview, name)
|
||||
await excel.locator('.fortune-sheet-overlay').waitFor()
|
||||
await expectExcelLayout(excel)
|
||||
expect(await selection.evaluate(node => ({
|
||||
text: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor,
|
||||
}))).toEqual({ text: 'rgb(0, 0, 0)', background: 'rgb(255, 255, 255)' })
|
||||
if (name === 'values.csv') {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
|
||||
await expect.poll(() => formula.textContent()).toBe(longCellText)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
await expectExcelLayout(excel)
|
||||
await expect.poll(() => formula.evaluate((node) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
return range.getClientRects().length
|
||||
})).toBe(1)
|
||||
expect(await formula.evaluate(node => node.scrollHeight <= node.clientHeight)).toBe(true)
|
||||
expect(await formula.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
await formula.hover()
|
||||
await page.mouse.wheel(400, 0)
|
||||
await expect.poll(() => formula.evaluate(node => node.scrollLeft)).toBeGreaterThan(0)
|
||||
await successShot(page, `excel-formula-single-line-${locale}-${colorScheme}`)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expectExcelLayout(excel)
|
||||
}
|
||||
await successShot(page, `excel-controls-${name}-${locale}-${colorScheme}`)
|
||||
}
|
||||
}
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
await originalCanvas.dispose()
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
/** Pan with native pixel wheel input in every direction and along the sheet edges. */
|
||||
async function expectExcelPanning(page: Page, excel: Locator): Promise<void> {
|
||||
// Chromium's CDP wheel distances scale with the emulated device pixel ratio.
|
||||
const scale = await page.evaluate(() => window.devicePixelRatio)
|
||||
const wheel = async (x: number, y: number) => { await page.mouse.wheel(x * scale, y * scale) }
|
||||
const offset = async () => await excel.evaluate(node => ({
|
||||
x: node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft,
|
||||
y: node.querySelector('.luckysheet-scrollbar-y')!.scrollTop,
|
||||
}))
|
||||
const selection = await excel.locator('.fortune-name-box').innerText()
|
||||
await excel.evaluate((node) => {
|
||||
node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft = 240
|
||||
node.querySelector('.luckysheet-scrollbar-y')!.scrollTop = 240
|
||||
})
|
||||
await expect.poll(offset).toEqual({ x: 240, y: 240 })
|
||||
await excel.locator('.fortune-sheet-overlay').hover({ position: { x: 260, y: 160 } })
|
||||
for (const [dx, dy] of [[37, 19], [-13, 27], [-19, -11], [23, -17], [11, 0], [0, 13], [-9, 0], [0, -7]] as const) {
|
||||
const before = await offset()
|
||||
await wheel(dx, dy)
|
||||
await expect.poll(offset).toEqual({ x: before.x + dx, y: before.y + dy })
|
||||
}
|
||||
expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection)
|
||||
await wheel(-10000, -10000)
|
||||
await expect.poll(offset).toEqual({ x: 0, y: 0 })
|
||||
await wheel(-20, 20)
|
||||
await expect.poll(offset).toEqual({ x: 0, y: 20 })
|
||||
await wheel(20, -20)
|
||||
await expect.poll(offset).toEqual({ x: 20, y: 0 })
|
||||
await wheel(-20, 0)
|
||||
await expect.poll(offset).toEqual({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
/** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */
|
||||
async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise<number> {
|
||||
return await excel.evaluate((node, direction) => {
|
||||
const canvas = node.querySelector('canvas')!
|
||||
const bounds = canvas.getBoundingClientRect()
|
||||
const scale = canvas.width / bounds.width
|
||||
const columnHeader = node.querySelector<HTMLElement>('.fortune-col-header')!
|
||||
const rowHeader = node.querySelector<HTMLElement>('.fortune-row-header')!
|
||||
const columnHandle = node.querySelector<HTMLElement>('.fortune-cols-freeze-handle')!
|
||||
const rowHandle = node.querySelector<HTMLElement>('.fortune-rows-freeze-handle')!
|
||||
// Canvas coordinates include the 1.5px omitted from FortuneSheet's header elements.
|
||||
const x = Number.parseFloat(rowHeader.style.width) + 1.5 + Number.parseFloat(columnHandle.style.left) - columnHeader.scrollLeft
|
||||
const y = Number.parseFloat(columnHeader.style.height) + 1.5 + Number.parseFloat(rowHandle.style.top) - rowHeader.scrollTop - 2
|
||||
const context = canvas.getContext('2d')!
|
||||
let ink = 0
|
||||
for (let offset = -Math.round(scale); offset < 0; offset += 1) {
|
||||
const px = direction === 'x' ? Math.floor(x * scale) + offset : Math.floor((bounds.width - 25) * scale)
|
||||
const py = direction === 'y' ? Math.floor(y * scale) + offset : Math.floor((y + 15) * scale)
|
||||
const pixel = context.getImageData(px, py, 1, 1).data
|
||||
ink += 255 - pixel[0]!
|
||||
}
|
||||
return ink / scale
|
||||
}, axis)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without dividers at DPR %i', async (deviceScaleFactor) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor, locale: 'en-US' })
|
||||
const consoleErrors = watchConsole(page)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-freeze-${deviceScaleFactor}-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await page.locator('[data-composer-input]').first().fill(PROMPT)
|
||||
await page.locator('[data-composer-input]').first().press('Enter')
|
||||
const cwd = scaffold.ctx.agents.get(await settled)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'freeze.xlsx'), await excelFreezeFixture())
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'freeze.xlsx', exact: true }).click()
|
||||
const excel = column.locator('[data-excel-preview]')
|
||||
const selectTopLeft = async () => {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 80, y: 35 } })
|
||||
return await excel.locator('.fortune-fx-input').innerText()
|
||||
}
|
||||
for (const sheet of ['Both', 'Rows', 'Columns', 'None']) {
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText(sheet, { exact: true }).click()
|
||||
await expect.poll(selectTopLeft).toBe('R1C1')
|
||||
for (const handle of ['.fortune-cols-freeze-handle', '.fortune-rows-freeze-handle']) {
|
||||
expect(await excel.locator(handle).isVisible()).toBe(false)
|
||||
expect(await excel.locator(handle).evaluate(node => node.getClientRects().length)).toBe(0)
|
||||
}
|
||||
for (const ratio of ['100%', '150%']) {
|
||||
await excel.locator('.fortune-zoom-ratio-current').click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText(ratio, { exact: true }).click()
|
||||
await expect.poll(() => excel.locator('.fortune-zoom-ratio-current').innerText()).toBe(ratio)
|
||||
await excel.locator('.fortune-fx-input').click()
|
||||
for (const axis of ['x', 'y'] as const) {
|
||||
if (sheet === 'Both' || sheet === (axis === 'x' ? 'Columns' : 'Rows')) {
|
||||
await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0)
|
||||
}
|
||||
}
|
||||
await expectExcelPanning(page, excel)
|
||||
await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 })
|
||||
await excel.locator('.luckysheet-scrollbar-y').evaluate((node) => { node.scrollTop = 240 })
|
||||
await expect.poll(async () => {
|
||||
const value = await selectTopLeft()
|
||||
const match = /^R(\d+)C(\d+)$/.exec(value)
|
||||
return match && { rowFixed: match[1] === '1', columnFixed: match[2] === '1' }
|
||||
}).toEqual({ rowFixed: sheet === 'Both' || sheet === 'Rows', columnFixed: sheet === 'Both' || sheet === 'Columns' })
|
||||
await successShot(page, `excel-freeze-scrolled-${sheet}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText('Both', { exact: true }).click()
|
||||
await expect.poll(selectTopLeft).toBe('R1C1')
|
||||
const canvas = await excel.locator('canvas').first().elementHandle()
|
||||
const selection = await excel.locator('.fortune-name-box').innerText()
|
||||
const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-freeze-narrow-${colorScheme}-${deviceScaleFactor}`)
|
||||
}
|
||||
await layout.evaluate(node => node.parentNode!.removeChild(node))
|
||||
await expectExcelLayout(excel)
|
||||
expect(await canvas!.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection)
|
||||
await canvas!.dispose()
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -557,10 +866,12 @@ else process.exit(1);
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1)
|
||||
await expectPdfResolution(canvas)
|
||||
pdfZoom = await revealDocumentZoom(page, preview)
|
||||
await pdfZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfWidth, 0)
|
||||
await expectPdfResolution(canvas)
|
||||
expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
|
||||
await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
|
||||
const firstColor = await canvasColor(canvas)
|
||||
@@ -595,6 +906,7 @@ else process.exit(1);
|
||||
`- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
|
||||
'- Zoom reveal: hidden -> bottom hover -> delayed hidden',
|
||||
'- Zoom modes: fit width -> 100% -> 150% -> fit width',
|
||||
'- Settled zoom redraws the page at device resolution',
|
||||
`- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
|
||||
`- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
|
||||
`- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
|
||||
@@ -1103,7 +1415,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor: 2,
|
||||
locale: 'en-US', timezoneId: 'Asia/Shanghai' })
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
@@ -1179,6 +1492,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('150%')
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.5, 1)
|
||||
await expectPdfResolution(canvas)
|
||||
const zoomScrollport = preview.locator('[data-document-zoom-scrollport]')
|
||||
await zoomScrollport.evaluate((node) => {
|
||||
const bounds = node.getBoundingClientRect()
|
||||
@@ -1187,10 +1501,31 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
})
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('166%')
|
||||
await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.66, 1)
|
||||
await expectPdfResolution(canvas)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
await copyPdfText(page, preview, '中文文档')
|
||||
await successShot(page, 'office-zoom-redrawn')
|
||||
await zoomMenu.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent)
|
||||
await expectPdfResolution(canvas)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
await zoomScrollport.evaluate(async (node) => {
|
||||
const bounds = node.getBoundingClientRect()
|
||||
for (let step = 0; step < 8; step++) {
|
||||
node.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
|
||||
deltaY: -40, clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 }))
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
}
|
||||
})
|
||||
await expect.poll(() => zoomMenu.innerText()).toBe('400%')
|
||||
expect(await zoomScrollport.evaluate(node => node.scrollLeft > node.clientWidth)).toBe(true)
|
||||
await expectPdfResolution(canvas)
|
||||
await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 })
|
||||
await successShot(page, 'office-pinch-400')
|
||||
await zoomMenu.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expectPdfResolution(canvas)
|
||||
expect(convert).toHaveBeenCalledTimes(1)
|
||||
await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
|
||||
await canvas.waitFor({ state: 'visible' })
|
||||
@@ -1243,6 +1578,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
'- Escape restores focus to the warning: true',
|
||||
'- Closing details preserves the warning and document position: true',
|
||||
'- Fit width, presets, and pinch resize the Office PDF continuously: true',
|
||||
'- Settled zoom redraws the Office PDF at device resolution: true',
|
||||
'- Continuous pinch to 400% redraws the page after horizontal panning: true',
|
||||
'- Pinch updates the displayed percentage during the gesture: 166%',
|
||||
`- Document top inset: ${topInset}px`,
|
||||
].join('\n'), MODE)
|
||||
@@ -1251,6 +1588,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
|
||||
await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
|
||||
const officeZoom = await revealDocumentZoom(page, preview)
|
||||
await officeZoom.click()
|
||||
await page.getByRole('menuitem', { name: '150%', exact: true }).click()
|
||||
await expectPdfResolution(canvas)
|
||||
if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0)
|
||||
await successShot(page, `office-${extension}`)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
|
||||
- text: 新任务可选择模式 Beta
|
||||
- text: 新任务可选择模式 实验性
|
||||
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
|
||||
- switch "新任务可选择模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
@@ -21,6 +21,7 @@
|
||||
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
|
||||
- 'button "模式说明: 标准模式"': 模式说明
|
||||
- 'button "如何使用: 标准模式"': 如何使用
|
||||
- 'button "查看配置: 标准模式"': 查看配置
|
||||
- listitem:
|
||||
- 'button "设为新任务默认: PTC 模式"':
|
||||
- text: PTC 模式 内置
|
||||
@@ -28,6 +29,7 @@
|
||||
- text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。
|
||||
- 'button "模式说明: PTC 模式"': 模式说明
|
||||
- 'button "如何使用: PTC 模式"': 如何使用
|
||||
- 'button "查看配置: PTC 模式"': 查看配置
|
||||
- listitem:
|
||||
- 'button "设为新任务默认: 极简模式"':
|
||||
- text: 极简模式 内置
|
||||
@@ -35,6 +37,7 @@
|
||||
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
|
||||
- 'button "模式说明: 极简模式"': 模式说明
|
||||
- 'button "如何使用: 极简模式"': 如何使用
|
||||
- 'button "查看配置: 极简模式"': 查看配置
|
||||
- listitem:
|
||||
- 'button "设为新任务默认: 创造模式"':
|
||||
- text: 创造模式 内置
|
||||
@@ -42,5 +45,6 @@
|
||||
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
|
||||
- 'button "模式说明: 创造模式"': 模式说明
|
||||
- 'button "如何使用: 创造模式"': 如何使用
|
||||
- 'button "查看配置: 创造模式"': 查看配置
|
||||
- heading "自定义" [level=3]
|
||||
- button "让 Agent 帮我创建预设模式"
|
||||
@@ -1,5 +1,5 @@
|
||||
- navigation "Session hierarchy": Seeded turn
|
||||
- text: Minimal mode
|
||||
- button "1 subagent"
|
||||
- text: Minimal mode
|
||||
- button "More actions"
|
||||
- button "Open right sidebar"
|
||||
@@ -9,7 +9,7 @@
|
||||
- button "Copy"
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Edited files, called tools, searched code, etc." [expanded]
|
||||
- button "Wrote files, called tools, searched code, etc." [expanded]
|
||||
- button "Write site/report.html +1 -0":
|
||||
- text: Write
|
||||
- button "site/report.html"
|
||||
@@ -137,7 +137,8 @@
|
||||
- code: notes.md
|
||||
- text: untouched.
|
||||
- paragraph:
|
||||
- img "Token preview"
|
||||
- 'button "View full image: Token preview"':
|
||||
- img "Token preview"
|
||||
- text: css
|
||||
- button "Wrap lines" [pressed]
|
||||
- button "Copy"
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
- status: Worked
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Called tools" [expanded]
|
||||
- button "Think"
|
||||
- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package."
|
||||
- button "Inspect plugins 0 dynamic plugins"
|
||||
- button "Think"
|
||||
- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package."
|
||||
- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]
|
||||
- tablist "Plugin source":
|
||||
- tab "Client"
|
||||
@@ -28,11 +28,11 @@
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
|
||||
- button "Inspect"
|
||||
- button "Think"
|
||||
- button "Think The Host returned snap-1/pkg-1, so I will request its first activation."
|
||||
- text: Run Cordis Plugin snap-1 · pkg-1 Ready
|
||||
- button "Inspect"
|
||||
- text: snap-1/pkg-1 is awaiting user approval (run-1).
|
||||
- button "Think"
|
||||
- button "Think The activation request has been submitted, so I will return the requested readiness marker."
|
||||
- paragraph: CORDIS_UI_READY
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
- group "Acme Gateway":
|
||||
- text: Acme Gateway
|
||||
- menuitemradio "Acme Think"
|
||||
- menuitemradio "Acme Swift" [checked]
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: 字号大小 仅影响会话内容的字号 14
|
||||
- button "增大字号"
|
||||
- button "减小字号"
|
||||
- text: px 工作过程展示 控制轮次和步骤的默认展开方式
|
||||
- button "简洁"
|
||||
- text: px 工作步骤展示 选择希望看到多少工具调用细节
|
||||
- button "标准"
|
||||
- text: 性能与用量 选择性能与用量信息展示的详细程度
|
||||
- button "详细"
|
||||
- text: 开发者工具 显示用于调试和排查问题的工具与信息
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
- text: Font size Only affects conversation content 14
|
||||
- button "Increase font size"
|
||||
- button "Decrease font size"
|
||||
- text: px Work details Controls how turns and steps expand by default
|
||||
- button "Compact"
|
||||
- text: px Work details Choose how much detail to show for tool calls
|
||||
- button "Standard"
|
||||
- text: Performance & usage Choose how much performance and usage information to show
|
||||
- button "Detailed"
|
||||
- text: Developer tools Show tools and information for debugging and troubleshooting
|
||||
|
||||
@@ -11,15 +11,28 @@
|
||||
- button "Took {{duration}}" [disabled]
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
- paragraph: Local test image
|
||||
- 'button "View full image: Remote test image"':
|
||||
- img "Remote test image"
|
||||
- paragraph:
|
||||
- img "Workspace test image"
|
||||
- paragraph: Oversized image
|
||||
- 'button "View full image: Local test image"':
|
||||
- img "Local test image"
|
||||
- paragraph:
|
||||
- img "Outside workspace image"
|
||||
- paragraph: Missing image
|
||||
- paragraph: {{cwd}}/corrupt.png
|
||||
- 'button "View full image: Workspace test image"':
|
||||
- img "Workspace test image"
|
||||
- paragraph:
|
||||
- button "View comparison"
|
||||
- paragraph:
|
||||
- 'button "View full image: Space path"':
|
||||
- img "Space path"
|
||||
- paragraph:
|
||||
- 'button "View full image: Encoded path"':
|
||||
- img "Encoded path"
|
||||
- paragraph: Image preview unavailable · Oversized image
|
||||
- paragraph:
|
||||
- 'button "View full image: Outside workspace image"':
|
||||
- img "Outside workspace image"
|
||||
- paragraph: Image preview unavailable · Missing image
|
||||
- paragraph: Image preview unavailable · {{cwd}}/corrupt.png
|
||||
- paragraph: REMOTE_IMAGE_DONE
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
@@ -7,5 +7,7 @@
|
||||
- Escape restores focus to the warning: true
|
||||
- Closing details preserves the warning and document position: true
|
||||
- Fit width, presets, and pinch resize the Office PDF continuously: true
|
||||
- Settled zoom redraws the Office PDF at device resolution: true
|
||||
- Continuous pinch to 400% redraws the page after horizontal panning: true
|
||||
- Pinch updates the displayed percentage during the gesture: 166%
|
||||
- Document top inset: 0px
|
||||
@@ -7,11 +7,11 @@
|
||||
- list:
|
||||
- listitem:
|
||||
- button "查看 智能体团队": 智能体团队
|
||||
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
|
||||
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
|
||||
- switch "启用 智能体团队"
|
||||
- listitem:
|
||||
- button "查看 语音输入": 语音输入
|
||||
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
|
||||
- switch "启用 语音输入"
|
||||
- listitem:
|
||||
- button "查看 终端": 终端
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- dialog "无法访问 GitHub":
|
||||
- heading "无法访问 GitHub" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: 请尝试其他安装来源。
|
||||
- button "取消"
|
||||
- button "改用国内镜像"
|
||||
@@ -0,0 +1,8 @@
|
||||
- dialog "添加插件":
|
||||
- heading "添加插件" [level=2]
|
||||
- button "关闭"
|
||||
- textbox "插件包名":
|
||||
- /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review
|
||||
- button "插件安装引导和示例"
|
||||
- button "安装源 中国大陆镜像源"
|
||||
- button "安装" [disabled]
|
||||
Loaded 100 of 933 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user