diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index bb7be001de..56f2e6e156 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index bb1d68cc04..c8773b77fe 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 6291211d58..076ad35178 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -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 保留实现后仍有价值的方案推导、逐业务适配、职责、算法和取舍。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index a5b7444619..e1a4357fcd 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md -2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343 -2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c +2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8 +2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index 451b1f316e..68bd519b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -14,7 +14,7 @@ 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. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index 0c22869582..f3cbefedeb 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -14,7 +14,7 @@ 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'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml index d5903d0fc2..9a6d0df300 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-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 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md index 0f1f6a575f..ea8e8cebcc 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -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` 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. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index d3982aa433..2e119bf054 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -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`。** 每一处都是特殊逻辑: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` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml new file mode 100644 index 0000000000..bfbfe3cdf3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md +2026-09-22-tool-call-three-phases.md: e7b42a4ed9e340b73257f7053bddd5221ccc0d20 +2026-09-22-tool-call-three-phases.zh.md: 5a68575fd8e2a68199cbedf1ac180a22254b7c54 diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md new file mode 100644 index 0000000000..e7b42a4ed9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md @@ -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 argument body, and one non-expandable row. | +| `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. + +When stream settlement removes temporary deltas, the Assembler selects the start from the remaining Matches, refreshes predecessor indexes, and recomputes State. A published preparation node hides when no call evidence remains; a later dispatch retains the same callId. Undispatched calls do not receive 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 arguments or result, and the shared argument parser returns null without parsing JSON. 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. +- Shared rows do not replace the whole row component when preparation becomes dispatch. Custom rows may still switch their internal components by stage. +- Partial JSON parsing and `useToolCallDelta` are not provided; argument-driven early presentation is outside this implementation. +- 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. diff --git a/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md new file mode 100644 index 0000000000..5a68575fd8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md @@ -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,不增加角色、注册表或旁路缓存。 + +流结束移除临时 delta 后,Assembler 从剩余 Match 重新选择 start,刷新前序索引并重算 State。没有剩余调用证据时,已发布的准备节点隐藏;后续正式调用仍使用相同 callId。未派发的调用不合成执行结果。 + +### 最终状态收敛,不重现准备过程 + +历史分页不把已结束 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;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 报错检测身份复用。 +- 通用行不因准备/派发分支替换整行组件;自定义外壳仍可按阶段切换内部组件。 +- 不提供部分 JSON 解析或 `useToolCallDelta`,参数驱动的提前展示不属于本次实现。 +- 组装测试分别覆盖实时准备态、撤回、活跃流重建、持久历史与分页收敛;组件测试检查无参数展示和阶段切换,录制会话覆盖浏览器中的准备态与重载结果。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml new file mode 100644 index 0000000000..727ff382c0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md +2026-09-23-bounded-pnpm-runs.md: 7ce6b1776e4587240072201781c9da5bc78650e8 +2026-09-23-bounded-pnpm-runs.zh.md: 1cb91132cca22a1130fdc48ce3d3dbd354fa5d16 diff --git a/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md new file mode 100644 index 0000000000..7ce6b1776e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md new file mode 100644 index 0000000000..1cb91132cc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md @@ -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 池收尾,它就不再必要。 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f4335f78bb..8b3bfc1400 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 7e395f72d5..430fd91f46 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c -README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec +README.md: 765440438175083d2dbd765592731df5ec148bcf +README.zh.md: 08db105b160199cb9a64276f273b8b00f0d21c09 diff --git a/apps/cli/README.md b/apps/cli/README.md index 4eb113a615..7654404381 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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` diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 6a0e8a38a7..08db105b16 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -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` diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index e52c7c8202..20f5867e23 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -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 { - const version = readVersion() + const version = getDshRuntimeVersion() const invocation = parseDshArgs(process.argv.slice(2), version) switch (invocation.mode) { diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 12a0dbc443..a246af7573 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -1,19 +1,76 @@ -/** 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 { + 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} --dsh-version ${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 { + 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') diff --git a/apps/cli/tests/plugin-compatibility.expected.e2e.ts b/apps/cli/tests/plugin-compatibility.expected.e2e.ts new file mode 100644 index 0000000000..a002bb2514 --- /dev/null +++ b/apps/cli/tests/plugin-compatibility.expected.e2e.ts @@ -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', 'compatibility-fixture@1.0.0', '--dsh-version', version]) + expect(missingConsent.exitCode).toBe(1) + const granted = await plugin(['allow-version', 'compatibility-fixture@1.0.0', '--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({ 'compatibility-fixture@1.0.0': [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', 'compatibility-fixture@1.0.0', '--dsh-version', version])).exitCode).toBe(0) + expect((await run(['--profile', 'compatibility'])).stdout).not.toContain('PLUGIN_IMPORTED') +}) diff --git a/apps/cli/tests/plugin.spec.ts b/apps/cli/tests/plugin.spec.ts new file mode 100644 index 0000000000..f3163d5d3d --- /dev/null +++ b/apps/cli/tests/plugin.spec.ts @@ -0,0 +1,91 @@ +/** 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(), + 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/plugin@1.2.3', '--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/plugin@1.2.3': [runtime] }) + expect(JSON.parse(readFileSync(join(dir, 'compatibility.json'), 'utf8'))).toEqual({ '@example/plugin@1.2.3': [runtime] }) + expect(await runPlugin('test', ['version-exemptions'])).toBe(0) + expect(stdout).toHaveBeenLastCalledWith(JSON.stringify({ '@example/plugin@1.2.3': [runtime] }, undefined, 2) + '\n') + expect(await runPlugin('test', ['revoke-version', '@example/plugin@1.2.3', `--dsh-version=${runtime}`])).toBe(0) + expect(readProfileVersionExemptions(dir)).toEqual({}) + expect(runPluginCommand).not.toHaveBeenCalled() +}) + +it.each([ + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT'], + ['allow-version', 'plugin@1.2.3', '--accept-risk'], + ['allow-version', 'plugin@^1.2.3', '--dsh-version', 'CURRENT', '--accept-risk'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', '999.0.0', '--accept-risk'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT', '--accept-risk=false'], + ['allow-version', 'plugin@1.2.3', '--dsh-version', 'CURRENT', '--accept-risk', '--accept-risk'], + ['revoke-version', 'plugin@1.2.3'], + ['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: pnpm 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') +}) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index e4a615b8bc..56dec2af4b 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -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 diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts new file mode 100644 index 0000000000..359e559241 --- /dev/null +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -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(''), + '_rels/.rels': strToU8(''), + 'word/document.xml': strToU8('Office CLI discovery'), + })) + 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[] = [] + 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 + 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 }) + } +}) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 5d9628bdee..eea02dc7ce 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -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:*", diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 209882013e..ecbff835ce 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -71,6 +71,7 @@ async function main(): Promise { 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'), }) diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 8420aa0feb..13941060ef 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -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 { 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'), + }) } diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index f26b5fc6e0..862f832029 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -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() diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 6972af5326..6a224c8ba2 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 9bed6eb21646a4021e45320281f82f93c5c22179 -README.zh.md: 67a6204ffb62b8e509a301c966a0bccfa25f8465 +README.md: fb83b0fc66c281a596d743b561e0ee33f32b2c04 +README.zh.md: 490eff4b35a3e7b52108ff529ed9d3b432f68bb3 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 9bed6eb216..fb83b0fc66 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -189,9 +189,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. @@ -403,7 +403,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 @@ -412,6 +412,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. Successful browser sign-in switches Welcome to the workspace without activating the application; the completion page’s dsh://open link brings it to the foreground. 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 67a6204ffb..490eff4b35 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -189,7 +189,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 构建在公证前执行这些检查。 @@ -403,7 +403,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。 ## 已知限制 @@ -412,6 +412,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 时,退出后返回欢迎窗。浏览器登录成功后,Welcome 切换到工作区但不激活应用;完成页的 dsh://open 链接负责将客户端置前。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index b26c633d9c..9a8e408ed1 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -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 diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 841da2c4ba..4f33e804c3 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -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') +} diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 74840eae03..fdd724e579 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -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 { 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 { hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), release, + target: resolveDesktopBuildTarget(), }) await preparePrimaryRuntime() await launchElectron() diff --git a/apps/desktop/scripts/development-project.ts b/apps/desktop/scripts/development-project.ts index 36c70baff0..d544ce6d36 100644 --- a/apps/desktop/scripts/development-project.ts +++ b/apps/desktop/scripts/development-project.ts @@ -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 } diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 2cee72d23d..e0a707dc32 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -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') diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index a89247ad18..377c300efb 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -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() diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts index 19b26fac1a..f15f10a928 100644 --- a/apps/desktop/scripts/smoke-runtime.ts +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -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 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() diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index 669d572b9d..41f40caa09 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -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, }) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2cc70495a3..2d9b8d4e32 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 { 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 @@ -371,8 +382,7 @@ async function main(): Promise { 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 () => { diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index a163885f6d..a5e9faf296 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -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', diff --git a/apps/desktop/tests/development-project.spec.ts b/apps/desktop/tests/development-project.spec.ts index 07e0a0a5ca..d08b82d3ac 100644 --- a/apps/desktop/tests/development-project.spec.ts +++ b/apps/desktop/tests/development-project.spec.ts @@ -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) }) }) diff --git a/apps/desktop/tests/installed-update-package-content.spec.ts b/apps/desktop/tests/installed-update-package-content.spec.ts index 8ecfeb9f39..76aea94736 100644 --- a/apps/desktop/tests/installed-update-package-content.spec.ts +++ b/apps/desktop/tests/installed-update-package-content.spec.ts @@ -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 +const { createPackageWithOptions, uncache } = builderRequire('@electron/asar') as { + createPackageWithOptions: (source: string, destination: string, options: { unpack: string }) => Promise + 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 }) => Promise, version: string = versions[0]): Promise { 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', () => { diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 0242d214a3..9cd00f74c4 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -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 diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index a9bb43c8f1..9b3dd0f2ed 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -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, diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index b2d18224d4..ef5f2f7fc2 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -166,6 +166,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) diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index 1be1eb6266..2fe4b3aaf2 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -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('{}') diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 301b268177..5ab43f206b 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -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, diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 8365c34f45..321d21fe3b 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -343,6 +343,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 +352,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 +369,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 +405,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 +422,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 +475,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('[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) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index a43b63099c..b48e7ec23b 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -77,6 +77,18 @@ async function canvasColor(canvas: Locator): Promise { }) } +/** Wait for device resolution, subject to the page bitmap allocation limit. */ +async function expectPdfResolution(canvas: Locator): Promise { + 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 { await filesTab.click() @@ -113,9 +125,18 @@ async function expectExcelLayout(excel: Locator): Promise { 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(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) => { @@ -138,6 +159,7 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan 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'), @@ -210,13 +232,76 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan 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', 'legacy.xls', 'values.csv', 'values.tsv']) { + 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) @@ -246,6 +331,38 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan } }) +/** Pan with native pixel wheel input in every direction and along the sheet edges. */ +async function expectExcelPanning(page: Page, excel: Locator): Promise { + // 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 { return await excel.evaluate((node, direction) => { @@ -314,6 +431,7 @@ it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without divider 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 }) @@ -748,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) @@ -786,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)}`, @@ -1294,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) @@ -1370,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() @@ -1378,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(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' }) @@ -1434,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) @@ -1442,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}`) } diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index c1d3acc103..4ce3d29216 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -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 帮我创建预设模式" diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index ab3230e5bd..25c6502dad 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -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" diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 199f2afb89..31dee5c21a 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -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" diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md index 7e2deb69b1..6d9ba31361 100644 --- a/apps/web/tests/expected/office-font-notice.md +++ b/apps/web/tests/expected/office-font-notice.md @@ -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 diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index f4bdf601f8..62c89bd029 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index b8f18c6c67..bc24459a1e 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -1,5 +1,5 @@ // Real browser image loading and failure fallbacks through the shipped Web composition. -import { open, writeFile } from 'node:fs/promises' +import { mkdir, open, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -112,6 +112,12 @@ function markdownImageFixture(remoteUrl: string, outsidePath: string): string { '', `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`, '', + '[View comparison](./valid.png)', + '', + '![Space path]({{cwd}}/test workspace/测试图.png)', + '', + '![Encoded path]({{cwd}}/test%20workspace/测试图.png)', + '', '![Oversized image]({{cwd}}/oversized.png)', '', `![Outside workspace image](${outsidePath})`, @@ -164,6 +170,9 @@ describe('web e2e: Markdown image rendering', () => { imageOrigin = await startImageOrigin() scaffold = await launchWebScaffold({}) await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG) + await writeFile(join(scaffold.workspaceCwd, 'local-image.png'), PNG) + await mkdir(join(scaffold.workspaceCwd, 'test workspace')) + await writeFile(join(scaffold.workspaceCwd, 'test workspace/测试图.png'), PNG) await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') try { @@ -237,11 +246,13 @@ describe('web e2e: Markdown image rendering', () => { borderRadius: '8px', decoding: 'async', loading: 'lazy', - maxWidth: '100%', + maxWidth: 'min(100%, 640px)', referrerPolicy: 'no-referrer', }) - expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) - expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + await expect.poll(() => page.getByRole('img', { name: LOCAL_ALT }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + for (const name of ['Space path', 'Encoded path']) { + await expect.poll(() => page.getByRole('img', { name, exact: true }).evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + } expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT }) @@ -253,23 +264,59 @@ describe('web e2e: Markdown image rendering', () => { const outsideImage = page.getByRole('img', { name: 'Outside workspace image' }) await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) for (const alt of ['Oversized image', 'Missing image']) { - await page.getByText(alt, { exact: true }).waitFor() + await page.getByText(`Image preview unavailable · ${alt}`, { exact: true }).waitFor() expect(await page.getByRole('img', { name: alt }).count()).toBe(0) } - await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor() + await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor() expect(mediaResponses).toEqual(new Map([ [join(scaffold.workspaceCwd, 'valid.png'), 200], + [`${scaffold.workspaceCwd}/./local-image.png`, 200], + [join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200], [join(scaffold.workspaceCwd, 'oversized.png'), 413], [join(scaffold.persistenceRoot, 'outside.png'), 200], [join(scaffold.workspaceCwd, 'missing.png'), 404], [join(scaffold.workspaceCwd, 'corrupt.png'), 200], ])) + await page.getByRole('button', { name: `View full image: ${WORKSPACE_ALT}`, exact: true }).click() + const lightbox = page.getByRole('dialog', { name: 'Image preview', exact: true }) + await lightbox.waitFor() + await lightbox.getByRole('button', { name: 'Close image preview' }).click() + await lightbox.waitFor({ state: 'detached' }) + const link = page.getByRole('button', { name: 'View comparison', exact: true }) + await link.hover() + const hoverImage = page.getByRole('img', { name: './valid.png', exact: true }) + await hoverImage.waitFor() + await expect.poll(() => hoverImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + await page.keyboard.press('Escape') + await hoverImage.waitFor({ state: 'detached' }) + await link.press('Tab') + await page.keyboard.press('Shift+Tab') + await hoverImage.waitFor() + await page.keyboard.press('Escape') + await hoverImage.waitFor({ state: 'detached' }) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) }) + await page.mouse.move(0, 0) + await link.hover() + await hoverImage.waitFor() + const linkRect = await link.boundingBox() + if (linkRect === null) throw new Error('image link is not visible') + const previewRect = await hoverImage.evaluate((element) => { + const rect = element.parentElement!.parentElement!.getBoundingClientRect() + return { top: rect.top, bottom: rect.bottom } + }) + expect(previewRect.bottom <= linkRect.y || previewRect.top >= linkRect.y + linkRect.height).toBe(true) + const thumbnailRect = await hoverImage.boundingBox() + if (thumbnailRect === null) throw new Error('image preview is not visible') + expect(thumbnailRect.y + thumbnailRect.height).toBeLessThanOrEqual(previewRect.bottom) + await page.mouse.click(linkRect.x + linkRect.width / 2, linkRect.y + linkRect.height / 2) + await expect.poll(() => page.locator('[data-image-preview] img').evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) }, 60_000) }) diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 16a9d7d0a9..ca1ef80e6d 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -107,8 +107,12 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await dialog.getByRole('button', { name: '立即启用', exact: true }).waitFor({ timeout: 20_000 }) await dialog.getByText('版本 2.0.0', { exact: true }).waitFor() const lookups = (await readFile(join(profile, '.registry-lookups'), 'utf8')).trim().split('\n') - expect(lookups).toHaveLength(1) - expect(JSON.parse(lookups[0]!)).toContain('--registry=' + MIRROR) + .map(line => JSON.parse(line) as string[]) + // The inspection asked the mirror first, and each install attempt checked the named package's + // DSH peers at the registry that attempt would use: the mirror, then pnpm's own. + expect(lookups.map(call => call.filter(argument => argument.startsWith('--registry=')))).toEqual([ + ['--registry=' + MIRROR], ['--registry=' + MIRROR], [], + ]) await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click() await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor() await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor() diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index abf392c727..4c95f6f14a 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -17,6 +17,7 @@ const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session. const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url)) const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url)) +const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. @@ -29,6 +30,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + let releasePreparation: (() => void) | undefined beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -46,6 +48,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { }, 120_000) afterAll(async () => { + releasePreparation?.() await browser?.close() await scaffold?.close() }) @@ -58,9 +61,38 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) const settled = scaffold.whenTurnSettled() - await input.fill(PROMPT) - await input.press('Enter') - const sessionId = await settled + const gate = Promise.withResolvers() + const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { + let held = false + for await (const chunk of next()) { + yield chunk + if (!held && chunk.type === 'tool-call-delta' && chunk.name) { + held = true + await gate.promise + } + } + }, { prepend: true }) + releasePreparation = () => { gate.resolve(undefined); dispose() } + const observePreparation = async () => { + try { + await input.fill(PROMPT) + await input.press('Enter') + const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first() + await row.waitFor({ state: 'attached', timeout: 30_000 }) + await expandOwningTurnProcess(page, row) + await row.waitFor({ state: 'visible' }) + await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor() + expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false) + expect(await row.getByRole('button').count()).toBe(0) + expect(await row.locator('pre').count()).toBe(0) + const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]') + await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE) + } finally { + releasePreparation?.() + } + } + const [sessionId] = await Promise.all([settled, observePreparation()]) + await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } @@ -118,6 +150,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { await page.reload({ waitUntil: 'load' }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) } const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index b3527fbfcf..669913fd69 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 946502676efcaa244e60f227aa9a1f2e17580c21 -config-catalog.zh.md: fb75cd11505924a905ad0c854d5787c0f4fbb12e +config-catalog.md: e3161d1e72e5a586b9b9662e1f8005caa8d320ad +config-catalog.zh.md: eeba33336e3a6d3d699068835521d13867a34427 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 946502676e..e3161d1e72 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2123,6 +2123,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -2134,7 +2136,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts) @@ -2706,10 +2708,14 @@ Requires: `skills` export interface Config { /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ assetRoot?: string + /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ + node?: string + /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ + cli?: string | false } ``` -Source: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts) +Source: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index fb75cd1150..eeba33336e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2125,6 +2125,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -2136,7 +2138,7 @@ export interface Config { } ``` -来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) +来源: [`packages/boot/plugin-manager/src/index.ts:40`](../packages/boot/plugin-manager/src/index.ts) @@ -2708,10 +2710,14 @@ export interface Config { export interface Config { /** Absolute assets directory containing the three skill folders and shared scripts; defaults to packaged assets. */ assetRoot?: string + /** Standalone Node executable; defaults to the current executable outside Electron and SEA. */ + node?: string + /** Absolute LibreOffice Kit CLI entry; false explicitly disables CLI access. */ + cli?: string | false } ``` -来源: [`packages/skill/skill-office/src/index.ts:15`](../packages/skill/skill-office/src/index.ts) +来源: [`packages/skill/skill-office/src/index.ts:16`](../packages/skill/skill-office/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 7b80ba2548..5609693899 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 0b9d154a8c3eb56b6a0334b66eee2314ea0b1c41 -event-producer-consumer.zh.md: 1662f00f62af1898f4ed431b62f9402341d836e3 +event-producer-consumer.md: 217f2de54302cfd11b44475f3382770f2f18a76e +event-producer-consumer.zh.md: 809fdc869c0b9d6d2251750e99cabc8fe220e399 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0b9d154a8c..217f2de543 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -26,7 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -55,9 +55,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 1662f00f62..809fdc869c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:59`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -28,7 +28,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:45`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -57,9 +57,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:230`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -103,4 +103,4 @@ | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` | | `slots/changed` | `ui-renderer` (`emit`) | - | -Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. +维护模式:自动生成:Cordis 事件声明及生产方/监听方关系从仓库 TypeScript Program 中解析。 diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index bab89e6efd..10e0a602c4 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -64,7 +64,7 @@ | spawn | spawn | | | | | steering | steering | steering(中途引导) | | | | job id | job id | | 任务 id | 保留英文 | -| subagent | subagent | | | | +| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 | | transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 | | Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 | | waterfall | waterfall | waterfall(瀑布式事件) | | | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 4df5918665..54a45c2704 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7 -boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4 +boot.md: 07391705977bc7b3cba9377e3142feea7d62f69f +boot.zh.md: 64ff36d41295f529b81f2856f2fbc77f09efa808 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 6ec2204ffe..0739170597 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -16,7 +16,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first. -`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. +`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from. @@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** Read exact plugin-version exemptions saved in this profile. + * @returns Accepted package-name@version keys with the runtime versions they may run on, and any + * record or file problem the reader rejected, which the caller reports instead of failing. + */ +@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] } + +/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins. + * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias. + * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime. + * @param enabled Whether to grant rather than revoke the exemption. + * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss. + * @returns Saved and runtime outcomes. Startup-only profiles require restart. + */ +@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise + /** Read current plugins, including why a row cannot be changed through the profile patch. * @returns Current runtime entries with persistent patch targets. */ diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index f5fde00f0c..64ff36d412 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -16,7 +16,7 @@ `PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。 -`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 +`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。 @@ -89,6 +89,21 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts) Manage profile files and apply their declared reload lifecycle. ```ts cordis-catalog +/** Read exact plugin-version exemptions saved in this profile. + * @returns Accepted package-name@version keys with the runtime versions they may run on, and any + * record or file problem the reader rejected, which the caller reports instead of failing. + */ +@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] } + +/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins. + * @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias. + * @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime. + * @param enabled Whether to grant rather than revoke the exemption. + * @param acceptRisk Required true for grants after the user accepts possible crashes and data loss. + * @returns Saved and runtime outcomes. Startup-only profiles require restart. + */ +@Remote setVersionExemption(packageVersion: string, runtimeVersion: string, enabled: boolean, acceptRisk?: boolean): Promise + /** Read current plugins, including why a row cannot be changed through the profile patch. * @returns Current runtime entries with persistent patch targets. */ diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 56327598b7..be624bc56d 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/conversation.md -conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310 -conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746 +conversation.md: dca3d513398cb1e29a8fe16d00fd1bac9c21ce58 +conversation.zh.md: 18d2a2b282f0ccb02cc903855cf65d542e4160da diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 9455858aca..dca3d51339 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -13,7 +13,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se | Concept | Owner and purpose | |---|---| | Event Definition | A business package matches one durable or Client-only transient event at a time, correlates it by stable `(kind, id)`, folds deterministic State, and optionally materializes one target node. | -| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. | +| Context | The engine-owned ordered Matches and current State for one `(kind, id)`. Durable and transient events may be starts. The earliest loaded start initializes State; later Matches update it. Update-only evidence stays pending until its start is loaded. | | Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. | | View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. | | Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. | @@ -69,7 +69,7 @@ Use the producer-owned branded id type across the process boundary. Put the `Ses Incremental events are supported. Prefer whole-value checkpoints when the producer can emit them cheaply, because they remain useful when the start is outside the loaded window. Each delta must carry the stable id and produce deterministic State when replayed in ascending log `seq`; it must not depend on live-only memory. If the current history window contains only updates, the assembler keeps a pending Context and builds no State until an older page supplies the start. If the product must render before the start is loaded, a terminal or checkpoint event must carry enough whole fallback state for the Definition to build that result directly; do not recover it by scanning unrelated events. -Live Assistant deltas arrive as Client-only `assistant/live-chunk` updates. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. Transient events can only be updates; `start()` receives a standard `SessionEvent`. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream. +Live Assistant deltas arrive as Client-only `assistant/live-chunk` events. Reconnect baselines expand the active process-local compact stream into the same transient events, while durable `assistant/message` and `assistant/attempt` events embed complete compact streams for history replay. A transient event may initialize a Context. A named tool delta and its later tool/call can both match as start under the same callId; only the earliest calls start(), and later Matches call update(). Historical pages do not expand settled messages into live deltas. A Definition that consumes Assistant output handles live chunks and durable settlements in the same `match()` and `update()` methods, while unrelated Definitions return `null` without expanding a stream. ## Definition and typed Chat payload @@ -243,11 +243,11 @@ export function apply(ctx: ClientContext): void { } ``` -`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and calls `start` once for a standard event or `update` for a standard or packed event. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics. +`match(event)` is an identity extractor, not a fold: it receives only the current `SessionEventLike` and returns the Definition-local id and lifecycle role. After a match, the assembler locates the Context by `(kind, id)` and initializes from its earliest loaded start, whether durable or transient. Every later Match calls `update`, including another start. Removing transient Matches reselects the start from remaining evidence and recomputes State; no remaining start leaves State undefined. Both functions return the State that the engine adopts; returning a new immutable value is preferred, but a function that mutates and returns the same object has the same adoption semantics. `buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. -`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. +`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from the matched event ordering, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. ## Predecessor reads @@ -274,7 +274,7 @@ With `D` registered Definitions, one incoming scalar event or packed run perform Add focused tests that establish these outcomes: 1. A complete window passed through replace produces the expected final State, Location data, Node payload, and `anchorSeq`. -2. An update-only tail stays pending; prepending the unique start produces the same result as a complete replace. +2. An update-only tail stays pending; prepending its start produces the same result as a complete replace. 3. Initial history followed by live append produces the same result as replaying the combined window. 4. Prepending an older page adds earlier rows without replacing existing keyed Node values whose data did not change. 5. Repeated visible deltas preserve `context.key` and publish at most once per animation frame when requested. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 2e7c48ab5f..18d2a2b282 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -13,7 +13,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session | 概念 | Owner 与用途 | |---|---| | Event Definition | 业务包一次匹配一个持久 event 或 Client-only 瞬态 event,以稳定 `(kind, id)` 关联输入、折叠确定性 State,并可选择 materialize 一个 target node。 | -| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 | +| Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。持久和瞬态事件都可以作为 start。当前最早的 start 初始化 State,后续 Match 更新它。只有 update 的证据保持 pending,直到其 start 加载。 | | Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 | | View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 | | Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 | @@ -69,7 +69,7 @@ Group 存储在安装前校验完整提交结果:根 Group 引用与记录一 系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 `seq` 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。 -实时 Assistant delta 作为 Client-only `assistant/live-chunk` update 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 只能充当 update;`start()` 只接收标准 `SessionEvent`。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。 +实时 Assistant delta 作为 Client-only `assistant/live-chunk` event 到达。重连 baseline 会把活跃的进程内紧凑 stream 展开为相同的瞬态 event,持久 `assistant/message` 与 `assistant/attempt` event 则嵌入完整紧凑 stream 供历史回放。瞬态 event 可以初始化 Context。具名工具 delta 和后续 tool/call 可以按同一个 callId 匹配为 start;只有最早的 Match 调用 start(),后续 Match 调用 update()。历史分页不把已结束消息展开为实时 delta。消费 Assistant 输出的 Definition 在同一组 `match()` 与 `update()` 方法里处理 live chunk 与持久 settlement,其他 Definition 直接返回 `null`,无需展开 stream。 ## Definition 与类型化 Chat payload @@ -243,7 +243,7 @@ export function apply(ctx: ClientContext): void { } ``` -`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;标准 event 可触发一次 `start`,标准或 packed event 可把当前 State 交给 `update`。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。 +`match(event)` 是身份提取器,不是 fold:它只能收到当前 `SessionEventLike`,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 `(kind, id)` 定位 Context;当前最早的 start 初始化 State,不论它是持久事件还是瞬态事件。后续所有 Match,包括其他 start,都调用 `update`。移除瞬态 Match 后,从剩余事件重新选择 start 并重算 State;没有剩余 start 时 State 为 undefined。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。 `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 @@ -274,7 +274,7 @@ Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的 添加聚焦测试,证明以下结果: 1. 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与 `anchorSeq`。 -2. 只有 update 的尾部窗口保持 pending;prepend 唯一 start 后,结果与完整 replace 相同。 +2. 只有 update 的尾部窗口保持 pending;prepend 其 start 后,结果与完整 replace 相同。 3. 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。 4. prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。 5. 重复的可见 delta 保持 `context.key`,并在请求 `animation-frame` 时每帧最多发布一次。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 29fbf9d0ae..51b124f552 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: aa9af309c03ebedae353139c4017d19f2607dd7d -core.zh.md: b18ff5bba1c0309e9b9dcdf1f3e97adb5895caf6 +core.md: 64344148708b29fc186a2e33ef187025c4bc5300 +core.zh.md: 0b85519b916490d6692a31f59ed89527ce72fa43 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index aa9af309c0..6434414870 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -510,6 +510,12 @@ async list(): Promise */ async resolve(id?: string): Promise +/** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ +@Remote('read') readDocument(agentPreset: string): Promise + /** Bind an unpublished Agent to the current preset revision. * @param ctx Agent context from its setup callback. * @param id Requested preset, or the default. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index b18ff5bba1..0b85519b91 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -520,6 +520,12 @@ async list(): Promise */ async resolve(id?: string): Promise +/** Read one declaration's child plugin list as YAML, for viewing only. + * @param agentPreset Preset identity. + * @returns The declared composition beside its published metadata. + */ +@Remote('read') readDocument(agentPreset: string): Promise + /** Bind an unpublished Agent to the current preset revision. * @param ctx Agent context from its setup callback. * @param id Requested preset, or the default. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index d020d136a3..2b04d1eacd 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 8e1f4f7c8c8b36de366aa8a1b795106f4ae32283 -tool-catalog.zh.md: 0995196921738ad4b5a5deda0cf48c83b1d4e664 +tool-catalog.md: 3e4407192ee75b61537d9fd09f3d68381d1e803e +tool-catalog.zh.md: cf78ee4d4f0d8a8bdddbf4ad3987a18bd1f300d1 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 8e1f4f7c8c..3e4407192e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -52,7 +52,7 @@ This table connects model-visible tool names to the plugin package and service s ### `plugin_manager` -List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. +List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one. ```json { @@ -67,7 +67,9 @@ List plugins or bundles in the current profile, enable or disable them, install "set_plugin", "set_bundle", "install_bundle", - "remove_bundle" + "remove_bundle", + "list_version_exemptions", + "set_version_exemption" ] }, "target": { @@ -76,7 +78,15 @@ List plugins or bundles in the current profile, enable or disable them, install }, "enabled": { "type": "boolean", - "description": "Required for set operations; defaults to true for installation." + "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." + }, + "runtimeVersion": { + "type": "string", + "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." + }, + "acceptRisk": { + "type": "boolean", + "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." }, "approvedBuilds": { "type": "array", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 0995196921..cf78ee4d4f 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -56,7 +56,7 @@ ### `plugin_manager` -列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。 +列出当前 profile 中的插件或组合包,启用或禁用它们,安装组合包或移除已安装的组合包。每项操作都要求 danger-full-access 权限或本次调用的批准。批准不改变会话权限模式。变更影响该 profile 的所有会话。先列出条目以获取准确标识。包安装可能运行已获批准的构建脚本。支持热更新的 profile 立即应用变更;仅启动时加载的 profile 需要重启。不兼容的 DSH peer 依赖会阻止安装和激活。版本豁免可能导致崩溃和数据丢失:授权前必须警告用户,并获得用户对精确插件版本与运行时版本组合的明确许可。 ```json { @@ -71,7 +71,9 @@ "set_plugin", "set_bundle", "install_bundle", - "remove_bundle" + "remove_bundle", + "list_version_exemptions", + "set_version_exemption" ] }, "target": { @@ -80,7 +82,15 @@ }, "enabled": { "type": "boolean", - "description": "Required for set operations; defaults to true for installation." + "description": "Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes." + }, + "runtimeVersion": { + "type": "string", + "description": "For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range." + }, + "acceptRisk": { + "type": "boolean", + "description": "For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough." }, "approvedBuilds": { "type": "array", diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 7f47a6c9eb..b7daafd1e3 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 1a746a83eb966fba6ae40b61061d42bf478e1c84 -README.zh.md: 7b37548f6ad894b761457df80278eeed7aa6ca7b +README.md: e0c986e290b1744017fca99e3a2553a68d754ef6 +README.zh.md: 8be11a7c5aea1e299c4ca45cde4b1c9dfa08ec4b diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 1a746a83eb..e0c986e290 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -38,6 +38,8 @@ When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgra Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads. + Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 7b37548f6a..8be11a7c5a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -38,6 +38,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。 + Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 7a580c5fac..92255a10ee 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -158,7 +158,7 @@ export class RemoteStreamMuxClient { signal: AbortSignal, inbox: StreamInbox, ): UplinkPump { - const stopped = Promise.withResolvers>() + let stopped: PromiseWithResolvers> | undefined const interruption: IteratorReturnResult = { value: undefined, done: true } const uplinkIterator = uplink[Symbol.asyncIterator]() const state = { stopping: false, exhausted: false, released: false } @@ -171,7 +171,12 @@ export class RemoteStreamMuxClient { const done = (async (): Promise => { try { while (true) { + const stoppingBeforeRead = state.stopping + if (stoppingBeforeRead) return + // A stop promise belongs to one read, not the whole uplink history. + stopped = Promise.withResolvers>() const next = await Promise.race([uplinkIterator.next(), stopped.promise]) + stopped = undefined if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return if (next.done === true) { state.exhausted = true @@ -183,6 +188,7 @@ export class RemoteStreamMuxClient { } catch (error) { inbox.fail(error) } finally { + stopped = undefined release() } })() @@ -191,7 +197,7 @@ export class RemoteStreamMuxClient { stop: () => { if (state.stopping) return state.stopping = true - stopped.resolve(interruption) + stopped?.resolve(interruption) release() }, } diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 777da35065..7c7a29b66a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1164,19 +1164,24 @@ async function *cancellableStream( ? Reflect.apply(asyncFactory, source, []) as AsyncIterator : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []) let rejectAbort: ((error: unknown) => void) | undefined - const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { rejectAbort?.(streamAbortFailure(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) while (true) { + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) + // Reusing a pending cancellation promise retains every completed race. + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + // next() can abort and throw synchronously before the race subscribes. + void aborted.catch(() => undefined) const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) + rejectAbort = undefined if (next.done === true) return yield next.value } } finally { + rejectAbort = undefined signal.removeEventListener('abort', onAbort) // The uplink closes first so a method blocked on `uplink.next()` unwinds // before its iterator is asked to return. @@ -1209,9 +1214,10 @@ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway */ class UplinkDecoder implements AsyncIterable, AsyncIterator { private readonly source: AsyncIterator - private readonly interrupted = Promise.withResolvers>() + private readonly interrupted = new Set>>() private readonly onAbort = (): void => { - this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason)) + const failure = streamAbortFailure(this.endpoint, this.signal.reason) + for (const read of this.interrupted) read.reject(failure) } private closed = false @@ -1223,9 +1229,6 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { private readonly abort: (reason: unknown) => void, ) { this.source = uplink[Symbol.asyncIterator]() - // The rejection settles the race inside next(); an abort with no read in - // flight must not surface as an unhandled rejection. - void this.interrupted.promise.catch(() => undefined) signal.addEventListener('abort', this.onAbort, { once: true }) } @@ -1237,7 +1240,17 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { // A cancelled stream reports the cancellation on every read, closed or not. if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) if (this.closed) return UPLINK_DONE - const next = await Promise.race([this.source.next(), this.interrupted.promise]) + // Only pending reads belong to the decoder; finish() must wake all of them. + const interrupted = Promise.withResolvers>() + this.interrupted.add(interrupted) + // The source can abort and throw synchronously before the race subscribes. + void interrupted.promise.catch(() => undefined) + let next: IteratorResult + try { + next = await Promise.race([this.source.next(), interrupted.promise]) + } finally { + this.interrupted.delete(interrupted) + } if (next.done === true) { this.finish() return UPLINK_DONE @@ -1270,7 +1283,7 @@ class UplinkDecoder implements AsyncIterable, AsyncIterator { private finish(): void { this.closed = true this.signal.removeEventListener('abort', this.onAbort) - this.interrupted.resolve(UPLINK_DONE) + for (const read of this.interrupted) read.resolve(UPLINK_DONE) } } diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 4c2f0e27c6..87aaaa9f75 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { once } from 'node:events' +import { queryObjects } from 'node:v8' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket, { type RawData } from 'ws' import { Context, symbols } from '@deepseek-ai/cordis' @@ -56,6 +57,7 @@ class FeedService extends TypertRemoteService { readonly peeked: unknown[] = [] readonly peers: PeerScope[] = [] leftover: AsyncIterator | undefined + source: Iterable = [] returns = 0 constructor(ctx: Context) { @@ -82,6 +84,11 @@ class FeedService extends TypertRemoteService { yield `${label}:two` } + @Remote({ mode: 'stream' }) + items(): Iterable { + return this.source + } + @Remote({ mode: 'stream' }) *invalid(): Iterable { yield 42 as unknown as string @@ -541,6 +548,110 @@ describe('Typert Remote streams', () => { await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) }) + it('finishes every pending uplink read when the downlink returns', async () => { + const { ctx, service } = await setup(false) + const reads = [ + Promise.withResolvers>(), + Promise.withResolvers>(), + ] + let index = 0 + const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, + uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) }, + }) + const iterator = source[Symbol.asyncIterator]() + const completed: IteratorResult[] = [] + const pending: Promise[] = [] + try { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const uplink = service.leftover! + pending.push(...reads.map(() => uplink.next().then((result) => { completed.push(result) }))) + await iterator.return?.() + expect(completed).toEqual([ + { done: true, value: undefined }, + { done: true, value: undefined }, + ]) + expect(returned).toHaveBeenCalledOnce() + } finally { + for (const read of reads) read.resolve({ done: true, value: undefined }) + await Promise.all(pending) + await iterator.return?.() + } + }) + + it.each(['downlink', 'uplink'] as const)('releases consumed %s read results while the stream stays open', async (direction) => { + class ReadResult implements IteratorYieldResult { + readonly done = false + readonly value = 'item' + } + const { ctx, service } = await setup(false) + service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) } + const source = await ctx.typertGateway.stream(direction === 'downlink' + ? { namespace: 'feed', method: 'items', args: {} } + : { + namespace: 'feed', method: 'echo', args: { prefix: '' }, + uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) }, + }) + const iterator = source[Symbol.asyncIterator]() + try { + for (const count of [64, 256]) { + for (let index = 0; index < count; index++) { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' }) + } + // queryObjects collects garbage; the suspended read can retain its last result, not the history. + expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2) + } + } finally { + await iterator.return?.() + } + }) + + it('does not read another downlink item after cancellation between reads', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const next = vi.fn((): IteratorResult => ({ done: false, value: 'item' })) + service.source = { [Symbol.iterator]: () => ({ next }) } + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'items', args: {}, signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + try { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'item' }) + abort.abort(new Error('caller cancelled between reads')) + await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/cancelled' }) + expect(next).toHaveBeenCalledOnce() + } finally { + await iterator.return?.() + } + }) + + it.each(['downlink', 'uplink'] as const)('handles a synchronous %s read that aborts and throws', async (direction) => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const failure = new Error('source aborted and threw') + const next = (): never => { + abort.abort(failure) + throw failure + } + service.source = { [Symbol.iterator]: () => ({ next }) } + const source = await ctx.typertGateway.stream(direction === 'downlink' + ? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal } + : { + namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, + uplink: { [Symbol.asyncIterator]: () => ({ next }) }, + }) + const iterator = source[Symbol.asyncIterator]() + try { + if (direction === 'uplink') { + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + } + await expect((direction === 'downlink' ? iterator : service.leftover!).next()).rejects.toBe(failure) + } finally { + await iterator.return?.() + } + }) + it('reports done on every read after the uplink ended', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ @@ -1461,6 +1572,7 @@ function descriptors(): InvocationDescriptor[] { stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), + stream('items', [], z.string()), stream('invalid', [], z.string()), stream('nonJson', [], z.unknown()), stream('missing', [], z.string()), diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index aeec14ecf1..b0c7d4f390 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1,3 +1,4 @@ +import { queryObjects } from 'node:v8' import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' @@ -2804,6 +2805,81 @@ describe('Client Typert API', () => { }) describe('Remote stream client carrier lifecycle', () => { + it('does not pull another uplink item when sending ends the stream', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const socket = FakeWebSocket.sockets[0]! + const send = socket.send.bind(socket) + const sent = vi.spyOn(socket, 'send').mockImplementation((text) => { + send(text) + const frame = parseRemoteStreamClientMessage(text) + if (frame.type === 'item') socket.receive({ type: 'end', streamId: frame.streamId }) + }) + const next = vi.fn(async (): Promise> => ({ done: false, value: 'item' })) + const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) + try { + const iterator = client.open('probe/attach', {}, new AbortController().signal, { + [Symbol.asyncIterator]: () => ({ next, return: returned }), + })[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + expect(next).toHaveBeenCalledOnce() + expect(returned).toHaveBeenCalledOnce() + } finally { + await client.close() + sent.mockRestore() + } + }) + }) + + it('releases sent uplink read results while the stream stays open', async () => { + await withFakeWebSocket('https://harness.example', async () => { + class ReadResult implements IteratorYieldResult { + readonly done = false + readonly value = 'item' + } + const blocked = Promise.withResolvers() + const lastRead = Promise.withResolvers>() + let reads = 0 + const uplink: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (reads++ < 256) return Promise.resolve(new ReadResult()) + blocked.resolve(undefined) + return lastRead.promise + }, + return: async () => { + lastRead.resolve({ done: true, value: undefined }) + return { done: true, value: undefined } + }, + }), + } + const client = new RemoteStreamMuxClient() + client.start() + const iterator = client.open('probe/attach', {}, new AbortController().signal, uplink)[Symbol.asyncIterator]() + const first = iterator.next() + // Closing the carrier during failed assertions can reject this pending read. + void first.catch(() => undefined) + try { + await blocked.promise + const socket = FakeWebSocket.sockets[0]! + expect(socket.sent).toHaveLength(257) + const opened = parseRemoteStreamClientMessage(socket.sent[0]!) + if (opened.type !== 'open') throw new Error('fixture expected an open frame') + socket.receive({ type: 'item', streamId: opened.streamId, value: 'ready' }) + await expect(first).resolves.toEqual({ done: false, value: 'ready' }) + // A full GC runs while the uplink pump is still waiting for its next item. + expect(queryObjects(ReadResult, { format: 'count' })).toBeLessThanOrEqual(2) + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + } finally { + await client.close() + await first.catch(() => undefined) + await iterator.return?.(undefined) + } + }) + }) + it('connects to the shell-owned Host while the document uses a local asset origin', async () => { await withFakeWebSocket('dsh-app://app', async () => { vi.stubGlobal('__DSH_TRANSPORT__', { streamBaseUrl: 'http://127.0.0.1:43210' }) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ff61bc8512..8a77721112 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 70a8e235ac5846061ea47e79e7dc575881110a50 -README.zh.md: 9849b033e01827a4c50a22b72ea20deebb0fdd85 +README.md: 729a4b1552cd28e89698580860f8c9e31043a76c +README.zh.md: c401978fdf05651479c8c6b74b01d48287fa2b63 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 70a8e235ac..729a4b1552 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -49,6 +49,12 @@ Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-mani A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile